#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自动更新模块：支持全量安装包与 bsdiff4 增量更新（Delta Update）

远端 version.json（或 update_url 指向的 JSON）格式：
{
  "version": "1.1.0",
  "release_notes": "修复了 UI 卡顿",
  "full_installer_url": "http://.../Setup_v1.1.0.exe",
  "delta_updates": {
    "1.0.0": {
      "patch_url": "http://.../v1.0.0_to_v1.1.0.patch",
      "new_exe_sha256": "abc123..."
    }
  }
}
- 若当前版本在 delta_updates 中，则优先使用增量（下载 patch，bsdiff4 打补丁后替换 exe）
- 否则下载 full_installer_url 进行完整安装
"""

import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, Optional, Tuple

# 可选：requests 用于带进度的下载；若无则回退 urllib
try:
    import requests
    _HAS_REQUESTS = True
except ImportError:
    _HAS_REQUESTS = False

try:
    import bsdiff4
    _HAS_BSDIFF = True
except ImportError:
    _HAS_BSDIFF = False


def _version_json_path() -> Path:
    """获取 version.json 的路径。
    打包后：优先从安装目录（exe 同目录）读取，便于安装包展开的 version.json 生效；若无则从 MEIPASS 读取。
    未打包：从项目根目录或运行目录读取。
    """
    if getattr(sys, "frozen", False):
        # 安装包会将 version.json 展开到 {app}，与 ServerManager.exe 同目录
        install_dir = Path(sys.executable).resolve().parent
        external = install_dir / "version.json"
        if external.exists():
            return external
        return Path(sys._MEIPASS) / "version.json"
    project_root = Path(__file__).resolve().parents[2]
    resource_version = project_root / "resources" / "version.json"
    if resource_version.exists():
        return resource_version
    return project_root / "version.json"


def get_current_version() -> str:
    """读取当前应用版本号"""
    path = _version_json_path()
    if not path.exists():
        path = Path.cwd() / "resources" / "version.json"
    if not path.exists():
        path = Path.cwd() / "version.json"
    if not path.exists():
        return "0.0.0"
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data.get("version", "0.0.0").strip()
    except Exception:
        return "0.0.0"


def get_version_info() -> Dict[str, Any]:
    """读取当前 version.json 完整内容"""
    path = _version_json_path()
    if not path.exists():
        path = Path.cwd() / "resources" / "version.json"
    if not path.exists():
        path = Path.cwd() / "version.json"
    if not path.exists():
        return {"version": "0.0.0", "release_notes": ""}
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {"version": "0.0.0", "release_notes": ""}


def _parse_version(v: str) -> Tuple[int, ...]:
    """将版本字符串转为可比较的元组"""
    parts = []
    for s in v.strip().replace("-", ".").split("."):
        s = "".join(c for c in s if c.isdigit())
        parts.append(int(s) if s else 0)
    return tuple(parts)


def version_less(a: str, b: str) -> bool:
    """True 表示 a < b（有可用更新）"""
    return _parse_version(a) < _parse_version(b)


def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[str, Any]]:
    """
    从 update_url 拉取更新清单，解析新格式：
    version, release_notes, full_installer_url, delta_updates { "from_ver": { patch_url, new_exe_sha256 } }
    兼容旧格式：download_url 视为 full_installer_url。
    """
    url = update_url.strip().rstrip("/")
    if not url.lower().startswith("http"):
        return None
    if not url.lower().endswith(".json"):
        url = url + "/version.json" if not url.endswith("version.json") else url
    try:
        if _HAS_REQUESTS:
            r = requests.get(url, headers={"User-Agent": "ServerManager-Updater/1.0"}, timeout=timeout)
            r.raise_for_status()
            data = r.json()
        else:
            import urllib.request
            req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"})
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                data = json.loads(resp.read().decode("utf-8"))
        if isinstance(data, dict) and "version" in data:
            if "full_installer_url" not in data and "download_url" in data:
                data["full_installer_url"] = data["download_url"]
            return data
        return None
    except Exception:
        return None


def _manifest_delta_map(manifest: Dict[str, Any]) -> Dict[str, Any]:
    delta_updates = manifest.get("delta_updates") or manifest.get("deltaUpdates") or manifest.get("delta") or {}
    return delta_updates if isinstance(delta_updates, dict) else {}


def get_delta_kind(delta: Dict[str, Any]) -> str:
    """Return 'file_zip', 'bsdiff_exe', or '' for unsupported delta entries."""
    if not isinstance(delta, dict):
        return ""
    kind = str(delta.get("type") or delta.get("kind") or "").strip().lower().replace("_", "-")
    url = get_delta_download_url(delta)
    if kind in {"file-zip", "file-delta", "archive", "zip"}:
        return "file_zip"
    if kind in {"bsdiff", "bsdiff-exe", "exe-patch"}:
        return "bsdiff_exe"
    if url.lower().endswith((".zip", ".smdelta")) or delta.get("format") == "server-manager-file-delta-v1":
        return "file_zip"
    if delta.get("new_exe_sha256") and (delta.get("patch_url") or delta.get("patchUrl")):
        return "bsdiff_exe"
    return ""


def get_delta_download_url(delta: Dict[str, Any]) -> str:
    """Return the URL field used by both new file-level deltas and legacy exe patches."""
    if not isinstance(delta, dict):
        return ""
    return (
        delta.get("package_url")
        or delta.get("packageUrl")
        or delta.get("delta_url")
        or delta.get("deltaUrl")
        or delta.get("patch_url")
        or delta.get("patchUrl")
        or ""
    ).strip()


def get_delta_expected_sha256(delta: Dict[str, Any]) -> str:
    kind = get_delta_kind(delta)
    if kind == "file_zip":
        return str(delta.get("sha256") or delta.get("package_sha256") or delta.get("packageSha256") or "").strip().lower()
    if kind == "bsdiff_exe":
        return str(delta.get("new_exe_sha256") or "").strip().lower()
    return ""


def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]:
    """
    从 manifest 的 delta_updates 中取当前版本对应的增量信息。
    支持新的文件级增量 zip，也兼容旧的 ServerManager.exe bsdiff patch。
    """
    d = _manifest_delta_map(manifest).get(current_version.strip())
    if not isinstance(d, dict):
        return None
    if get_delta_kind(d) and get_delta_download_url(d):
        return d
    return None


def download_file(
    url: str,
    dest_path: Path,
    progress_callback: Optional[Any] = None,
    timeout: int = 60,
) -> bool:
    """下载文件到 dest_path，可选进度回调 progress_callback(percent: int)"""
    dest_path = Path(dest_path)
    try:
        if _HAS_REQUESTS:
            r = requests.get(
                url,
                headers={"User-Agent": "ServerManager-Updater/1.0"},
                stream=True,
                timeout=timeout,
            )
            r.raise_for_status()
            total = int(r.headers.get("Content-Length", 0)) or None
            read = 0
            dest_path.parent.mkdir(parents=True, exist_ok=True)
            with open(dest_path, "wb") as f:
                for chunk in r.iter_content(chunk_size=65536):
                    if chunk:
                        f.write(chunk)
                        read += len(chunk)
                        if progress_callback and total and total > 0:
                            progress_callback(min(100, int(100 * read / total)))
            if progress_callback:
                progress_callback(100)
            return True
        else:
            import urllib.request
            req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"})
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                total = int(resp.headers.get("Content-Length", 0)) or None
                data = b""
                read = 0
                chunk_size = 8192
                while True:
                    part = resp.read(chunk_size)
                    if not part:
                        break
                    data += part
                    read += len(part)
                    if progress_callback and total and total > 0:
                        progress_callback(min(100, int(100 * read / total)))
            dest_path.parent.mkdir(parents=True, exist_ok=True)
            dest_path.write_bytes(data)
            if progress_callback:
                progress_callback(100)
            return True
    except Exception:
        return False


def clean_up_old_version() -> None:
    """
    启动时调用：删除当前 exe 所在目录下以 .old 结尾的残留文件，
    即上一次「重命名大法」更新退下来的旧程序，静默删除不报错。
    """
    if getattr(sys, "frozen", False):
        install_dir = os.path.dirname(os.path.abspath(sys.executable))
    else:
        install_dir = os.path.dirname(os.path.abspath(__file__)) or os.getcwd()
    try:
        for name in os.listdir(install_dir) or []:
            if name.endswith(".old"):
                path = os.path.join(install_dir, name)
                if os.path.isfile(path):
                    try:
                        os.remove(path)
                    except Exception:
                        pass
    except Exception:
        pass


def _write_update_log(install_dir: str, message: str) -> None:
    """向安装目录下的 logs/update_launcher.log 写入，便于确认是否走了 mini_updater。"""
    try:
        from datetime import datetime
        log_dir = os.path.join(install_dir, "logs")
        os.makedirs(log_dir, exist_ok=True)
        log_path = os.path.join(log_dir, "update_launcher.log")
        with open(log_path, "a", encoding="utf-8") as f:
            f.write("[%s] %s\n" % (datetime.now().isoformat(), message))
    except Exception:
        pass


def apply_update_and_restart(
    new_exe_path: str,
    target_exe_name: Optional[str] = None,
) -> None:
    """
    独立进程外更新：拉起 mini_updater.exe，由其等待本进程退出后替换 exe 并以正确 cwd 重启。
    若 mini_updater.exe 不存在则回退到“死循环重试”的 .bat。
    调用后本进程会立即 os._exit(0)，不会返回。
    """
    new_exe_path = os.path.abspath(new_exe_path)
    current_exe_path = os.path.abspath(sys.executable)
    if not os.path.isfile(new_exe_path):
        return
    install_dir = os.path.normpath(os.path.dirname(current_exe_path))
    target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"

    # 优先使用安装目录下的 mini_updater.exe（与 ServerManager.exe 同目录），必须用绝对路径
    mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
    if sys.platform == "win32" and os.path.isfile(mini_updater_exe):
        try:
            pid = os.getpid()
            creationflags = 0x00000008  # DETACHED_PROCESS
            subprocess.Popen(
                [
                    mini_updater_exe,
                    "--pid", str(pid),
                    "--install-dir", install_dir,
                    "--new-exe-path", new_exe_path,
                    "--target-exe-name", target_name,
                ],
                cwd=install_dir,
                close_fds=True,
                creationflags=creationflags,
                shell=False,
            )
            _write_update_log(install_dir, "已拉起 mini_updater.exe (pid=%s)" % pid)
            os._exit(0)
            return
        except Exception as e:
            _write_update_log(install_dir, "拉起 mini_updater.exe 失败: %s，改用 .bat" % e)
    else:
        _write_update_log(
            install_dir,
            "mini_updater.exe 未找到 (路径: %s)，使用 .bat 回退" % mini_updater_exe,
        )

    # 回退：.bat 死循环重试（仅 Windows）
    tmp = tempfile.gettempdir()
    bat_path = os.path.join(tmp, "apply_update_%s.bat" % os.getpid())
    bat_lines = [
        "@echo off",
        ":retry",
        'move /y "%s" "%s" >nul 2>&1' % (new_exe_path, current_exe_path),
        "if errorlevel 1 (",
        "  ping 127.0.0.1 -n 2 >nul",
        "  goto retry",
        ")",
        'cd /d "%s"' % install_dir,
        'start "" "%s"' % current_exe_path,
        'del "%~f0"',
    ]
    try:
        with open(bat_path, "w", encoding="utf-8") as f:
            f.write("\r\n".join(bat_lines))
        subprocess.Popen(
            ["cmd", "/c", bat_path],
            creationflags=subprocess.CREATE_NO_WINDOW if (sys.platform == "win32" and hasattr(subprocess, "CREATE_NO_WINDOW")) else 0,
            shell=False,
        )
    except Exception:
        pass
    os._exit(0)


DELTA_PACKAGE_FORMAT = "server-manager-file-delta-v1"


def _sha256_path(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest().lower()


def _safe_delta_rel(value: str) -> str:
    rel = str(value or "").replace("\\", "/").strip("/")
    parts = [part for part in rel.split("/") if part]
    if not parts or any(part in (".", "..") for part in parts):
        raise ValueError(f"unsafe path: {value}")
    return "/".join(parts)


def _safe_install_path(install_dir: Path, rel_path: str) -> Path:
    rel = _safe_delta_rel(rel_path)
    root = install_dir.resolve()
    target = (root / Path(*rel.split("/"))).resolve()
    if target != root and root not in target.parents:
        raise ValueError(f"path escapes install dir: {rel_path}")
    return target


def _validate_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
    if not delta_zip_path.is_file():
        return False, "增量包文件不存在"
    expected_sha = (expected_sha256 or "").strip().lower()
    if expected_sha and _sha256_path(delta_zip_path) != expected_sha:
        return False, "增量包 SHA256 校验失败，请使用全量更新"
    try:
        with zipfile.ZipFile(delta_zip_path, "r") as zf:
            with zf.open("delta_manifest.json") as f:
                manifest = json.loads(f.read().decode("utf-8-sig"))
        if not isinstance(manifest, dict) or manifest.get("format") != DELTA_PACKAGE_FORMAT:
            return False, "增量包格式不受支持，请使用全量更新"
        if not isinstance(manifest.get("files"), list):
            return False, "增量包文件清单无效，请使用全量更新"
        if getattr(sys, "frozen", False):
            install_dir = Path(sys.executable).resolve().parent
            for entry in manifest.get("files", []):
                rel = _safe_delta_rel(entry.get("path"))
                old_sha = str(entry.get("old_sha256") or "").strip().lower()
                if old_sha:
                    target = _safe_install_path(install_dir, rel)
                    if not target.is_file() or _sha256_path(target) != old_sha:
                        return False, f"当前安装文件与增量包来源版本不一致: {rel}"
            for entry in manifest.get("delete", []) if isinstance(manifest.get("delete"), list) else []:
                rel = _safe_delta_rel(entry.get("path"))
                old_sha = str(entry.get("old_sha256") or "").strip().lower()
                target = _safe_install_path(install_dir, rel)
                if old_sha and target.is_file() and _sha256_path(target) != old_sha:
                    return False, f"当前安装文件与增量包来源版本不一致: {rel}"
    except Exception as e:
        return False, f"增量包解析失败: {e!s}"
    return True, ""


def apply_file_delta_package(delta_zip_path: Path, expected_sha256: str = "") -> Tuple[bool, str]:
    """
    应用文件级增量包：主进程验证 zip 后，复制 mini_updater 到 TEMP 并在退出后替换安装目录文件。
    成功时本函数不会返回；失败时返回 (False, reason) 供 UI 回退全量更新。
    """
    if not getattr(sys, "frozen", False):
        return False, "当前未以打包方式运行"
    delta_zip_path = Path(delta_zip_path)
    ok, reason = _validate_delta_package(delta_zip_path, expected_sha256)
    if not ok:
        return False, reason

    current_exe_path = os.path.abspath(sys.executable)
    install_dir = os.path.normpath(os.path.dirname(current_exe_path))
    mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe"))
    if sys.platform != "win32" or not os.path.isfile(mini_updater_exe):
        return False, "未找到 mini_updater.exe，请使用全量更新"

    try:
        temp_updater = os.path.join(tempfile.gettempdir(), f"ServerManager_mini_updater_{os.getpid()}.exe")
        shutil.copy2(mini_updater_exe, temp_updater)
        pid = os.getpid()
        subprocess.Popen(
            [
                temp_updater,
                "--pid", str(pid),
                "--install-dir", install_dir,
                "--delta-zip-path", str(delta_zip_path.resolve()),
                "--target-exe-name", os.path.basename(current_exe_path) or "ServerManager.exe",
            ],
            cwd=install_dir,
            close_fds=True,
            creationflags=0x00000008,
            shell=False,
        )
        _write_update_log(install_dir, "已拉起文件级增量更新器 (pid=%s)" % pid)
        os._exit(0)
    except Exception as e:
        _write_update_log(install_dir, "拉起文件级增量更新器失败: %s" % e)
        return False, f"启动增量更新器失败: {e!s}"


def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]:
    """
    使用 bsdiff4 应用增量补丁：当前 exe + patch -> 新 exe，校验 sha256 后在本进程内
    用「重命名大法」替换并拉起新进程再退出（无需 .bat 或 --do-replace）。
    仅在被 PyInstaller 打包为单文件 exe 时可用；非 frozen 时返回 (False, reason)。
    成功时本函数不会返回（进程会退出）；返回 (False, "原因") 表示未执行或失败。
    """
    if not getattr(sys, "frozen", False):
        return False, "当前未以打包方式运行"
    if not _HAS_BSDIFF:
        return False, "未包含 bsdiff4 组件，请使用全量更新"
    patch_path = Path(patch_path)
    if not patch_path.is_file():
        return False, "补丁文件不存在"
    current_exe_path = os.path.abspath(sys.executable)
    try:
        with open(current_exe_path, "rb") as f:
            old_data = f.read()
        with open(patch_path, "rb") as f:
            patch_data = f.read()
        new_data = bsdiff4.patch(old_data, patch_data)
        got_sha = hashlib.sha256(new_data).hexdigest().lower()
        expected_sha = (expected_new_sha256 or "").strip().lower()
        if expected_sha and got_sha != expected_sha:
            return False, "校验未通过（补丁结果与预期不一致），请使用全量更新"
        tmp = tempfile.gettempdir()
        temp_new_exe_path = os.path.join(tmp, "ServerManager_new.exe")
        with open(temp_new_exe_path, "wb") as f:
            f.write(new_data)
        if not os.path.isfile(temp_new_exe_path):
            return False, "写入临时 exe 失败"
        # 独立进程外更新器 或 回退 .bat
        apply_update_and_restart(temp_new_exe_path, target_exe_name=os.path.basename(current_exe_path))
    except Exception as e:
        return False, f"应用补丁时出错: {e!s}，请使用全量更新"
    return False, "未知错误"


def run_installer_and_exit(installer_path: Path, silent: bool = True) -> None:
    """运行完整安装包并退出本进程。Windows 下 silent 时传 /VERYSILENT 等参数。"""
    path = str(Path(installer_path).resolve())
    if sys.platform == "win32":
        args = [path]
        if silent:
            args.extend(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/FORCECLOSEAPPLICATIONS"])
        subprocess.Popen(args, shell=False)
    else:
        subprocess.Popen(
            ["open" if sys.platform == "darwin" else "xdg-open", path],
            shell=False,
        )
    if getattr(sys, "frozen", False):
        os._exit(0)
    else:
        sys.exit(0)
