#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
扫描暂存库里的 meta.json，生成 App 用的 catalog.json。

用法：
    python3 tools/build_catalog.py           # 重新生成 catalog.json
    python3 tools/build_catalog.py --check   # 只校验，不改文件

校验规则与 App 的 GameCatalog.kt 对齐（schema 1）：
ID 正则、标题长度、机种、扩展名、相对路径、大小上限、sha256、人数、标签、许可长度。
另外做几件 App 管不到的事：

· ROM 的实际大小 / sha256 必须和 meta.json 一致；
· 封面、说明文件必须真的存在；
· 同一份 ROM / 封面不能被两条记录引用；
· 目录里不允许出现「没写进 catalog 的 ROM」——防止漏登记；
· status=rom-not-provided 的目录里不允许有 ROM ——商业作品占位必须真的没有 ROM。
"""

from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
CATALOG = ROOT / "catalog.json"
PLATFORM_ORDER = ("nes", "gba", "gbc", "snes")
PLATFORM_EXTENSIONS = {
    "nes": {"nes"},
    "gba": {"gba"},
    "gbc": {"gb", "gbc"},
    "snes": {"sfc", "smc"},
}
ROM_EXTS = {".nes", ".gba", ".gb", ".gbc", ".sfc", ".smc"}
MAX_GAMES = 2000
MAX_ROM_BYTES = 64 * 1024 * 1024
MAX_CATALOG_BYTES = 2 * 1024 * 1024
ID_RE = re.compile(r"^[a-z]+_[a-z0-9][a-z0-9-]*_[0-9a-f]{8}$")
SHA_RE = re.compile(r"^[0-9a-f]{64}$")


class Bad(Exception):
    pass


def sha256_file(p: Path) -> str:
    h = hashlib.sha256()
    with p.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def is_safe_rel_path(p: str) -> bool:
    if not p or len(p) > 512:
        return False
    if p.startswith("/") or "\\" in p:
        return False
    if any(ord(ch) < 0x20 for ch in p):
        return False
    parts = p.split("/")
    return all(part not in ("", ".", "..") for part in parts)


def load_metas() -> list[tuple[Path, dict]]:
    metas = []
    for platform in PLATFORM_ORDER:
        base = ROOT / platform
        if not base.is_dir():
            continue
        for meta_path in sorted(base.glob("*/meta.json")):
            try:
                meta = json.loads(meta_path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError) as e:
                raise Bad(f"{meta_path.relative_to(ROOT)} 读不了：{e}")
            if not isinstance(meta, dict):
                raise Bad(f"{meta_path.relative_to(ROOT)} 不是对象")
            metas.append((meta_path, meta))
    return metas


def validate_one(meta_path: Path, m: dict) -> dict | None:
    rel = meta_path.relative_to(ROOT).as_posix()
    status = m.get("status")

    def bad(why: str):
        raise Bad(f"{rel}：{why}")

    if status == "rom-not-provided":
        # 占位目录必须真的没有 ROM
        d = meta_path.parent
        for p in d.iterdir():
            if p.is_file() and p.suffix.lower() in ROM_EXTS:
                bad(f"占位目录里出现了 ROM：{p.name}")
        return None
    if status != "ready":
        bad(f"status 不认识：{status!r}")

    gid = m.get("id")
    if not isinstance(gid, str) or not ID_RE.match(gid):
        bad("id 不合法")

    platform = m.get("platform")
    if platform not in PLATFORM_ORDER:
        bad(f"platform 不认识：{platform!r}")

    title = (m.get("title_zh") or m.get("title") or "").strip()
    if not title or len(title) > 120:
        bad("标题为空或超过 120 字")

    file = m.get("file")
    if not isinstance(file, str) or not is_safe_rel_path(file):
        bad("file 路径不安全")
    ext = file.rsplit(".", 1)[-1].lower() if "." in file else ""
    if ext not in PLATFORM_EXTENSIONS[platform]:
        bad(f"扩展名 .{ext} 和 {platform} 对不上")

    fpath = ROOT / file
    if not fpath.is_file():
        bad(f"ROM 不存在：{file}")
    size = m.get("size")
    if not isinstance(size, int) or size <= 0 or size > MAX_ROM_BYTES:
        bad("size 不合法")
    actual_size = fpath.stat().st_size
    if actual_size != size:
        bad(f"size 不一致：meta={size} 实际={actual_size}")
    sha = m.get("sha256")
    if not isinstance(sha, str) or not SHA_RE.match(sha):
        bad("sha256 不合法")
    actual_sha = sha256_file(fpath)
    if actual_sha != sha:
        bad(f"sha256 不一致：meta={sha[:12]}… 实际={actual_sha[:12]}…")
    if not gid.endswith("_" + sha[:8]):
        bad("id 尾部的 8 位必须是 sha256 前 8 位")

    players = m.get("players", 1)
    if not isinstance(players, int) or not 1 <= players <= 8:
        bad("players 不合法")

    tags = m.get("tags", [])
    if not isinstance(tags, list):
        bad("tags 不是数组")
    clean_tags = []
    for t in tags:
        if not isinstance(t, str):
            bad("tags 里有非字符串")
        t = t.strip()
        if not t or len(t) > 32:
            bad(f"标签不合法：{t!r}")
        clean_tags.append(t)

    license_text = m.get("license", "unknown")
    if not isinstance(license_text, str):
        bad("license 不是字符串")
    license_text = license_text.strip()
    if not license_text or len(license_text) > 64:
        bad("license 为空或超过 64 字")

    cover = m.get("cover")
    if cover is not None:
        if not isinstance(cover, str) or not is_safe_rel_path(cover):
            bad("cover 路径不安全")
        if not (ROOT / cover).is_file():
            bad(f"封面不存在：{cover}")

    description = m.get("description")
    if description is not None:
        if not isinstance(description, str) or not is_safe_rel_path(description):
            bad("description 路径不安全")
        if not (ROOT / description).is_file():
            bad(f"说明不存在：{description}")

    return {
        "id": gid,
        "title": title,
        "platform": platform,
        "file": file,
        "size": size,
        "sha256": sha,
        "players": players,
        "tags": clean_tags,
        "license": license_text,
        "cover": cover,
    }


def build() -> dict:
    metas = load_metas()
    if not metas:
        raise Bad("一个 meta.json 都没找到")

    games = []
    seen_ids = set()
    seen_files = set()
    seen_covers = set()
    for meta_path, meta in metas:
        entry = validate_one(meta_path, meta)
        if entry is None:
            continue
        if entry["id"] in seen_ids:
            raise Bad(f"ID 重复：{entry['id']}")
        if entry["file"] in seen_files:
            raise Bad(f"文件被重复引用：{entry['file']}")
        if entry["cover"] and entry["cover"] in seen_covers:
            raise Bad(f"封面被重复引用：{entry['cover']}")
        seen_ids.add(entry["id"])
        seen_files.add(entry["file"])
        if entry["cover"]:
            seen_covers.add(entry["cover"])
        games.append(entry)

    if not games:
        raise Bad("catalog 里一个可玩的游戏都没有")
    if len(games) > MAX_GAMES:
        raise Bad(f"条目太多：{len(games)} > {MAX_GAMES}")

    # 目录里不允许有「没登记」的 ROM（占位目录已在 validate_one 里单独查过）
    for p in ROOT.rglob("*"):
        if p.is_file() and p.suffix.lower() in ROM_EXTS:
            rel = p.relative_to(ROOT).as_posix()
            if rel not in seen_files:
                raise Bad(f"有 ROM 没登记进 catalog：{rel}")

    games.sort(key=lambda g: (PLATFORM_ORDER.index(g["platform"]), g["id"]))
    catalog = {
        "schema": 1,
        "generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "games": games,
    }
    text = json.dumps(catalog, ensure_ascii=False, indent=2) + "\n"
    if len(text.encode("utf-8")) > MAX_CATALOG_BYTES:
        raise Bad("catalog.json 超过 2MB（App 会拒收）")
    return catalog


def canonical(catalog: dict) -> str:
    """忽略 generated_at 的稳定形式，用来比较。"""
    c = dict(catalog)
    c.pop("generated_at", None)
    return json.dumps(c, ensure_ascii=False, indent=2, sort_keys=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--check", action="store_true", help="只校验，不写文件")
    args = ap.parse_args()

    try:
        catalog = build()
    except Bad as e:
        print(f"✗ {e}", file=sys.stderr)
        return 1

    if args.check:
        if not CATALOG.is_file():
            print("✗ catalog.json 不存在，先运行不带 --check 的命令", file=sys.stderr)
            return 1
        try:
            old = json.loads(CATALOG.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as e:
            print(f"✗ catalog.json 读不了：{e}", file=sys.stderr)
            return 1
        if canonical(old) != canonical(catalog):
            print("✗ catalog.json 和 meta.json 不一致，请重新生成", file=sys.stderr)
            return 1
    else:
        tmp = CATALOG.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        tmp.replace(CATALOG)

    by_platform = {p: 0 for p in PLATFORM_ORDER}
    total = 0
    for g in catalog["games"]:
        by_platform[g["platform"]] += 1
        total += g["size"]
    print(
        "✓ catalog.json：{} 个游戏，{:.1f} MB（{}）".format(
            len(catalog["games"]),
            total / 1024 / 1024,
            "  ".join(f"{p.upper()} {by_platform[p]}" for p in PLATFORM_ORDER if by_platform[p]),
        )
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())
