#!/usr/bin/env python3
"""教学覆盖层 · 对照卡静态视图生成器(机制①)。

读 teaching/cards/*.yml,渲染成"概念 │ 源码 │ 形式化"三栏对照网页 + 索引。
设计原则:
  - 零额外依赖(stdlib + 仓库已有的 pyyaml);
  - 源码 span 在生成时**实时从仓库读取**(read_source_span),对照卡永不与代码脱节;
    锚点失效(文件/行号不存在)会在页面上显式报警,而非静默腐烂;
  - live_demo 直接挂现有 run 的 trace.json,自动算统计 + 抽工具调用。

用法:  python3 teaching/gen.py            # 生成到 teaching/site/
       python3 teaching/gen.py --check   # 只校验锚点,不写文件(CI 用)
"""
from __future__ import annotations

import html
import json
import re
import sys
from pathlib import Path

import yaml

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
CARDS_DIR = HERE / "cards"
SITE_DIR = HERE / "site"

MODULE_NAMES = {
    "A": "A · 智能体即解释器",
    "B": "B · 状态、记忆与成本",
    "C": "C · 安全、编排与产物",
    "D": "D · 可靠性与形式化收口",
}

# 已知内置工具名,用于从 react-over-text 的文本 trace 里抽"被调用的工具"。
KNOWN_TOOLS = [
    "ReadFile", "WriteFile", "EditFile", "ListFiles", "SearchContent",
    "Bash", "WebSearch", "WebFetch", "DocGen", "ChunkSplit", "KBSearch",
    "RunTests", "Git", "terminate", "done",
]


# ──────────────────────────── 数据加载 ────────────────────────────
def load_cards():
    cards = []
    for f in sorted(CARDS_DIR.glob("*.yml")):
        data = yaml.safe_load(f.read_text(encoding="utf-8"))
        data["_src"] = f.name
        cards.append(data)
    cards.sort(key=lambda c: (c.get("week", 99), c.get("id", "")))
    return cards


def read_source_span(file_rel: str, lines: str):
    """实时从仓库读源码 span。返回 (ok, [(lineno, text)...] | error_msg)。"""
    p = REPO / file_rel
    if not p.exists():
        return False, f"锚点文件不存在: {file_rel}"
    try:
        start, end = (int(x) for x in str(lines).split("-"))
    except ValueError:
        return False, f"行号格式应为 'a-b',得到: {lines}"
    all_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
    if start < 1 or end > len(all_lines) or start > end:
        return False, f"行号越界: {lines}(文件共 {len(all_lines)} 行)"
    return True, [(i, all_lines[i - 1]) for i in range(start, end + 1)]


def summarize_trace(run_rel: str):
    """读 run 的 trace.json,返回统计 dict 或 None。"""
    if not run_rel:
        return None
    run = REPO / run_rel
    tp = run / "trace.json"
    if not tp.exists():
        return {"missing": True, "path": run_rel}
    try:
        events = json.loads(tp.read_text(encoding="utf-8", errors="replace"))
    except Exception as e:  # noqa: BLE001
        return {"missing": True, "path": run_rel, "error": str(e)}
    if not isinstance(events, list):
        events = [events]
    tools = {}
    pat = re.compile(r"\[Step \d+\]\s*([A-Za-z_]\w*)")
    for e in events:
        text = f"{e.get('output', '')}"
        for m in pat.findall(text):
            if m in KNOWN_TOOLS:
                tools[m] = tools.get(m, 0) + 1
    # 兜底:若 [Step] 模式没抽到,扫已知工具词
    if not tools:
        blob = " ".join(str(e.get("output", "")) for e in events)
        for t in KNOWN_TOOLS:
            n = blob.count(t)
            if n:
                tools[t] = n
    cost = {}
    cp = run / "cost.json"
    if cp.exists():
        try:
            cost = json.loads(cp.read_text(encoding="utf-8"))
        except Exception:  # noqa: BLE001
            cost = {}
    return {
        "missing": False,
        "path": run_rel,
        "events": len(events),
        "tokens": sum(int(e.get("tokens_used", 0) or 0) for e in events),
        "duration_ms": sum(float(e.get("duration_ms", 0) or 0) for e in events),
        "tools": sorted(tools.items(), key=lambda kv: -kv[1]),
        "status": cost.get("status", ""),
        "steps": cost.get("steps", len(events)),
    }


