#!/usr/bin/env python3
"""lockfile-diff.py —— 对比两个 package-lock.json，列出 transitive dep 主版本变化

拦元根因 2（依赖关系不可见演化）。详见：
  - docs/standards/12-five-meta-rules.md 规则 2
  - scripts/ops/lockfile-risk-watchlist.txt（高风险包标注源）

用法:
  python3 lockfile-diff.py <base.json> <head.json> [--watchlist <path>]

输出:
  Markdown 表格到 stdout，含包名 / base 版本 / head 版本 / watchlist 命中标记。
  无任何主版本变化 → 不输出表格，只 stderr 一行 "no major bumps"，exit 0。
  有变化但无 watchlist 命中 → exit 0
  有 watchlist 命中 → exit 0（仍不阻断；workflow 决定是否 fail）

退出码:
  0  正常（不论是否有 diff）
  2  参数错误 / 文件不存在 / JSON 解析失败
"""
import argparse
import fnmatch
import json
import sys
from pathlib import Path


def load_packages(lockfile_path: Path) -> dict:
    """从 package-lock.json 提取 {package_name: version} dict（兼容 v2/v3 lockfileVersion）"""
    with lockfile_path.open() as f:
        data = json.load(f)

    out = {}
    # v3+: .packages，key 形如 "node_modules/foo" / "node_modules/@scope/bar"
    for k, v in (data.get("packages") or {}).items():
        if not k:
            continue  # root 自身（key=""）
        # 取最后一个 node_modules/ 之后的部分（处理嵌套 node_modules/foo/node_modules/bar）
        name = k.split("node_modules/")[-1]
        if not name or "version" not in v:
            continue
        # 同名包多次出现（嵌套）取首个版本（root install 的）
        out.setdefault(name, v["version"])

    # v1 lockfile fallback: .dependencies（lockfileVersion 1，无 .packages）
    if not out:
        def walk(deps):
            for name, info in (deps or {}).items():
                if "version" in info:
                    out.setdefault(name, info["version"])
                if "dependencies" in info:
                    walk(info["dependencies"])
        walk(data.get("dependencies"))

    return out


def major_of(version: str) -> int | None:
    """从 semver 字符串提主版本号；非数字开头返回 None（如 "git+ssh://..."）"""
    v = version.lstrip("v")
    head = v.split(".", 1)[0]
    return int(head) if head.isdigit() else None


def load_watchlist(path: Path) -> list[str]:
    """读 watchlist，去整行注释 + 行内 # 注释 + 空行"""
    if not path.exists():
        return []
    out = []
    for ln in path.read_text().splitlines():
        if "#" in ln:
            ln = ln.split("#", 1)[0]
        ln = ln.strip()
        if ln:
            out.append(ln)
    return out


def is_watched(name: str, patterns: list[str]) -> bool:
    return any(fnmatch.fnmatchcase(name, p) for p in patterns)


def main() -> int:
    ap = argparse.ArgumentParser(description="diff two package-lock.json for major version bumps")
    ap.add_argument("base", type=Path, help="base lockfile")
    ap.add_argument("head", type=Path, help="head lockfile")
    ap.add_argument(
        "--watchlist",
        type=Path,
        default=Path(__file__).parent / "lockfile-risk-watchlist.txt",
        help="高风险包名单（默认 scripts/ops/lockfile-risk-watchlist.txt）",
    )
    ap.add_argument("--label", default="package-lock.json", help="表格标题里显示的 lockfile 名称")
    args = ap.parse_args()

    for p in (args.base, args.head):
        if not p.is_file():
            print(f"❌ file not found: {p}", file=sys.stderr)
            return 2

    try:
        base_pkgs = load_packages(args.base)
        head_pkgs = load_packages(args.head)
    except json.JSONDecodeError as e:
        print(f"❌ JSON parse error: {e}", file=sys.stderr)
        return 2

    watchlist = load_watchlist(args.watchlist)

    bumps = []
    for name, head_v in sorted(head_pkgs.items()):
        base_v = base_pkgs.get(name)
        if base_v is None:
            continue  # 新增 dep 不算 major bump（可能后续单独维度）
        if base_v == head_v:
            continue
        h_major, b_major = major_of(head_v), major_of(base_v)
        if h_major is None or b_major is None:
            continue
        if h_major != b_major:
            bumps.append((name, base_v, head_v, is_watched(name, watchlist)))

    if not bumps:
        print("no major bumps", file=sys.stderr)
        return 0

    print(f"## 📦 Lockfile dep major version diff (`{args.label}`)")
    print()
    print("| Package | base | head | watch |")
    print("|---|---|---|---|")
    for name, b, h, watched in bumps:
        mark = "⚠️" if watched else "—"
        print(f"| `{name}` | {b} | {h} | {mark} |")
    print()
    watched_count = sum(1 for _, _, _, w in bumps if w)
    if watched_count > 0:
        print(
            f"⚠️ 命中 [`scripts/ops/lockfile-risk-watchlist.txt`](scripts/ops/lockfile-risk-watchlist.txt) "
            f"高风险包 **{watched_count}** 个，请人工 review 兼容性后再合并。"
        )
        print()
        print("**典型风险**：ESM-only 升级 (CJS 项目装了会运行时炸)、breaking API 变更、迁移成本高。")
    else:
        print("（无命中高风险 watchlist；常规 review 即可）")

    return 0


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