# -*- coding: utf-8 -*-
"""
独立进程外更新器 (Out-of-Process Updater)

无第三方依赖（仅标准库 + 可选 ctypes 探测 PID），
打包为 mini_updater.exe 后置于安装目录，由主程序在“确认重启”后拉起。
负责：等待主进程退出 → 重命名替换 exe → 以正确 cwd 与 DETACHED_PROCESS 启动新进程。
"""

import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import tempfile
import zipfile

# 仅标准库：PID 检测用 ctypes（Windows）或 os.kill（Unix）
if sys.platform == "win32":
    import ctypes
    try:
        _kernel32 = ctypes.windll.kernel32  # type: ignore[attr-defined]
    except Exception:
        _kernel32 = None
else:
    _kernel32 = None


def _log_error(install_dir: str, message: str, exc: Exception = None) -> None:
    """将错误写入安装目录下的 logs/update_error.log，便于排查。"""
    try:
        log_dir = os.path.join(install_dir, "logs")
        os.makedirs(log_dir, exist_ok=True)
        log_path = os.path.join(log_dir, "update_error.log")
        with open(log_path, "a", encoding="utf-8") as f:
            from datetime import datetime
            line = "[%s] %s" % (datetime.now().isoformat(), message)
            if exc is not None:
                line += " (%s)" % exc
            f.write(line + "\n")
    except Exception:
        pass


def is_process_alive(pid: int) -> bool:
    """
    跨平台判断指定 PID 是否仍存活。
    Windows: ctypes 调用 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)；
    Unix: os.kill(pid, 0)。
    """
    if sys.platform == "win32" and _kernel32 is not None:
        # PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
        handle = _kernel32.OpenProcess(0x1000, False, pid)
        if handle is None or handle == 0:
            return False
        try:
            _kernel32.CloseHandle(handle)
        except Exception:
            pass
        return True
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError, PermissionError):
        return False


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


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


def _safe_relative_path(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("unsafe relative path: %s" % value)
    return "/".join(parts)


def _safe_join(root: str, rel_path: str) -> str:
    root_abs = os.path.abspath(root)
    rel = _safe_relative_path(rel_path)
    target = os.path.abspath(os.path.join(root_abs, *rel.split("/")))
    if target != root_abs and not target.startswith(root_abs + os.sep):
        raise ValueError("path escapes install dir: %s" % rel_path)
    return target


def _read_delta_manifest(delta_zip_path: str) -> dict:
    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):
        raise ValueError("delta manifest must be an object")
    if manifest.get("format") != DELTA_FORMAT:
        raise ValueError("unsupported delta format: %s" % manifest.get("format"))
    if not isinstance(manifest.get("files"), list):
        raise ValueError("delta manifest files must be a list")
    if not isinstance(manifest.get("delete"), list):
        manifest["delete"] = []
    return manifest


def _validate_base_files(install_dir: str, manifest: dict) -> None:
    for entry in manifest.get("files", []):
        rel = _safe_relative_path(entry.get("path"))
        old_sha = str(entry.get("old_sha256") or "").lower()
        target = _safe_join(install_dir, rel)
        if old_sha:
            if not os.path.isfile(target):
                raise ValueError("base file missing: %s" % rel)
            if _sha256_file(target) != old_sha:
                raise ValueError("base file hash mismatch: %s" % rel)

    for entry in manifest.get("delete", []):
        rel = _safe_relative_path(entry.get("path"))
        old_sha = str(entry.get("old_sha256") or "").lower()
        target = _safe_join(install_dir, rel)
        if os.path.exists(target) and old_sha and os.path.isfile(target) and _sha256_file(target) != old_sha:
            raise ValueError("delete file hash mismatch: %s" % rel)


def _extract_delta_files(delta_zip_path: str, manifest: dict) -> str:
    staging_dir = tempfile.mkdtemp(prefix="server_manager_delta_")
    with zipfile.ZipFile(delta_zip_path, "r") as zf:
        names = set(zf.namelist())
        for entry in manifest.get("files", []):
            rel = _safe_relative_path(entry.get("path"))
            zip_path = _safe_relative_path(entry.get("zip_path") or entry.get("source") or rel)
            expected_sha = str(entry.get("sha256") or "").lower()
            if zip_path not in names:
                raise ValueError("delta zip entry missing: %s" % zip_path)
            staged = _safe_join(staging_dir, rel)
            os.makedirs(os.path.dirname(staged), exist_ok=True)
            with zf.open(zip_path) as src, open(staged, "wb") as dst:
                shutil.copyfileobj(src, dst)
            if expected_sha and _sha256_file(staged) != expected_sha:
                raise ValueError("delta file hash mismatch: %s" % rel)
    return staging_dir