# ──────────────────────────── 渲染 ────────────────────────────
def esc(s):
    return html.escape(str(s if s is not None else ""))


CSS = """
:root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
--codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
*{box-sizing:border-box}
body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
color:var(--fg);background:var(--bg);margin:0;padding:0}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
.wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
header.top h1{margin:0 0 4px;font-size:20px}
.sub{color:var(--mut);font-size:13px}
.crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
.badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
border-radius:4px;padding:1px 8px;margin-right:6px}
h2.ctitle{font-size:19px;margin:6px 0 4px}
.keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
border-radius:4px;margin:14px 0;font-size:14px}
.cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
.col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
.col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
.col.concept{background:var(--conceptbg)}
.col.formal{background:var(--formalbg)}
.col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
.anchor{margin:0 0 16px}
.anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
.anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
.ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
.note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
.warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
padding:8px 12px;font-size:13px}
.kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
.formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
.effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
.trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
.trace h3{margin:0 0 10px;font-size:14px}
.stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
.stat b{font-size:17px;color:var(--accent)}
.toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
.takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
white-space:pre-wrap;font-size:14px}
.rel a{margin-right:14px;font-size:13px}
table.idx{border-collapse:collapse;width:100%;margin-top:8px}
table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
.modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
@media(max-width:900px){.cols{grid-template-columns:1fr}}
"""


def render_code_block(file_rel, lines):
    ok, payload = read_source_span(file_rel, lines)
    if not ok:
        return f'<div class="warn">⚠ {esc(payload)}</div>'
    rows = []
    for n, text in payload:
        rows.append(f'<div class="ln"><span class="n">{n}</span>'
                    f'<span class="c">{esc(text)}</span></div>')
    return f'<pre><code>{"".join(rows)}</code></pre>'


def render_card(card, cards_by_id):
    cid = card.get("id", "?")
    title = card.get("title", cid)
    week = card.get("week", "?")
    module = card.get("module", "?")
    concept = card.get("concept", {}) or {}
    formal = card.get("formal", {}) or {}

    # 概念栏
    concept_html = f'<p>{esc(concept.get("textbook", ""))}</p>'

    # 源码栏
    anchors = []
    for a in card.get("code", []) or []:
        block = render_code_block(a.get("file", ""), a.get("lines", ""))
        anchors.append(
            '<div class="anchor"><div class="meta">'
            f'<code>{esc(a.get("file",""))}</code> · '
            f'<b>{esc(a.get("symbol",""))}</b> · L{esc(a.get("lines",""))}'
            f'</div>{block}'
            + (f'<div class="note">{esc(a.get("note",""))}</div>' if a.get("note") else "")
            + "</div>"
        )
    code_html = "".join(anchors) or '<div class="note">(无源码锚点)</div>'

    # 形式化栏
    formal_html = ""
    if formal.get("term"):
        formal_html += f'<div class="term">{esc(formal["term"])}</div>'
    if formal.get("effect"):
        formal_html += f'<div class="effect">effect: {esc(formal["effect"])}</div>'
    if formal.get("note"):
        formal_html += f'<p>{esc(formal["note"])}</p>'

    # trace 面板
    trace_html = ""
    ld = card.get("live_demo") or {}
    summ = summarize_trace(ld.get("run", ""))
    if summ is not None:
        if summ.get("missing"):
            if ld.get("run"):
                trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
                              f'<div class="warn">trace 未找到: {esc(summ["path"])}</div>'
                              + (f'<p class="note">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
                              + "</div>")
        else:
            chips = "".join(f'<span class="toolchip">{esc(t)} ×{n}</span>'
                            for t, n in summ["tools"][:10]) or '<span class="note">未从文本抽到工具调用</span>'
            trace_html = (
                '<div class="trace"><h3>🎞 Live Demo · 真实 run</h3>'
                f'<div class="note"><code>{esc(summ["path"])}</code> · status={esc(summ["status"])}</div>'
                f'<div class="stat"><b>{summ["events"]}</b> 步</div>'
                f'<div class="stat"><b>{summ["tokens"]}</b> tokens</div>'
                f'<div class="stat"><b>{summ["duration_ms"]/1000:.1f}</b> s</div>'
                f'<div style="margin-top:8px">{chips}</div>'
                + (f'<p class="note" style="margin-top:8px">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
                + "</div>"
            )
    elif ld.get("highlight"):
        trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
                      f'<p class="note">{esc(ld["highlight"])}</p></div>')

    # 相关卡
    rel = card.get("related", []) or []
    rel_links = []
    for r in rel:
        rc = cards_by_id.get(r)
        label = rc.get("title", r) if rc else r
        if rc:
            rel_links.append(f'<a href="{r}.html">{esc(label)}</a>')
        else:
            rel_links.append(f'<span class="note">{esc(r)}(待建)</span>')
    rel_html = (f'<p class="rel"><b>相关对照卡:</b> {" ".join(rel_links)}</p>'
                if rel_links else "")

    keypoint = (f'<div class="keypoint">🎯 {esc(concept.get("key_point",""))}</div>'
                if concept.get("key_point") else "")
    takeaway = (f'<div class="takeaway"><b>学习目标 / Takeaway</b>\n{esc(card.get("takeaway",""))}</div>'
                if card.get("takeaway") else "")

    body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{esc(title)}</title><style>{CSS}</style></head><body><div class="wrap">
