#!/usr/bin/env python3
"""
脆弱点 6②：propExtractors / requiredProps ↔ 组件实际消费 props 的**双向对账**。

不同于纯静态「consumed - providable」对账（噪声过大：分不清可选/必填、textLayers
派生、同名变量污染），本校验用**三个无误报的稳健信号**做对账，且复用 render-video
的真实 `build_binding`（而非重写绑定逻辑），保证与运行时一致：

  A. requiredProps ⊆ produced
     用真实 dsl-example.json 跑 build_binding，断言每个 slot 声明的 requiredProps
     都真的进了 props。缺失 = 渲染前会回退通用布局（脆弱点 2 的 slideId 事故类）。
     —— ERROR

  B. requiredProps ⊆ typeFields
     slot 声明 required 的 prop，组件 props 类型（entry.props as <T>，<T> 定义在
     types.ts）里必须有该字段；否则 = 契约自相矛盾（声明必填却无处接收）。
     —— ERROR

  C. propExtractors keys ⊆ typeFields
     提取器产出的每个 prop，目标组件类型必须声明；否则 = 死字段 / 漂移（提取器辛苦
     算出来却没人读，或类型重命名后提取器没跟）。
     —— WARN

为什么这三个无误报：它们只用「类型声明（含 ?可选信息）」+「requiredProps 显式契约」
+「真实绑定产出」，完全不猜组件源码里的 `props.x` 成员访问（那才是噪声源，且 tsc
已覆盖「读了类型里没有的字段」）。

与 test-template-pipeline.py 的分工：后者通过 ab-api registry 跑 build_binding，已
覆盖 A（missingRequiredProps），但**离线 CI 跑不了**（registry_loader 需 ab-api）；
本模块直接从磁盘读 template.json + types.ts，**无需 ab-api**，且额外提供类型级的
B/C 对账（别处都没有）。由 scripts/test-props-contract.py 作为 CI 入口驱动。

库接口：
  verify_template(template_dir) -> {name, errors[], warnings[], info{}}
"""

from __future__ import annotations

import json
import os
import re
import sys

# 复用真实绑定逻辑（同目录 sibling 模块）。registry_loader 无 import 期副作用，
# 不触发 ab-api，故可安全 import。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from match_template import build_binding  # noqa: E402


CAST_RE = re.compile(r"entry\.props\s+as\s+([A-Za-z_$][\w$]*)")


def parse_interface_fields(types_src: str) -> dict:
    """从 types.ts 解析 `export interface Name { ... }` → {Name: {field: optional_bool}}.

    只解析顶层 interface 的顶层成员（足够覆盖模板 props 类型——它们都是扁平接口）。
    嵌套对象类型按整体跳过（不影响顶层字段名收集）。
    """
    result: dict = {}
    i = 0
    n = len(types_src)
    iface_re = re.compile(r"export\s+interface\s+([A-Za-z_$][\w$]*)\s*\{")
    for m in iface_re.finditer(types_src):
        name = m.group(1)
        # 从 `{` 起做花括号配平，截取 body
        start = m.end() - 1
        depth = 0
        j = start
        while j < n:
            c = types_src[j]
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
                if depth == 0:
                    break
            j += 1
        body = types_src[start + 1 : j]
        result[name] = _parse_members(body)
    return result


def _parse_members(body: str) -> dict:
    """解析接口 body 顶层成员名 + 是否可选。嵌套 { } 整体跳过。"""
    fields: dict = {}
    i = 0
    n = len(body)
    # 移除注释，避免把注释里的标识符当成员
    body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
    body = re.sub(r"//[^\n]*", "", body)
    n = len(body)
    # 按顶层切分：在 depth==0 处遇到 `;` 或 `\n` 视为成员边界
    member_re = re.compile(r"([A-Za-z_$][\w$]*)\s*(\??)\s*:")
    depth = 0
    seg_start = 0
    segments = []
    for k, c in enumerate(body):
        if c in "{([":
            depth += 1
        elif c in "})]":
            depth -= 1
        elif c in ";\n" and depth == 0:
            segments.append(body[seg_start:k])
            seg_start = k + 1
    segments.append(body[seg_start:])
    for seg in segments:
        seg = seg.strip()
        if not seg:
            continue
        mm = member_re.match(seg)
        if mm:
            fields[mm.group(1)] = mm.group(2) == "?"
    return fields


def resolve_slot_def(slot_mapping: dict, purpose: str) -> dict:
    slot_def = slot_mapping.get(purpose) or slot_mapping.get("default") or {}
    visited = set()
    while isinstance(slot_def, dict) and "$ref" in slot_def:
        ref = slot_def["$ref"]
        if ref in visited:
            break
        visited.add(ref)
        slot_def = slot_mapping.get(ref) or {}
    return slot_def


