#!/usr/bin/env python3
"""机械校验 Gitea Wiki 与源码的可比对事实(签名/导出/字段序/列清单/env 键)。

**只查机械可比对的部分**——机制语义、行为口径这类需要读懂代码才能判断的断言
不在此列(那部分靠 `解释-治理行为` 的适用性总表做单一事实源 + 人工审查)。

设计动机: 2026-08 对 wiki 做了七轮人工审查,88 条发现里有相当一部分属于
"机械可校验却写错"——`gather_bounded(coros, limit)`(实为 keyword-only 的
`concurrency`)、`LLMResponse` 字段表把 `source_name` 排进前 11 位、遥测列数
写成 20/21(实为 22 列)、`__version__` 在自称"全集"的页面缺席。这类偏差不该
靠人一轮轮追,故收敛为脚本。

用法(wiki 是独立仓库,须显式给路径;**不做 skip 静默降级**):
    python3 tools/check_wiki_alignment.py --wiki /path/to/PolyGateway.wiki
"""

from __future__ import annotations

import argparse
import dataclasses
import inspect
import re
import sys
from pathlib import Path

import polygateway
from polygateway import EmbeddingClient, GatewayClient, LLMResponse
from polygateway.config import _SOURCE_FIELDS
from polygateway.ocr import OcrClient
from polygateway.providers import register_provider
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS

# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}

# (符号, 可调用对象) —— 这些的签名必须在 wiki 里逐参数出现
_SIGNATURE_TARGETS = [
    ("GatewayClient.chat", GatewayClient.chat),
    ("GatewayClient.from_env", GatewayClient.from_env),
    ("EmbeddingClient.from_env", EmbeddingClient.from_env),
    ("EmbeddingClient.embed", EmbeddingClient.embed),
    ("OcrClient.from_env", OcrClient.from_env),
    ("OcrClient.recognize_text", OcrClient.recognize_text),
    ("gather_bounded", polygateway.gather_bounded),
    ("register_provider", register_provider),
]


def _wiki_text(wiki: Path) -> dict[str, str]:
    """读全部 .md;文件名(不含后缀)→ 正文。"""
    pages = {p.stem: p.read_text(encoding="utf-8") for p in sorted(wiki.glob("*.md"))}
    if not pages:
        raise SystemExit(f"错误: {wiki} 下没有 .md 文件,路径是否指向 wiki 克隆?")
    return pages


def check_exports_documented(pages: dict[str, str]) -> list[str]:
    """`__all__` 每一项都得在某页出现过(R1 漏 gather_bounded、R6 漏 __version__)。"""
    blob = "\n".join(pages.values())
    missing = [name for name in polygateway.__all__ if name not in blob]
    return [f"__all__ 的 {name!r} 在全部 wiki 页面中零命中(页首自称『顶层导出全集』)"
            for name in missing]


def check_signatures(pages: dict[str, str]) -> list[str]:
    """提到某个公共可调用的那一行,必须列全它的参数名。

    只查「参数名是否出现」,不查顺序与类型——后者用自然语言表述合法。
    历史命中: gather_bounded 的 concurrency 被写成 limit;EmbeddingClient/
    OcrClient 的 from_env 用省略号承接 chat 的关键字集合,掩盖了没有 cache=。
    """
    problems = []
    for label, func in _SIGNATURE_TARGETS:
        symbol = label.split(".")[-1]
        params = [
            p.name
            for p in inspect.signature(func).parameters.values()
            if p.name not in ("self", "cls")
        ]
        # 找出提到该符号的所有行,任一行列全即算通过
        lines = [
            line
            for text in pages.values()
            for line in text.splitlines()
            if f"`{symbol}`" in line or f"{symbol}(" in line
        ]
        if not lines:
            problems.append(f"{label}: wiki 里找不到任何提及")
            continue
        best_missing: list[str] | None = None
        for line in lines:
            missing = [
                p for p in params
                if p not in line and not (_PARAM_ALIASES.get(p, set()) & set(line.split()))
            ]
            if not missing:
                best_missing = []
                break
            if best_missing is None or len(missing) < len(best_missing):
                best_missing = missing
        if best_missing:
            problems.append(
                f"{label}: 没有任何一行列全参数,最接近的一行仍缺 {best_missing}"
                f"(实际签名 {inspect.signature(func)})"
            )
    return problems


