#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TBDS-1 · Trustbase 可引用事实规范 v0.1 —— 符合性自检器（conformance self-check）

用途：任何采用者（包括本平台自己）用它检查**自己的记录集**是否符合 TBDS-1。
     输出逐条判定，退出码：0 = 无 MUST 失败，1 = 有 MUST 失败。

用法：
    python conformance.py <records.json> [--map <map.json>] [--level L1|L2|L3|L4]

`records.json` 接受：记录数组，或 {"records":[...]} / {"data":[...]} / {"items":[...]}。

── 为什么需要 --map ────────────────────────────────────────────────────────
本规范**不管你的物理字段怎么放**，只管语义。所以本器不假设字段名，而是通过
一份**字段映射**（logical → 你的 dot-path）把标准语义接上你的数据形状。
不给 --map 时用「同名扁平」默认映射（id / assertion_kind / confidence / sources[…]/）。

map.json 例（本平台自己的形状）：
{
  "id": "id",
  "confidence": "gated.confidence",
  "as_of": "as_of",
  "retrieved_at": "sources[0].retrieved_at",
  "content_hash": "content_hash",
  "assertion_kind": "assertion_kind",
  "revision_log": "revision_log",
  "tombstone": "tombstone",
  "sources": null,                       // null = 本库没有 sources 数组，改用「单来源映射」
  "single_source": {                     // 把记录级字段当成「一个来源」
     "url": "gated.source_url",
     "publisher": "gated.source_org",
     "locator": "gated.source_locator",
     "retrieved_at": "sources[0].retrieved_at",
     "quote": "gated.source_quote"
  }
}

