#!/usr/bin/env python3
"""Build one case: QA the run outputs, then render core cards and the full read.

Usage: python3 build.py cases/<slug>
"""
from __future__ import annotations

import html
import json
import re
import shutil
import sys
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parent

PUBLISHED_RUN_FILES = (
    "pass1.md",
    "step1-notes.md",
    "reviewed.md",
    "review-notes.md",
    "desk-notes.md",
    "final.md",
    "article.md",
    "grouping-notes.md",
    "card-notes.md",
    "cards.json",
)


def esc(value: str) -> str:
    return html.escape(value)


def norm(text: str) -> str:
    return re.sub(r"\s+", "", text)


NUMBER = re.compile(r"\d[\d,.]*(?:–[\d,.]+)?")


# ---------- QA ----------

def content_blocks(text: str) -> list[str]:
    """Paragraphs and list items, ignoring headings; nested items stay with their parent."""
    blocks: list[str] = []
    paragraph: list[str] = []

    def flush() -> None:
        if paragraph:
            blocks.append(" ".join(paragraph))
            paragraph.clear()

    for raw in text.splitlines():
        stripped = raw.strip()
        if not stripped or stripped.startswith("#"):
            flush()
            continue
        if raw.startswith((" ", "\t")) and re.match(r"^[-*]\s+", stripped) and blocks:
            flush()
            blocks[-1] += "\n" + stripped
        elif re.match(r"^(?:[-*]|\d+\.)\s+", stripped):
            flush()
            blocks.append(stripped)
        else:
            paragraph.append(stripped)
    flush()
    return blocks


def check_grouping(final: str, article: str) -> None:
    a = Counter(norm(b) for b in content_blocks(final))
    b = Counter(norm(b) for b in content_blocks(article))
    if a != b:
        raise SystemExit(
            f"grouping changed content: missing {sum((a - b).values())}, extra {sum((b - a).values())}"
        )
    if Counter(NUMBER.findall(final)) != Counter(NUMBER.findall(article)):
        raise SystemExit("grouping changed numbers")
    heads = lambda t: [l for l in t.splitlines() if re.match(r"^#{1,2} ", l)]
    if heads(final) != heads(article):
        raise SystemExit("grouping changed title or ## headings")


def card_text(card: dict) -> str:
    values = [card["question"], card["answer"]]
    for block in card.get("blocks", []):
        values += [block["label"], block["text"]]
    for side in card.get("sides", []):
        values += [side["label"], *side["items"]]
    for gate in card.get("gates", []):
        values += [gate["name"], gate["when"], gate["text"]]
    if card.get("readthrough"):
        values.append(card["readthrough"])
    return " ".join(values)


def check_cards(data: dict, article: str) -> None:
    flat = article.replace(" ", "")
    no_commas = article.replace(",", "")
    cards = data["cards"]
    if not 4 <= len(cards) <= 8:
        raise SystemExit(f"expected 4–8 cards, got {len(cards)}")
    for i, card in enumerate(cards, 1):
        modules = sum(len(card.get(k, [])) for k in ("blocks", "sides", "gates"))
        if not 2 <= modules <= 5:
            raise SystemExit(f"card {i}: {modules} modules, expected 2–5")
        for number in re.findall(r"\d[\d,.]*(?:\s*%|亿|万|倍)?", card_text(card)):
            token = number.replace(" ", "")
            bare = re.sub(r"[%亿万倍,]", "", token)
            if bare and token not in flat and bare not in no_commas:
                raise SystemExit(f"card {i}: number not in article: {number}")


PRIVACY = re.compile(r"群里|群聊|微信|发言人|有人说|买入|卖出|加仓|抄底|目标价")
PLAIN_ENGLISH = re.compile(r"\b(?:compute|headline|pricing|beat|guide|guidance|pipeline|upside)\b", re.I)
FRAMES = {
    "不是X而是Y": re.compile(r"不是[^。！？；\n]{0,60}而是"),
    "是A还是B": re.compile(r"是[^。！？；\n]{0,60}还是"),
    "强调框架": re.compile(r"真正(?:的)?|最大的信号|最硬(?:的)?证据"),
}


def check_language(name: str, text: str) -> None:
    problems = []
    if m := PRIVACY.search(text):
        problems.append(f"privacy/advice {m.group(0)!r}")
    if english := sorted({m.group(0) for m in PLAIN_ENGLISH.finditer(text)}):
        problems.append(f"plain English {english}")
    for label, pattern in FRAMES.items():
        if hits := pattern.findall(text):
            problems.append(f"{label} {hits[:3]}")
    if problems:
        raise SystemExit(f"{name}: " + "; ".join(problems))