def check_llmresponse_field_order(pages: dict[str, str]) -> list[str]:
    """字段表出现顺序须与 dataclass 声明顺序一致。

    R2 命中: wiki 把 source_name 与 model/provider 并成一行(位置 5),而它实为
    第 12 个字段。迁移中的三项目按位置构造 fake,照 wiki 写会静默错位。
    """
    page = pages.get("参考-公共API")
    if page is None:
        return ["缺少 参考-公共API.md"]
    # 只在 LLMResponse 小节内找: 别的类型(EmbeddingResponse 等)也有同名字段,
    # 全页搜索会命中它们、把顺序判断带偏
    start = page.find("## LLMResponse")
    if start < 0:
        return ["参考-公共API.md 缺少 `## LLMResponse` 小节"]
    end = page.find("\n## ", start + 1)
    section = page[start : end if end > 0 else len(page)]
    declared = [f.name for f in dataclasses.fields(LLMResponse)]
    positions = []
    for name in declared:
        # 取该字段在小节内最早的出现位置(表格首列可能写成 `a / b` 合并形式)
        cands = [
            section.find(pat)
            for pat in (f"| {name} ", f"{name} /", f"/ {name} ", f"| {name}\n")
        ]
        hits = [i for i in cands if i >= 0]
        positions.append((name, min(hits) if hits else -1))
    documented = [n for n, i in positions if i >= 0]
    missing = [n for n, i in positions if i < 0]
    problems = [f"LLMResponse 字段 {missing} 未在 参考-公共API 的字段表出现"] if missing else []
    ordered = sorted((i, n) for n, i in positions if i >= 0)
    actual = [n for _, n in ordered]
    expected = [n for n in declared if n in documented]
    if actual != expected:
        problems.append(
            f"LLMResponse 字段表顺序与声明顺序不符\n"
            f"    wiki 顺序: {actual}\n"
            f"    声明顺序: {expected}"
        )
    return problems


def check_telemetry_columns(pages: dict[str, str]) -> list[str]:
    """遥测列清单与 sqlite 后端的 _COLUMNS 对齐(created_at 由 DDL 生成,单列)。"""
    page = pages.get("指南-遥测与成本")
    if page is None:
        return ["缺少 指南-遥测与成本.md"]
    expected = [*TELEMETRY_COLUMNS, "created_at"]
    missing = [c for c in expected if c not in page]
    problems = [f"遥测列 {missing} 未在 指南-遥测与成本 出现"] if missing else []
    # 列数声明: 表 = _COLUMNS + created_at;端口 = _COLUMNS。
    # 页内**所有** "N 列" 声明都必须等于真实列数——只查"正确值是否出现"会被
    # 漏改的旧数字骗过(它们同时存在时检查照样通过)
    table_n, port_n = len(expected), len(TELEMETRY_COLUMNS)
    declared_counts = {int(m) for m in re.findall(r"(\d+)\s*列", page)}
    if not declared_counts:
        problems.append(f"指南-遥测与成本 未声明表列数(应为 {table_n} 列)")
    elif declared_counts != {table_n}:
        wrong = sorted(declared_counts - {table_n})
        problems.append(
            f"指南-遥测与成本 的列数声明 {wrong} 与实际 {table_n} 列不符"
            f"(端口是 {port_n} 参数,两者差 created_at)"
        )
    return problems


def check_source_env_fields(pages: dict[str, str]) -> list[str]:
    """`_SOURCE_FIELDS` 的每个 FIELD 段都得在 参考-配置键 出现(R1 命中 EXTRA_BODY)。"""
    page = pages.get("参考-配置键")
    if page is None:
        return ["缺少 参考-配置键.md"]
    # 用词边界匹配: `f in page` 会让 EXTRA_BODY 被 EXTRA_BODYY 蒙混过关
    missing = [f for f in _SOURCE_FIELDS if not re.search(rf"\b{re.escape(f)}\b", page)]
    return [f"源键 FIELD 段 {missing} 未在 参考-配置键 文档化"] if missing else []


_CHECKS = [
    ("顶层导出覆盖", check_exports_documented),
    ("公共签名参数", check_signatures),
    ("LLMResponse 字段序", check_llmresponse_field_order),
    ("遥测列清单", check_telemetry_columns),
    ("源 env 键覆盖", check_source_env_fields),
]


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--wiki", required=True, type=Path, help="PolyGateway.wiki 克隆目录")
    args = parser.parse_args()
    if not args.wiki.is_dir():
        raise SystemExit(f"错误: {args.wiki} 不是目录")

    pages = _wiki_text(args.wiki)
    failed = 0
    for label, check in _CHECKS:
        problems = check(pages)
        if problems:
            failed += len(problems)
            print(f"✗ {label}")
            for p in problems:
                print(f"    {p}")
        else:
            print(f"✓ {label}")
    print()
    if failed:
        print(f"{failed} 处机械偏差 —— wiki 与源码不一致")
        return 1
    print(f"{len(pages)} 页机械校验通过")
    return 0


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