def verify_template(tdir: str) -> dict:
    """对单个模板目录做 A/B/C 对账，返回 {errors:[], warnings:[], info:{}}。"""
    name = os.path.basename(tdir.rstrip("/"))
    errors: list = []
    warnings: list = []
    info: dict = {}

    tpl_path = os.path.join(tdir, "template.json")
    with open(tpl_path, "r", encoding="utf-8") as f:
        tpl = json.load(f)

    slot_mapping = tpl.get("slotMapping", {}) or {}
    compositions = tpl.get("compositions", []) or []

    # compositionId → componentFile
    comp_file = {c.get("compositionId"): c.get("componentFile") for c in compositions}

    # componentFile → cast 类型名（entry.props as <T>）
    file_cast: dict = {}
    for cf in set(comp_file.values()):
        if not cf:
            continue
        src_path = os.path.normpath(os.path.join(tdir, cf))
        if not os.path.exists(src_path):
            warnings.append(f"组件文件不存在：{cf}")
            continue
        with open(src_path, "r", encoding="utf-8") as f:
            src = f.read()
        casts = CAST_RE.findall(src)
        file_cast[cf] = casts[0] if casts else None
        if not casts:
            warnings.append(f"{cf} 未找到 `entry.props as <Type>`，跳过类型对账")

    # 收集 types.ts（含同目录所有 .ts 里的 interface，稳妥起见全扫）
    type_fields: dict = {}
    for root, _dirs, files in os.walk(tdir):
        for fn in files:
            if fn.endswith(".ts") and not fn.endswith(".d.ts"):
                try:
                    with open(os.path.join(root, fn), "r", encoding="utf-8") as f:
                        type_fields.update(parse_interface_fields(f.read()))
                except Exception:
                    pass

    # compositionId → 类型字段集合
    def fields_for_comp(comp_id: str):
        cf = comp_file.get(comp_id)
        cast = file_cast.get(cf)
        if cast and cast in type_fields:
            return set(type_fields[cast].keys()), cast
        return None, cast

    # ---- B & C：基于 slot 声明（不依赖 dsl-example）----
    seen_slots = set()
    for purpose in list(slot_mapping.keys()):
        sd = resolve_slot_def(slot_mapping, purpose)
        comp_id = sd.get("compositionId")
        if not comp_id or comp_id in seen_slots:
            continue
        seen_slots.add(comp_id)
        tfields, cast = fields_for_comp(comp_id)
        required = sd.get("requiredProps") or []
        extractor_keys = list((sd.get("propExtractors") or {}).keys())

        if tfields is None:
            if required or extractor_keys:
                warnings.append(
                    f"slot→{comp_id}: 无法解析组件 props 类型({cast})，B/C 跳过"
                )
            continue

        # B: requiredProps ⊆ typeFields
        for rp in required:
            if rp not in tfields:
                errors.append(
                    f"[B] slot→{comp_id}: requiredProps 含 '{rp}'，但组件类型 {cast} 未声明该字段"
                )
        # C: propExtractors keys ⊆ typeFields
        for ek in extractor_keys:
            if ek not in tfields:
                warnings.append(
                    f"[C] slot→{comp_id}: propExtractors 产出 '{ek}'，但组件类型 {cast} 未声明 → 死字段/漂移"
                )

    # ---- A：用真实 dsl-example 跑 build_binding，requiredProps ⊆ produced ----
    ex_path = os.path.join(tdir, "dsl-example.json")
    if os.path.exists(ex_path):
        with open(ex_path, "r", encoding="utf-8") as f:
            dsl = json.load(f)
        binding = build_binding(tpl, dsl)
        miss_rows = [b for b in binding.get("bindings", []) if b.get("missingRequiredProps")]
        for b in miss_rows:
            errors.append(
                f"[A] scene={b['sceneId']} slot={b['slotId']}: "
                f"requiredProps 缺失 {b['missingRequiredProps']}（dsl-example 跑真实绑定后仍未产出）"
            )
        info["scenes_bound"] = len(binding.get("bindings", []))
    else:
        warnings.append("缺 dsl-example.json，A（运行时绑定对账）跳过")

    return {"name": name, "errors": errors, "warnings": warnings, "info": info}


def verify_all(base: str) -> int:
    """对 base/*/template.json 逐个对账，打印报告，返回 error 总数。"""
    dirs = sorted(
        os.path.join(base, d)
        for d in os.listdir(base)
        if os.path.isdir(os.path.join(base, d))
        and os.path.exists(os.path.join(base, d, "template.json"))
    )
    if not dirs:
        print(f"未发现模板（{base}/*/template.json）", file=sys.stderr)
        return 0

    total_err = 0
    total_warn = 0
    for tdir in dirs:
        r = verify_template(tdir)
        status = "FAIL" if r["errors"] else ("WARN" if r["warnings"] else "PASS")
        scenes = r["info"].get("scenes_bound", "-")
        print(f"\n=== {r['name']}  [{status}]  (scenes_bound={scenes}) ===")
        for e in r["errors"]:
            print(f"  ERROR {e}")
        for w in r["warnings"]:
            print(f"  WARN  {w}")
        if not r["errors"] and not r["warnings"]:
            print("  ✓ A/B/C 全部通过")
        total_err += len(r["errors"])
        total_warn += len(r["warnings"])

    print(f"\n—— 汇总：{len(dirs)} 模板，{total_err} error，{total_warn} warning ——")
    return total_err


if __name__ == "__main__":
    import argparse

    ap = argparse.ArgumentParser(description="脆弱点 6② props 双向对账（A/B/C）")
    ap.add_argument("--templates-dir", required=True, help="template-library/packages/templates/src")
    args = ap.parse_args()
    base = os.path.abspath(args.templates_dir)
    if not os.path.isdir(base):
        print(f"templates-dir 不存在: {base}", file=sys.stderr)
        raise SystemExit(2)
    raise SystemExit(1 if verify_all(base) else 0)