def apply_delta_zip(install_dir: str, delta_zip_path: str) -> bool:
    """Apply a file-level delta zip after the main process has exited."""
    backup_dir = ""
    staged_dir = ""
    moved_backups = []
    created_targets = []
    try:
        manifest = _read_delta_manifest(delta_zip_path)
        _validate_base_files(install_dir, manifest)
        staged_dir = _extract_delta_files(delta_zip_path, manifest)
        backup_dir = os.path.join(install_dir, ".update_backup", str(int(time.time())))

        for entry in manifest.get("delete", []):
            rel = _safe_relative_path(entry.get("path"))
            target = _safe_join(install_dir, rel)
            if os.path.exists(target):
                backup = _safe_join(backup_dir, rel)
                os.makedirs(os.path.dirname(backup), exist_ok=True)
                shutil.move(target, backup)
                moved_backups.append((target, backup))

        for entry in manifest.get("files", []):
            rel = _safe_relative_path(entry.get("path"))
            source = _safe_join(staged_dir, rel)
            target = _safe_join(install_dir, rel)
            os.makedirs(os.path.dirname(target), exist_ok=True)
            if os.path.exists(target):
                backup = _safe_join(backup_dir, rel)
                os.makedirs(os.path.dirname(backup), exist_ok=True)
                shutil.move(target, backup)
                moved_backups.append((target, backup))
            else:
                created_targets.append(target)
            shutil.move(source, target)

        try:
            if os.path.isdir(staged_dir):
                shutil.rmtree(staged_dir, ignore_errors=True)
            if os.path.isdir(backup_dir):
                shutil.rmtree(backup_dir, ignore_errors=True)
            if os.path.isfile(delta_zip_path):
                os.remove(delta_zip_path)
        except Exception:
            pass
        return True
    except Exception as e:
        _log_error(install_dir, "apply delta zip failed", e)
        for target in reversed(created_targets):
            try:
                if os.path.exists(target):
                    os.remove(target)
            except Exception:
                pass
        for target, backup in reversed(moved_backups):
            try:
                if os.path.exists(target):
                    if os.path.isdir(target):
                        shutil.rmtree(target, ignore_errors=True)
                    else:
                        os.remove(target)
                os.makedirs(os.path.dirname(target), exist_ok=True)
                if os.path.exists(backup):
                    shutil.move(backup, target)
            except Exception as restore_error:
                _log_error(install_dir, "restore backup failed: %s" % target, restore_error)
        if staged_dir:
            shutil.rmtree(staged_dir, ignore_errors=True)
        return False


def main() -> None:
    parser = argparse.ArgumentParser(description="独立更新守护：等主进程退出后替换 exe 并重启")
    parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID，必须等其完全退出")
    parser.add_argument("--install-dir", type=str, required=True, help="主程序安装根目录")
    parser.add_argument("--new-exe-path", type=str, default="", help="已准备好的新版本 ServerManager.exe 的完整路径（通常在 TEMP）")
    parser.add_argument("--delta-zip-path", type=str, default="", help="文件级增量包 zip 路径")
    parser.add_argument("--target-exe-name", type=str, default="ServerManager.exe", help="主程序文件名，如 ServerManager.exe")
    args = parser.parse_args()

    install_dir = os.path.abspath(args.install_dir)
    new_exe_path = os.path.abspath(args.new_exe_path) if args.new_exe_path else ""
    delta_zip_path = os.path.abspath(args.delta_zip_path) if args.delta_zip_path else ""
    if bool(new_exe_path) == bool(delta_zip_path):
        _log_error(install_dir, "exactly one of --new-exe-path or --delta-zip-path is required")
        sys.exit(1)
    target_exe_name = args.target_exe_name.strip() or "ServerManager.exe"
    target_exe_full = os.path.join(install_dir, target_exe_name)
    old_exe_full = target_exe_full + ".old"

    # -------------------------------------------------------------------------
    # 1. 死神凝视：每隔 0.5 秒检测 --pid 是否存活，必须等到进程彻底消失
    # -------------------------------------------------------------------------
    while is_process_alive(args.pid):
        time.sleep(0.5)

    # -------------------------------------------------------------------------
    # 2. 障眼法替换 (The Swap)
    # -------------------------------------------------------------------------
    import shutil
    try:
        os.chdir(install_dir)
    except Exception as e:
        _log_error(install_dir, "切换到安装目录失败", e)
        sys.exit(1)

    if delta_zip_path:
        if not apply_delta_zip(install_dir, delta_zip_path):
            sys.exit(1)
    else:
        # 若存在上次遗留的 .old，先静默删除
        if os.path.isfile(old_exe_full):
            try:
                os.remove(old_exe_full)
            except Exception as e:
                _log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)

        # 将当前主程序重命名为 .old，腾出位置
        if not os.path.isfile(target_exe_full):
            _log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full)
            sys.exit(1)
        try:
            os.rename(target_exe_full, old_exe_full)
        except Exception as e:
            _log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
            sys.exit(1)

        # 将新 exe 移入安装目录并命名为 target-exe-name
        if not os.path.isfile(new_exe_path):
            _log_error(install_dir, "新 exe 不存在: %s" % new_exe_path)
            sys.exit(1)
        try:
            shutil.move(new_exe_path, target_exe_full)
        except Exception as e:
            _log_error(install_dir, "移动新 exe 到安装目录失败", e)
            try:
                os.rename(old_exe_full, target_exe_full)
            except Exception:
                pass
            sys.exit(1)

    # -------------------------------------------------------------------------
    # 3. 干净的重生：Popen 新程序，强制 cwd、close_fds、DETACHED_PROCESS
    # -------------------------------------------------------------------------
    try:
        creationflags = 0
        if sys.platform == "win32":
            # DETACHED_PROCESS = 0x00000008，不继承控制台与父进程句柄
            creationflags = 0x00000008
        p = subprocess.Popen(
            [target_exe_full],
            cwd=install_dir,
            close_fds=True,
            creationflags=creationflags,
            shell=False,
        )
        # 不等待子进程，更新器功成身退
    except Exception as e:
        _log_error(install_dir, "拉起新主程序失败", e)
        sys.exit(1)

    # -------------------------------------------------------------------------
    # 4. 功成身退
    # -------------------------------------------------------------------------
    sys.exit(0)


if __name__ == "__main__":
    main()