# ---------- shared look ----------

CSS = """
:root{--bg:#f1f2f3;--card:#fff;--ink:#17191d;--text:#3a3d44;--mute:#91959e;--line:#e9ebee;--hot:#b85b2b;--hot-soft:#f9eee7;--up:#39845a;--up-soft:#edf6f0;--down:#a95048;--down-soft:#faefed;--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Noto Sans SC",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
*{box-sizing:border-box;min-width:0}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.75 var(--font);-webkit-font-smoothing:antialiased;overflow-wrap:anywhere}
header,main,footer{max-width:650px;margin:0 auto;padding-left:15px;padding-right:15px}header{padding-top:32px}h1{margin:0;color:var(--ink);font-size:25px;line-height:1.4}.deck{margin:8px 0 16px;color:var(--mute);font-size:13px}
.tabs{display:inline-flex;gap:2px;padding:3px;margin-bottom:10px;background:#fff;border-radius:99px}.tabs a{padding:4px 12px;border-radius:99px;color:var(--mute);text-decoration:none;font-size:12px;font-weight:600}.tabs a.on{background:var(--ink);color:#fff}
nav{position:sticky;top:0;z-index:5;display:flex;gap:6px;flex-wrap:wrap;padding:10px 15px;background:rgba(241,242,243,.94);backdrop-filter:blur(12px)}nav a{display:grid;place-items:center;width:32px;height:26px;border-radius:99px;background:#fff;color:var(--mute);text-decoration:none;font:600 11px var(--mono)}nav.cards a:first-child{background:var(--hot);color:#fff}
.card{background:var(--card);border-radius:16px;padding:22px 20px 16px;margin:13px 0;scroll-margin-top:58px;box-shadow:0 1px 1px rgba(0,0,0,.025)}.num{font:600 11px var(--mono);color:var(--mute);letter-spacing:.08em;margin-bottom:13px}
h2{margin:0;color:var(--ink);font-size:20px;line-height:1.5}.answer{margin:10px 0 17px;color:var(--hot);font-size:16px;line-height:1.65;font-weight:700}
.blocks{border-top:1px solid var(--line)}.block{display:grid;grid-template-columns:5.2em 1fr;gap:10px;padding:13px 0;border-bottom:1px dashed var(--line)}.label{color:var(--mute);font-size:12px;font-weight:700;padding-top:2px}.block p,.gate p,.readthrough p{margin:0}
.sides{display:grid;grid-template-columns:1fr 1fr;gap:10px}.side{border-radius:12px;padding:12px}.side.up{background:var(--up-soft)}.side.down{background:var(--down-soft)}.side-label{font-size:12px;font-weight:800;margin-bottom:7px}.up .side-label{color:var(--up)}.down .side-label{color:var(--down)}
ul{list-style:none;margin:0;padding:0}li{position:relative;padding-left:14px;margin:0 0 10px;font-size:14px;line-height:1.65}li:last-child{margin-bottom:0}li:before{content:"";position:absolute;left:0;top:.72em;width:4px;height:4px;border-radius:50%;background:currentColor}
.gates{border-top:1px solid var(--line)}.gate{display:grid;grid-template-columns:25px 1fr;gap:10px;padding:13px 0;border-bottom:1px dashed var(--line)}.gate-num{width:23px;height:23px;border-radius:50%;border:1px solid var(--hot);color:var(--hot);display:grid;place-items:center;font:700 11px var(--mono)}.gate-head{display:flex;gap:8px;align-items:baseline;margin-bottom:3px}.gate-head b{color:var(--ink)}.gate-head em{margin-left:auto;color:var(--mute);font-size:11px;font-style:normal}
.readthrough{margin-top:14px;padding:12px 13px;background:var(--hot-soft);border-radius:11px}.readthrough b{color:var(--hot);font-size:12px}.readthrough p{margin-top:3px;font-size:13.5px}
.more{display:flex;justify-content:space-between;align-items:center;margin-top:15px;color:var(--ink);text-decoration:none;font-weight:600;font-size:13px}.more span{color:var(--hot);font-size:18px}
.lead{margin:0 0 14px;color:var(--ink);font-size:15.5px;line-height:1.75}.lead p{margin:0 0 6px}
.sec h2{margin-bottom:12px}.group{padding:12px 0 13px;border-top:1px solid var(--line)}.group+.group{border-top-style:dashed}.group-label{margin-bottom:7px;color:var(--hot);font-size:12px;font-weight:800;letter-spacing:.02em}
.up .group-label{color:var(--up)}.down .group-label{color:var(--down)}.group p{margin:0 0 8px;font-size:14.5px}.group p:last-child{margin-bottom:0}.group li{font-size:14.5px;line-height:1.75}.group b{color:var(--ink);font-weight:700}
.group li ul{margin-top:5px}.group li li{font-size:13.5px;color:var(--mute);margin-bottom:4px}.group li li:before{width:6px;height:0;border-top:1px solid currentColor;border-radius:0;background:none}
.ledger .group p,.ledger .group li{font-size:13.5px;color:#5d6169}
footer{padding-top:8px;padding-bottom:42px;color:var(--mute);font-size:11px}footer a{color:var(--mute)}
@media(max-width:500px){header{padding-top:24px}h1{font-size:23px}.card{padding:19px 16px 14px}h2{font-size:19px}.answer{font-size:15.5px}.sides{grid-template-columns:1fr}.block{grid-template-columns:4.6em 1fr}}
"""


