#!/usr/bin/env python3
"""Cross-module contract checker: template-library ↔ remotion-renderer.

Verifies that every `compositionId` referenced from the @ab-templates/metadata
registry.json is:
  1. Registered in `<monorepo-root>/remotion-renderer/src/core/compositions/manifest.ts`
  2. Mapped there to the SAME `templateId` as the template entry's
     top-level `templateId` field

Exit non-zero with a useful diff if the two sides disagree.

Usage:
    python3 scripts/check_contracts.py
"""
from __future__ import annotations

import json
import os
import re
import sys
from typing import Iterator

from template_paths import monorepo_registry_path, monorepo_root

_REGISTRY_PATH = monorepo_registry_path()
_MANIFEST_PATH = os.path.abspath(
    os.path.join(
        monorepo_root(),
        "remotion-renderer",
        "src",
        "core",
        "compositions",
        "manifest.ts",
    )
)


# Matches a single manifest row:
#   SomeCompositionId: { component: Foo, templateId: "t-id", slot: "opening" },
_MANIFEST_ROW_RE = re.compile(
    r'^\s*(?P<cid>[A-Za-z0-9_]+)\s*:\s*\{\s*'
    r'component\s*:\s*[A-Za-z0-9_]+\s*,\s*'
    r'templateId\s*:\s*"(?P<tid>[^"]+)"\s*,\s*'
    r'slot\s*:\s*"(?P<slot>[^"]+)"\s*\}\s*,?\s*$'
)


def parse_manifest(path: str) -> dict[str, dict[str, str]]:
    """Parse manifest.ts and return {compositionId: {templateId, slot}}."""
    if not os.path.isfile(path):
        raise FileNotFoundError(f"manifest.ts not found at {path}")

    with open(path, "r", encoding="utf-8") as f:
        text = f.read()

    start_marker = "// MANIFEST_ENTRIES:START"
    end_marker = "// MANIFEST_ENTRIES:END"
    start = text.find(start_marker)
    end = text.find(end_marker)
    if start == -1 or end == -1 or end < start:
        raise ValueError(
            f"manifest.ts is missing {start_marker} / {end_marker} markers"
        )

    block = text[start:end]
    entries: dict[str, dict[str, str]] = {}
    for line in block.splitlines():
        m = _MANIFEST_ROW_RE.match(line)
        if not m:
            continue
        entries[m.group("cid")] = {
            "templateId": m.group("tid"),
            "slot": m.group("slot"),
        }

    if not entries:
        raise ValueError(
            f"No manifest entries parsed from {path}. Did the format change?"
        )
    return entries


def _iter_composition_ids(node: object) -> Iterator[str]:
    """Walk an arbitrary JSON tree and yield every `compositionId` value."""
    if isinstance(node, dict):
        cid = node.get("compositionId")
        if isinstance(cid, str):
            yield cid
        for v in node.values():
            yield from _iter_composition_ids(v)
    elif isinstance(node, list):
        for v in node:
            yield from _iter_composition_ids(v)


def collect_template_references(registry_path: str) -> list[dict[str, str]]:
    """Return a list of {templateId, compositionId, file} for every reference in the registry."""
    refs: list[dict[str, str]] = []
    if not os.path.isfile(registry_path):
        print(f"WARN: registry.json not found at {registry_path}", file=sys.stderr)
        return refs

    with open(registry_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    for tpl in data.get("templates", []):
        tid = tpl.get("templateId")
        if not tid:
            continue
        for cid in _iter_composition_ids(tpl):
            refs.append({"templateId": tid, "compositionId": cid, "file": registry_path})
    return refs


def main() -> int:
    try:
        manifest = parse_manifest(_MANIFEST_PATH)
    except (FileNotFoundError, ValueError) as e:
        print(f"FAIL: {e}", file=sys.stderr)
        return 2

    refs = collect_template_references(_REGISTRY_PATH)

    errors: list[str] = []
    seen: set[str] = set()

    for ref in refs:
        cid = ref["compositionId"]
        tid = ref["templateId"]
        rel = os.path.relpath(ref["file"], monorepo_root())
        seen.add(cid)

        entry = manifest.get(cid)
        if entry is None:
            errors.append(
                f"{rel}: compositionId '{cid}' is referenced but not in manifest.ts"
            )
            continue
        if entry["templateId"] != tid:
            errors.append(
                f"{rel}: compositionId '{cid}' is registered under templateId "
                f"'{entry['templateId']}' in manifest.ts, but template.json declares "
                f"templateId '{tid}'"
            )

    # Warn (non-fatal) about manifest entries never referenced by any template.
    unused = sorted(set(manifest.keys()) - seen)
    for cid in unused:
        print(
            f"WARN: manifest.ts entry '{cid}' is not referenced by any "
            f"template.json (templateId={manifest[cid]['templateId']})",
            file=sys.stderr,
        )

    # 命名空间唯一性说明：compositionId 是否全库唯一，由 template-library 的
    # generateManifest 在生成 manifest.ts 时保障（跨模板撞名直接抛
    # DuplicateCompositionIdError、CI 失败）。manifest.ts 因此按构造即唯一，
    # 本脚本读取的已是去重结果，无需再校验唯一性。
    #
    # （已移除早期 P1.1 的 `<templateId>/` 前缀 warning：权威 schema 的
    # compositionId pattern 为 PascalCase `^[A-Z][A-Za-z0-9]*$`，本就禁止 `/`，
    # 前缀建议与 schema 矛盾且非必需——唯一性已由上游硬保障。）

    if errors:
        print("FAIL: contract check found inconsistencies:", file=sys.stderr)
        for e in errors:
            print(f"  - {e}", file=sys.stderr)
        return 1

    print(
        f"OK: {len(refs)} compositionId references across "
        f"{len({r['templateId'] for r in refs})} templates match manifest.ts "
        f"({len(manifest)} entries)."
    )
    return 0


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