<div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
<header class="top">
<span class="badge">模块 {esc(module)}</span><span class="badge">第 {esc(week)} 周</span>
<span class="badge">{esc(cid)}</span>
<h2 class="ctitle">{esc(title)}</h2></header>
{keypoint}
<div class="cols">
  <div class="col concept"><h3>📖 教科书概念</h3>{concept_html}</div>
  <div class="col code"><h3>💻 平台源码落点</h3>{code_html}</div>
  <div class="col formal"><h3>∑ 形式化对象</h3>{formal_html}</div>
</div>
{trace_html}
{takeaway}
{rel_html}
<footer>对照卡源文件: <code>teaching/cards/{esc(card.get("_src",""))}</code> ·
源码 span 由 gen.py 在生成时实时读自仓库</footer>
</div></body></html>"""
    return body


def render_index(cards):
    by_mod = {}
    for c in cards:
        by_mod.setdefault(c.get("module", "?"), []).append(c)
    rows = []
    for mod in sorted(by_mod):
        rows.append(f'<div class="modhdr">{esc(MODULE_NAMES.get(mod, mod))}</div>')
        rows.append('<table class="idx">')
        for c in by_mod[mod]:
            cid = c.get("id")
            kp = (c.get("concept") or {}).get("key_point", "")
            rows.append(
                f'<tr><td class="wk">第 {esc(c.get("week","?"))} 周</td>'
                f'<td><a href="{esc(cid)}.html"><b>{esc(c.get("title",cid))}</b></a>'
                f'<div class="note">{esc(kp)}</div></td></tr>'
            )
        rows.append("</table>")
    body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>对照卡索引 · 理论↔代码对应</title><style>{CSS}</style></head><body><div class="wrap">
<header class="top"><h1>智能体:理论 ↔ 代码 对照卡</h1>
<div class="sub">每张卡三栏对照:教科书概念 │ 平台源码落点(实时读仓库) │ 形式化对象。
共 {len(cards)} 张卡。</div></header>
{"".join(rows)}
<footer>由 <code>teaching/gen.py</code> 生成 · 卡片源: <code>teaching/cards/*.yml</code></footer>
</div></body></html>"""
    return body


def main():
    check_only = "--check" in sys.argv
    cards = load_cards()
    cards_by_id = {c.get("id"): c for c in cards}

    # 锚点校验(check 模式 + 正常模式都跑)
    problems = []
    for c in cards:
        for a in c.get("code", []) or []:
            ok, msg = read_source_span(a.get("file", ""), a.get("lines", ""))
            if not ok:
                problems.append(f"[{c.get('id')}] {msg}")
    if problems:
        print("⚠ 锚点问题:")
        for p in problems:
            print("   -", p)
    else:
        print(f"✓ {sum(len(c.get('code',[]) or []) for c in cards)} 个源码锚点全部有效")

    if check_only:
        sys.exit(1 if problems else 0)

    SITE_DIR.mkdir(exist_ok=True)
    (SITE_DIR / "index.html").write_text(render_index(cards), encoding="utf-8")
    for c in cards:
        (SITE_DIR / f"{c['id']}.html").write_text(
            render_card(c, cards_by_id), encoding="utf-8")
    print(f"✓ 生成 {len(cards)+1} 个页面 → {SITE_DIR.relative_to(REPO)}/")
    print(f"  打开: file://{SITE_DIR}/index.html")


if __name__ == "__main__":
    main()