def page(title: str, deck: str, active: str, lead: str, nav: str, nav_class: str, body: str, footer: str) -> str:
    tabs = "".join(
        f'<a class="{"on" if key == active else ""}" href="{href}">{label}</a>'
        for key, href, label in (("cards", "/", "核心卡片"), ("article", "/article.html", "完整导读"))
    )
    return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{esc(title)}</title><style>{CSS}</style></head><body>
<header><h1>{esc(title)}</h1><p class="deck">{esc(deck)}</p><div class="tabs">{tabs}</div>{lead}</header>
<nav class="{nav_class}">{nav}</nav><main>{body}</main>
<footer>{footer}</footer>
</body></html>"""


# ---------- core cards ----------

def render_cards(data: dict, footer: str) -> str:
    cards, nav = [], []
    ordered = sorted(data["cards"], key=lambda card: 0 if "多空" in card["question"] else 1)
    for i, card in enumerate(ordered, 1):
        nav.append(f'<a href="#c{i}">{i:02d}</a>')
        body = ""
        if card.get("blocks"):
            body += '<div class="blocks">' + "".join(
                f'<div class="block"><div class="label">{esc(b["label"])}</div><p>{esc(b["text"])}</p></div>'
                for b in card["blocks"]
            ) + "</div>"
        if card.get("sides"):
            body += '<div class="sides">' + "".join(
                f'<div class="side {esc(s["tone"])}"><div class="side-label">{esc(s["label"])}</div><ul>'
                + "".join(f"<li>{esc(item)}</li>" for item in s["items"])
                + "</ul></div>"
                for s in card["sides"]
            ) + "</div>"
        if card.get("gates"):
            body += '<div class="gates">' + "".join(
                f'<div class="gate"><span class="gate-num">{n}</span><div><div class="gate-head"><b>{esc(g["name"])}</b><em>{esc(g["when"])}</em></div><p>{esc(g["text"])}</p></div></div>'
                for n, g in enumerate(card["gates"], 1)
            ) + "</div>"
        if card.get("readthrough"):
            body += f'<div class="readthrough"><b>顺带影响</b><p>{esc(card["readthrough"])}</p></div>'
        cards.append(
            f'<section class="card" id="c{i}"><div class="num">{i:02d}</div><h2>{esc(card["question"])}</h2>'
            f'<p class="answer">{esc(card["answer"])}</p>{body}'
            f'<a class="more" href="/article.html">完整导读 <span>↗</span></a></section>'
        )
    return page(data["title"], data["deck"], "cards", "", "".join(nav), "cards", "".join(cards), footer)


# ---------- full read ----------

def inline(text: str) -> str:
    return re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", esc(text))


def parse_article(text: str):
    title, intro, sections = "", [], []
    section = group = None
    paragraph: list[str] = []

    def target() -> list:
        nonlocal group
        if section is None:
            return intro
        if group is None:
            group = {"title": "", "blocks": []}
            section["groups"].append(group)
        return group["blocks"]

    def flush() -> None:
        if paragraph:
            target().append({"kind": "p", "text": " ".join(paragraph), "subs": []})
            paragraph.clear()

    for raw in text.splitlines():
        stripped = raw.strip()
        if not stripped:
            flush()
        elif stripped.startswith("# ") and not title:
            flush()
            title = stripped[2:]
        elif stripped.startswith("## "):
            flush()
            section = {"title": stripped[3:], "groups": []}
            sections.append(section)
            group = None
        elif stripped.startswith("### "):
            flush()
            if section is None:
                raise SystemExit("### before first ##")
            group = {"title": stripped[4:], "blocks": []}
            section["groups"].append(group)
        elif raw.startswith((" ", "\t")) and re.match(r"^[-*]\s+", stripped):
            flush()
            blocks = target()
            if not blocks or blocks[-1]["kind"] != "li":
                raise SystemExit(f"nested item without parent: {stripped}")
            blocks[-1]["subs"].append(re.sub(r"^[-*]\s+", "", stripped))
        elif m := re.match(r"^(?:[-*]|\d+\.)\s+(.+)$", stripped):
            flush()
            target().append({"kind": "li", "text": m.group(1), "subs": []})
        else:
            paragraph.append(stripped)
    flush()
    return title, intro, sections


def group_html(group: dict) -> str:
    chunks, items = [], []

    def flush_items() -> None:
        if items:
            chunks.append("<ul>" + "".join(items) + "</ul>")
            items.clear()

    for block in group["blocks"]:
        if block["kind"] == "li":
            subs = "".join(f"<li>{inline(s)}</li>" for s in block["subs"])
            items.append(f"<li>{inline(block['text'])}{f'<ul>{subs}</ul>' if subs else ''}</li>")
        else:
            flush_items()
            chunks.append(f"<p>{inline(block['text'])}</p>")
    flush_items()
    label = f'<div class="group-label">{esc(group["title"])}</div>' if group["title"] else ""
    return f'<div class="group">{label}{"".join(chunks)}</div>'


def render_article(text: str, deck: str, footer: str) -> str:
    title, intro, sections = parse_article(text)
    lead = '<div class="lead">' + "".join(f"<p>{inline(b['text'])}</p>" for b in intro) + "</div>" if intro else ""
    nav, body = [], []
    for i, section in enumerate(sections, 1):
        name = section["title"]
        tone = " up" if "多头" in name else " down" if "空头" in name else " ledger" if "口径" in name else ""
        nav.append(f'<a href="#s{i}">{i:02d}</a>')
        body.append(
            f'<section class="card sec{tone}" id="s{i}"><div class="num">{i:02d}</div><h2>{esc(name)}</h2>'
            + "".join(group_html(g) for g in section["groups"])
            + "</section>"
        )
    return page(title, deck, "article", lead, "".join(nav), "article", "".join(body), footer)


# ---------- build ----------

def main() -> None:
    if len(sys.argv) != 2:
        raise SystemExit(__doc__)
    case = (ROOT / sys.argv[1]).resolve()
    meta = json.loads((case / "case.json").read_text(encoding="utf-8"))
    run = case / "run"
    final = (run / "final.md").read_text(encoding="utf-8")
    article = (run / "article.md").read_text(encoding="utf-8")
    cards = json.loads((run / "cards.json").read_text(encoding="utf-8"))

    check_grouping(final, article)
    check_cards(cards, article)

    label = f'{meta["company"]} · {meta["quarter"]}'
    footer = (
        f'{esc(label)} · Earnings Read v5 · '
        '<a href="/method/PROMPTS.md">Prompt</a> · '
        '<a href="/method/HANDOFF.md">Handoff</a> · '
        '仅作业绩信息梳理，不构成投资建议。'
    )
    index_html = render_cards(cards, footer)
    article_html = render_article(article, f'{meta["ticker"]} · {meta["quarter"]} · 完整导读', footer)

    for name, text in (
        ("article.md", article),
        ("cards.json", json.dumps(cards, ensure_ascii=False)),
        ("index.html", index_html),
        ("article.html", article_html),
    ):
        check_language(name, text)

    public = case / "public"
    if public.exists():
        shutil.rmtree(public)
    (public / "method").mkdir(parents=True)
    (public / "run").mkdir()
    (public / "index.html").write_text(index_html, encoding="utf-8")
    (public / "article.html").write_text(article_html, encoding="utf-8")
    shutil.copyfile(ROOT / "PROMPTS.md", public / "method/PROMPTS.md")
    handoff = case / "HANDOFF.md"
    shutil.copyfile(handoff if handoff.is_file() else ROOT / "HANDOFF.md", public / "method/HANDOFF.md")
    shutil.copyfile(case / "INPUTS.md", public / "method/INPUTS.md")
    for name in PUBLISHED_RUN_FILES:
        if (run / name).is_file():
            shutil.copyfile(run / name, public / "run" / name)

    groups = sum(len(s["groups"]) for s in parse_article(article)[2])
    print(f"built {public}: {len(cards['cards'])} cards, {groups} article groups; all QA passed")


if __name__ == "__main__":
    main()
