"""apply_update.py — 把 make_update.py 产的更新包覆盖到安装目录(updater 核心)。

覆盖规则(来自实测 #91):
  - manifest.clean_replace 里的目录(dist): **先整目录删掉再解压** → 避免 Vite 哈希僵尸胀包
  - 其余文件(backend/Backend.exe、VERSION、可选 _internal): 合并覆盖(文件名稳定)
  - skip 列表(用户数据/静态层)永不触碰:包里本就没有,这里再兜底拒绝写入
  - 应用前逐文件校验 sha256 == manifest(防损坏/篡改)
    ⚠️ 正式 updater 还应校验整包 Ed25519 签名 + 在 start_all 之前独立进程跑(避免覆盖被占用文件)

用法:
  python apply_update.py dist_update/update_20260712.1.zip --target C:\\安装目录
  python apply_update.py update_xxx.zip --target <目录> --dry-run   # 只打印计划不落盘
"""
from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import zipfile
from pathlib import Path

SKIP_PREFIXES = [
    "backend/static/audio", "backend/static/meta_human",
    "backend/config_kv", "backend/user_config.json",
]


def _sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def _is_skipped(rel: str) -> bool:
    return any(rel == p or rel.startswith(p + "/") for p in SKIP_PREFIXES)


def apply(zip_path: Path, target: Path, dry_run: bool) -> None:
    with zipfile.ZipFile(zip_path) as zf:
        # 优先从 zip 内部读 manifest（自包含更新包），没有则尝试外部文件
        if "manifest.json" in (n for n in zf.namelist()):
            manifest = json.loads(zf.read("manifest.json"))
        else:
            manifest_path = zip_path.with_name(zip_path.stem + ".manifest.json")
            if not manifest_path.exists():
                raise SystemExit(f"[apply] 找不到 manifest: {manifest_path}")
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        clean_dirs = manifest.get("clean_replace", [])
        expected = {f["path"]: f["sha256"] for f in manifest["files"]}

        names = [n for n in zf.namelist() if not n.endswith("/") and n != "manifest.json"]
        # 1) 逐文件校验 sha256
        for n in names:
            got = _sha256_bytes(zf.read(n))
            if n in expected and got != expected[n]:
                raise SystemExit(f"[apply] 校验失败,包已损坏/被篡改: {n}")
            if _is_skipped(n):
                raise SystemExit(f"[apply] 包内含用户数据路径,拒绝应用: {n}")
        print(f"[apply] 版本 {manifest['version']}  文件 {len(names)}  校验通过")

        # 2) clean-replace 目录:先删整目录
        for d in clean_dirs:
            dpath = target / d
            print(f"[apply] clean-replace 目录: {d}  (删除后重放)")
            if not dry_run and dpath.exists():
                shutil.rmtree(dpath)

        # 3) 写入所有文件(skip 已在上面兜底拒绝)
        for n in names:
            dest = target / n
            if dry_run:
                print(f"[apply] 计划写入 {n}")
                continue
            dest.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(n) as src, dest.open("wb") as out:
                shutil.copyfileobj(src, out)

    if dry_run:
        print("[apply] dry-run 完成,未落盘")
    else:
        print(f"[apply] 完成,已更新到 {target}")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("zip", help="make_update.py 产出的 update_xxx.zip")
    ap.add_argument("--target", required=True, help="要覆盖的安装目录")
    ap.add_argument("--dry-run", action="store_true", help="只打印计划,不落盘")
    args = ap.parse_args()
    apply(Path(args.zip).resolve(), Path(args.target).resolve(), args.dry_run)


if __name__ == "__main__":
    main()