本器**只检查可机器判定的规则**；不可机判的（如「置信度必须由来源类型决定」）标为
MANUAL 并附人工举证说明——**不假装检查过。**
"""
import json, re, sys

ASSERTION_KIND = ("observation", "compilation", "derivation", "model")
CONFIDENCE = ("A", "B", "C")
AXIS_L = ("L1", "L2", "L3", "L4")
AXIS_R = ("R1", "R2", "R3", "R4")
AXIS_AI = ("ai_permitted", "ai_prohibited", "ai_unverified")
ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*(?:[-_.][A-Za-z0-9]+)+$")
IDX_RE = re.compile(r"^(.+?)\[(\d+|\*)\]$")

MANUAL_RULES = {
    "TBDS-1-2.4", "TBDS-1-3.2", "TBDS-1-4.3", "TBDS-1-5.2", "TBDS-1-7.1",
    "TBDS-1-7.4", "TBDS-1-8.1", "TBDS-1-8.2", "TBDS-1-9.1", "TBDS-1-9.2",
    "TBDS-1-9.3", "TBDS-1-9.4", "TBDS-1-10.2", "TBDS-1-10.3",
}
RULES = [
    ("TBDS-1-2.1", "MUST", "每条记录有稳定标识符 id"),
    ("TBDS-1-2.2", "MUST", "标识符全库唯一"),
    ("TBDS-1-2.3", "SHOULD", "标识符形如 <前缀>-<序号>"),
    ("TBDS-1-2.4", "MUST", "标识符未被回收（跨版本比对，MANUAL）"),
    ("TBDS-1-3.1", "MUST", "assertion_kind 取值合法"),
    ("TBDS-1-3.2", "MUST", "derivation 未被呈现为来源原文（语境判断，MANUAL）"),
    ("TBDS-1-4.1", "MUST", "每个来源三轴 L/R/AI 齐备且取值合法"),
    ("TBDS-1-4.2", "MUST", "每个来源带 reliance_page_url + snapshot_at"),
    ("TBDS-1-4.3", "MUST", "条款矛盾按最严处理（MANUAL）"),
    ("TBDS-1-4.4", "MUST", "再发布型来源带 upstream（条件规则：无 upstream 即视作非再发布）"),
    ("TBDS-1-5.1", "MUST", "confidence 取值合法"),
    ("TBDS-1-5.2", "MUST", "置信度由来源类型决定（MANUAL）"),
    ("TBDS-1-6.1", "MUST", ">=1 来源，且来源含 url/publisher/locator/retrieved_at/quote"),
    ("TBDS-1-6.2", "MUST", "as_of 存在且与 retrieved_at 区分"),
    ("TBDS-1-6.3", "SHOULD", "记录含 content_hash"),
    ("TBDS-1-6.4", "MUST", "无来源者未被呈现为「已溯源」"),
    ("TBDS-1-7.1", "MUST", "记录库追加式（MANUAL）"),
    ("TBDS-1-7.2", "MUST", "每次变更记入 revision_log（未变更记录不计失败）"),
    ("TBDS-1-7.3", "MUST", "失效用 tombstone，非删除"),
    ("TBDS-1-7.4", "MUST", "可按标识符取回修订历史（MANUAL）"),
    ("TBDS-1-8.1", "MUST", "引用格式（MANUAL）"),
    ("TBDS-1-8.2", "SHOULD", "引用保留置信度与 as_of（MANUAL）"),
    ("TBDS-1-9.1", "MUST", "公开可提交的纠错通道（站点级，MANUAL）"),
    ("TBDS-1-9.2", "MUST", "承诺明确响应时限（站点级，MANUAL）"),
    ("TBDS-1-9.3", "MUST", "维护公开纠错档案（站点级，MANUAL）"),
    ("TBDS-1-9.4", "MUST", "付费不影响核验结论（MANUAL）"),
    ("TBDS-1-10.1", "SHOULD", "提供可执行自检"),
    ("TBDS-1-10.2", "MUST", "声明的符合性可被第三方复现（MANUAL）"),
    ("TBDS-1-10.3", "MUST", "如实声明已达层级（MANUAL）"),
]
LEVELS = {
    "L1": ("TBDS-1-2.1", "TBDS-1-6.1", "TBDS-1-7.3"),
    "L2": tuple(r[0] for r in RULES if r[0] not in MANUAL_RULES),
    "L3": tuple(r[0] for r in RULES if r[1] == "MUST"),
    "L4": tuple(r[0] for r in RULES),
}
DEFAULT_MAP = {
    "id": "id", "assertion_kind": "assertion_kind", "confidence": "confidence",
    "as_of": "as_of", "retrieved_at": "retrieved_at", "content_hash": "content_hash",
    "revision_log": "revision_log", "tombstone": "tombstone", "deleted": "deleted",
    "sources": "sources",
    "source_fields": {"url": "url", "publisher": "publisher", "locator": "locator",
                      "retrieved_at": "retrieved_at", "quote": "quote",
                      "reliance_page_url": "reliance_page_url",
                      "snapshot_at": "snapshot_at", "upstream": "upstream",
                      "axes_L": "axes.L", "axes_R": "axes.R", "axes_AI": "axes.AI"},
}


def pick(obj, path):
    """dot-path 取值；支持 `a.b` 与 `a[0].b` / `a[*].b`（[*] 返回列表）。"""
    if not path or not isinstance(obj, (dict, list)):
        return None
    cur = obj
    for part in str(path).split("."):
        m = IDX_RE.match(part)
        if m:
            base, idx = m.group(1), m.group(2)
            if base:
                cur = cur.get(base) if isinstance(cur, dict) else None
            if not isinstance(cur, list):
                return None
            if idx == "*":
                return cur
            i = int(idx)
            cur = cur[i] if i < len(cur) else None
        else:
            cur = cur.get(part) if isinstance(cur, dict) else None
        if cur is None:
            return None
    return cur


def load_records(path):
    d = json.load(open(path, encoding="utf-8"))
    if isinstance(d, dict):
        for k in ("records", "data", "items"):
            if isinstance(d.get(k), list):
                return d[k]
        return [d]
    return d


def main():
    pos = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not pos:
        print(__doc__)
        return 2
    MP = dict(DEFAULT_MAP)
    lvl_want = None
    for a in sys.argv[1:]:
        if a.startswith("--map="):
            MP.update(json.load(open(a.split("=", 1)[1], encoding="utf-8")))
        elif a.startswith("--level="):
            lvl_want = a.split("=", 1)[1]
    records = load_records(pos[0])
    SF = MP.get("source_fields") or {}

    res = {r[0]: [] for r in RULES}
    n_ok = {r[0]: 0 for r in RULES}

    def ok(rid):
        n_ok[rid] += 1

    def bad(rid, msg):
        res[rid].append(msg)

    seen = {}
    for i, r in enumerate(records):
        rid_ = pick(r, MP["id"])
        tag = rid_ or "#%d" % i
        if isinstance(rid_, str) and rid_.strip():
            ok("TBDS-1-2.1")
        else:
            bad("TBDS-1-2.1", "%s：缺 id" % tag)
        if rid_:
            if rid_ in seen:
                bad("TBDS-1-2.2", "%s：与 #%d 重复" % (rid_, seen[rid_]))
            else:
                seen[rid_] = i
                ok("TBDS-1-2.2")
            if ID_RE.match(str(rid_)):
                ok("TBDS-1-2.3")
            else:
                bad("TBDS-1-2.3", "%s：不符合 <前缀>-<序号>" % tag)

        ak = pick(r, MP["assertion_kind"])
        if ak in ASSERTION_KIND:
            ok("TBDS-1-3.1")
        else:
            bad("TBDS-1-3.1", "%s：assertion_kind=%r" % (tag, ak))

        cf = pick(r, MP["confidence"])
        if cf in CONFIDENCE:
            ok("TBDS-1-5.1")
        else:
            bad("TBDS-1-5.1", "%s：confidence=%r" % (tag, cf))

        srcs = []
        sp = MP.get("sources")
        if sp:
            v = pick(r, sp)
            if isinstance(v, list):
                srcs = [s for s in v if isinstance(s, dict)]
            elif isinstance(v, dict):
                srcs = [v]
        elif MP.get("single_source"):
            srcs = [r]      # 记录级字段即一个来源（由 source_fields 取值）
            SF = {k: v for k, v in (MP.get("single_source") or {}).items()}

        if not srcs:
            bad("TBDS-1-6.1", "%s：无来源" % tag)
            bad("TBDS-1-6.4", "%s：无来源（不得呈现为已溯源）" % tag)
            bad("TBDS-1-4.1", "%s：无来源故无三轴" % tag)
            bad("TBDS-1-4.2", "%s：无来源故无条款页" % tag)
        else:
            for j, s in enumerate(srcs):
                miss = [k for k in ("url", "publisher", "locator", "retrieved_at")
                        if not pick(s, SF.get(k, k))]
                if not SF.get("quote") and "quote" not in s:
                    miss.append("quote")
                elif SF.get("quote") and pick(s, SF["quote"]) is None:
                    miss.append("quote")
                if miss:
                    bad("TBDS-1-6.1", "%s.sources[%d] 缺 %s" % (tag, j, ",".join(miss)))
                else:
                    ok("TBDS-1-6.1")
                if pick(s, SF.get("reliance_page_url", "reliance_page_url")) and \
                        pick(s, SF.get("snapshot_at", "snapshot_at")):
                    ok("TBDS-1-4.2")
                else:
                    bad("TBDS-1-4.2", "%s.sources[%d] 缺 reliance_page_url/snapshot_at" % (tag, j))
                lv = pick(s, SF.get("axes_L", "axes.L"))
                rv = pick(s, SF.get("axes_R", "axes.R"))
                av = pick(s, SF.get("axes_AI", "axes.AI"))
                if lv in AXIS_L and rv in AXIS_R and av in AXIS_AI:
                    ok("TBDS-1-4.1")
                else:
                    bad("TBDS-1-4.1", "%s.sources[%d] 三轴不全/非法 L=%r R=%r AI=%r"
                        % (tag, j, lv, rv, av))
                if rv == "R2" and pick(s, SF.get("quote", "quote")):
                    bad("TBDS-1-6.1", "%s.sources[%d] R2 来源 quote 须为空" % (tag, j))

        aof = pick(r, MP["as_of"])
        rat = pick(r, MP["retrieved_at"]) or (pick(srcs[0], SF.get("retrieved_at", "retrieved_at")) if srcs else None)
        if aof and rat:
            if str(aof) == str(rat):
                bad("TBDS-1-6.2", "%s：as_of 与 retrieved_at 相同" % tag)
            else:
                ok("TBDS-1-6.2")
        elif aof:
            bad("TBDS-1-6.2", "%s：有 as_of 但无 retrieved_at" % tag)
        else:
            bad("TBDS-1-6.2", "%s：缺 as_of" % tag)

        if pick(r, MP["content_hash"]):
            ok("TBDS-1-6.3")
        else:
            bad("TBDS-1-6.3", "%s：无 content_hash（SHOULD）" % tag)

        if pick(r, MP["revision_log"]):
            ok("TBDS-1-7.2")
        if pick(r, MP["deleted"]) is True:
            bad("TBDS-1-7.3", "%s：用 deleted 表示失效（须 tombstone）" % tag)
        else:
            ok("TBDS-1-7.3")

    print("== TBDS-1 符合性自检 ==")
    print("记录数：%d｜规则：%d（可机检 %d / 需人工举证 %d）｜映射：%s\n"
          % (len(records), len(RULES), len(RULES) - len(MANUAL_RULES),
             len(MANUAL_RULES), "自定义" if "--map=" in " ".join(sys.argv) else "默认同名"))
    must_fail = 0
    for rid, lvl, desc in RULES:
        if rid in MANUAL_RULES:
            print("  MANUAL  %-6s %-14s %s" % ("", rid, desc))
            continue
        f = res[rid]
        if f:
            print("  FAIL    %-6s %-14s %s" % (lvl, rid, desc))
            for m in f[:5]:
                print("              - " + m)
            if len(f) > 5:
                print("              - …共 %d 处" % len(f))
            if lvl == "MUST":
                must_fail += 1
        else:
            print("  PASS    %-6s %-14s %s" % (lvl, rid, desc))
    print()
    reached = None
    if lvl_want:
        need = LEVELS.get(lvl_want, ())
        manual = [r for r in need if r in MANUAL_RULES]
        missed = [r for r in need if r not in MANUAL_RULES and res[r]]
        print("声明层级 %s：机检通过 %d/%d｜需人工举证 %d%s"
              % (lvl_want, len(need) - len(manual) - len(missed), len(need), len(manual),
                 "" if not missed else "｜未过：" + ",".join(missed)))
        reached = not missed
        print("结论（按声明层级 %s）：%s%s"
              % (lvl_want, "达到" if reached else "未达到",
                 "" if not missed else "；阻塞规则：" + ",".join(missed)))
        if missed:
            print("     提示：全量 MUST 失败 %d 条（含层级外规则）；本层级结论仅计入 %s"
                  % (sum(1 for r, l, _ in RULES if l == "MUST" and res[r]), lvl_want))
    else:
        print("结论（全量）：MUST 失败 %d 条 —— %s"
              % (must_fail, "符合" if must_fail == 0 else "不符合"))
    return 1 if (must_fail and not reached) or (not lvl_want and must_fail) else 0


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