"""씬 분해 실측 갤러리 — **잡은 것과 놓친 것을 둘 다** 보여준다.

개수만 보면 안 된다. 실측으로 겪었다: 경계가 조용히 바뀌어도 개수·번호
연속성·헤딩 내용이 전부 완벽할 수 있다(몽타주 안 항목이 진짜 씬을 밀어낸
경우). 그리고 잡은 것을 늘어놓는 것만으로도 부족하다 — **놓친 진짜 헤딩은
그 목록에 아예 없어서** 눈에 안 띈다.

그래서 두 방향을 다 싣는다.

  잡은 것   — 헤딩 원문(자르지 않는다) + 정리본 줄 번호
  놓쳤을 것 — ①구간 길이(놓친 헤딩이 합쳐지면 그 구간이 튄다)
              ②첫 경계 앞 머리말(검증이 아예 안 보는 자리)
              ③다른 후보만 잡은 경계(선택된 후보의 사각지대)

★연속성은 `number_group` 이 있을 때만 실측이다. 없으면 1.0 이 아니라 N/A 로
적는다 — 재지도 않은 값이 만점처럼 보이면 통과한 줄로 읽힌다.

사용:
    cd backend && .venv/bin/python build_segmenter_gallery.py
"""
from __future__ import annotations

import html
import json
import statistics
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from segmenter_common import fingerprint, load_json, norm as _norm  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "artifact" / "20260808_세그먼테이션_저작"
#: 구간이 중앙값의 이만큼을 넘으면 눈에 띄게 — 놓친 헤딩이 합쳐진 자리일 수 있다.
LONG_FACTOR = 3.0


def _slug(row: dict) -> str:
    """본문 뷰 파일명 — 저작 모델까지 넣는다.

    ★대본 이름만 쓰면 저작 모델이 둘일 때 **본문 경계 화면이 서로 덮인다**
    (Codex 지적). 마지막에 쓴 판본의 경계 표시가 앞 카드 링크에도 보여, 눈으로
    훑는 일 자체가 틀린 그림을 보게 된다 — 오늘 찾은 결함 넷은 전부 눈으로만
    드러났으므로 이 화면이 어긋나면 검증 수단이 사라진다.
    """
    base = (row.get("text_file") or "").rsplit(".txt", 1)[0]
    src = row.get("_src") or ""
    return f"{base}__{src}" if src else base


def esc(s: object) -> str:
    return html.escape(str(s if s is not None else ""))


def _used_text(row: dict) -> str:
    """그 대본의 정리본. 없으면 빈 문자열 — 지문이 자연히 달라진다."""
    tf = row.get("text_file")
    src = OUT / "used" / tf if tf else None
    return src.read_text("utf-8") if src and src.exists() else ""


def load_rows() -> list[dict]:
    """현재 판본만 읽는다.

    ★`authored_*.json` 을 통째로 훑으면 백업(`…before_fix.json`)까지 섞여
    같은 대본이 두 번 나온다. 실측으로 22개가 44개로 보였다 — 갤러리 머리글의
    "대본 N개"가 틀리면 무엇을 보고 있는지부터 어긋난다.
    """
    rows: list[dict] = []
    for p in sorted(OUT.glob("authored_*.json")):
        model = p.stem.replace("authored_", "")
        if "." in model:            # authored_gpt.before_fix → 백업
            continue
        for r in load_json(p):
            r["_src"] = model
            rows.append(r)

    # 다른 모델이 본 결과를 붙인다. 아직 안 돌린 모델은 그냥 없다.
    # ★`probe_*.json` 은 탐지기가 켜져 있는지 재려고 틀린 판본에 돌린 것이라
    # 여기 섞이면 안 된다 — 그 지적은 이미 고친 자리를 가리킨다.
    #
    # ★이름만으로 붙이면 안 된다(Codex 지적). 저작 모델이 둘이면(`authored_gpt`
    # 와 `authored_gpt-terra`) 대본 이름이 같아서, gpt 규칙을 검토한 결과가
    # terra 행에도 붙는다. 규칙이 바뀐 뒤 안 돌린 검토도 현행 판정처럼 보인다.
    #
    # ★개수로 대조하면 약하다. 오늘 찾은 결함 넷이 전부 "개수는 맞는데 경계가
    # 틀린" 것이었다 — 규칙과 잡은 자리를 함께 담은 지문으로 본다. 지문이 없는
    # 옛 기록은 개수로만 확인했다고 화면에 밝힌다(조용히 통과시키지 않는다).
    for p in sorted(OUT.glob("crosscheck_*.json")):
        checker = p.stem.replace("crosscheck_", "")
        by_name = {c["name"]: c for c in load_json(p)}
        for r in rows:
            got = by_name.get(r["name"])
            if not got:
                continue
            if not got.get("error"):
                mine = fingerprint(r, _used_text(r))
                theirs = got.get("checked")
                if theirs and theirs != mine:
                    got = {"name": r["name"],
                           "error": "이 규칙을 검토한 것이 아니다 "
                                    f"(검토 {theirs} / 지금 {mine})"}
                elif not theirs and got.get("count") != r.get("count"):
                    got = {"name": r["name"],
                           "error": f"이 규칙을 검토한 것이 아니다 "
                                    f"(검토 당시 {got.get('count')}개 / 지금 {r.get('count')}개)"}
                elif not theirs:
                    got = dict(got, _weak_match=True)
            r.setdefault("_cross", {})[checker] = got
    return rows




def seg_table(segs: list[dict], slug: str) -> str:
    if not segs:
        return ""
    med = statistics.median(s["chars"] for s in segs) or 1
    out = ['<table class="segs"><tr><th>#</th><th>줄</th><th>번호</th>'
           "<th>글자</th><th>줄수</th><th>헤딩 (원문 그대로)</th><th>본문</th></tr>"]
    for i, s in enumerate(segs, start=1):
        long = s["chars"] > med * LONG_FACTOR
        out.append(
            f'<tr class="{"long" if long else ""}">'
            f'<td>{i}</td><td>{s["line"]}</td><td>{esc(s["no"])}</td>'
            f'<td>{s["chars"]:,}{" ★" if long else ""}</td>'
            f'<td>{s.get("lines", "")}</td>'
            f'<td>{esc(s["heading"])}</td>'
            f'<td><a href="text_{slug}.html#L{s["line"]}" target="_blank">열기</a></td>'
            "</tr>")
    out.append("</table>")
    n_long = sum(1 for s in segs if s["chars"] > med * LONG_FACTOR)
    head = (f'<p class="hint">구간 중앙값 {int(med):,}자 · '
            f'★표시({LONG_FACTOR:g}배 초과) {n_long}개 — '
            "긴 구간은 그 안에 헤딩이 묻혀 있을 수 있다. "
            '<b>본문 "열기"</b>로 그 자리를 직접 본다.</p>')
    return head + "".join(out)


def build_text_view(row: dict) -> str:
    """정리본 전체에 **경계를 표시한** 본문 — 놓친 헤딩을 눈으로 찾는 자리.

    ★줄 번호만 적어 두면 "긴 구간에 헤딩이 묻혔나"를 화면에서 확인할 수 없다
    (Codex 지적). 다른 창에서 파일을 열어 그 줄을 찾아야 한다. 여기서는 경계
    줄에 표시를 달고 각 줄에 앵커를 두어, 구간 표에서 바로 그 자리로 간다.
    """
    tf = row.get("text_file")
    if not tf:
        return ""
    src = OUT / "used" / tf
    if not src.exists():
        return ""
    text = src.read_text("utf-8")
    marks = {s["line"]: s for s in (row.get("segments") or [])}
    others = {o["line"]: o for o in (row.get("only_others") or [])}

    rows = []
    for i, ln in enumerate(text.split("\n"), start=1):
        cls = "b" if i in marks else ("o" if i in others else "")
        tag = ""
        if i in marks:
            tag = f'<span class="tag">경계 #{marks[i]["no"] or "?"}</span>'
        elif i in others:
            tag = f'<span class="tag o">다른 후보: {esc(others[i]["cand"])}</span>'
        rows.append(f'<tr id="L{i}" class="{cls}"><td class="n">{i}</td>'
                    f"<td>{tag}{esc(ln)}</td></tr>")
    return (
        '<meta charset="utf-8">\n'
        f"<title>{esc(row['name'])} — 경계 표시 본문</title>\n"
        "<style>body{font:13px/1.6 ui-monospace,Menlo,monospace;margin:0;padding:16px}"
        "table{border-collapse:collapse;width:100%}"
        "td{padding:1px 8px;vertical-align:top;white-space:pre-wrap}"
        "td.n{color:#999;text-align:right;user-select:none;width:60px;"
        "border-right:1px solid #ddd}"
        "tr.b td{background:rgba(60,170,110,.20);font-weight:600}"
        "tr.o td{background:rgba(220,150,60,.20)}"
        ".tag{display:inline-block;font-size:11px;background:#3a7;color:#fff;"
        "padding:0 6px;border-radius:3px;margin-right:6px}"
        ".tag.o{background:#c83}"
        ":target td{outline:2px solid #c33}</style>\n"
        f"<h3>{esc(row['name'])} — 초록=선택된 경계 · 주황=다른 후보만 잡은 자리</h3>\n"
        f'<table>{"".join(rows)}</table>\n')


def _line_index(r: dict) -> dict:
    """정리본의 '그 줄 → 줄 번호'. 같은 줄이 여럿이면 처음 것을 가리킨다."""
    tf = r.get("text_file")
    src = OUT / "used" / tf if tf else None
    if not src or not src.exists():
        return {}
    idx: dict = {}
    for i, line in enumerate(src.read_text("utf-8").split("\n"), start=1):
        idx.setdefault(_norm(line), i)
    return idx


def cross_section(r: dict, cross: dict, slug: str) -> str:
    """다른 모델이 본 것 — 지적을 답으로 두지 않고, 짚은 줄을 본문에 연결한다.

    ★모델이 옮겼다는 줄이 **원문에 정말 있는지**는 이미 대조했다. 그 수를 같이
    보여주는 이유는, 지어낸 줄이 있으면 그 모델의 나머지 지적도 그만큼만
    믿어야 하기 때문이다.

    ★지적 자체는 답이 아니다. 실측에서 qwen 은 몽타주를 "씬 아님"이라 했고
    gpt 는 씬 안의 회상을 "놓친 씬"이라 했다 — 방향만 반대인 같은 함정이다.
    그래서 판정을 싣지 않고 **그 줄로 가는 링크**를 싣는다. 눈으로 본다.
    """
    idx = _line_index(r)
    out = ['<details open><summary>★다른 모델이 본 것 '
           f'({len(cross)}종) — 지적은 답이 아니다, 그 줄을 열어 본다</summary>']
    for checker, got in sorted(cross.items()):
        if got.get("error"):
            out.append(f'<p class="err">{esc(checker)}: 검토 못 함 — '
                       f'{esc(got["error"][:160])}</p>')
            continue
        fake = (got.get("missed_fabricated") or []) + (got.get("not_scenes_fabricated") or [])
        head = (f'<p class="sum">{esc(checker)} — 놓쳤다 {len(got.get("missed") or [])} · '
                f'씬 아니다 {len(got.get("not_scenes") or [])}')
        if fake:
            head += f' · <b>★원문에 없는 줄 {len(fake)}</b>'
        if got.get("unclear"):
            head += (f' · 자리 못 가림 {len(got["unclear"])}')
        if got.get("_weak_match"):
            head += (' · <b>지문 없는 옛 기록 — 개수로만 맞춰 봤다</b>')
        out.append(head + f'</p><p class="hint">{esc(got.get("note"))}</p>')
        rows_ = [("놓쳤다", m) for m in (got.get("missed") or [])]
        rows_ += [("씬 아니다", m) for m in (got.get("not_scenes") or [])]
        if rows_:
            out.append("<table><tr><th>지적</th><th>그 줄</th><th>본문</th></tr>")
            for kind, line in rows_:
                ln = idx.get(_norm(line))
                link = (f'<a href="text_{slug}.html#L{ln}" target="_blank">{ln}줄</a>'
                        if ln else "—")
                out.append(f'<tr><td>{kind}</td><td>{esc(line)}</td>'
                           f'<td>{link}</td></tr>')
            out.append("</table>")
        for f in fake:
            out.append(f'<p class="err">★어긋난 줄(원문에 없거나 주장과 반대): '
                       f'{esc(f)}</p>')
        for u in (got.get("unclear") or []):
            out.append(f'<p class="hint">자리 못 가림 — 같은 문구가 경계에도 '
                       f'본문에도 있다: {esc(u)}</p>')
    out.append("</details>")
    return "".join(out)


def card(r: dict) -> str:
    ok = bool(r.get("rule"))
    p = [f'<section class="card {"ok" if ok else "bad"}">',
         f'<h2>{esc(r["name"])} <small>{r["chars"]:,}자 · '
         f'{r.get("lines", 0):,}줄 · 저작 {esc(r.get("_src"))}</small></h2>']
    if ok:
        cont = r.get("continuity")
        p.append(
            f'<p class="sum"><b>{r["count"]}개</b> · 헤딩 내용 {r.get("content")}'
            f' · 번호 연속성 {cont if cont is not None else "N/A (번호 없는 규칙)"}'
            f' · number_group {esc(r.get("number_group"))}</p>'
            f'<pre class="pat">{esc(r.get("pattern"))}</pre>')
        d = r.get("detail") or {}
        keep = {k: v for k, v in d.items() if k.startswith(("C5", "C7"))}
        if keep:
            p.append(f'<p class="hint">검증 세부: {esc(keep)}</p>')
    else:
        p.append('<p class="sum">★규칙을 못 찾았다</p>')

    pre = r.get("prefix")
    if pre and pre.get("chars"):
        p.append(
            f'<details><summary>★첫 경계 앞 {pre["chars"]:,}자 / {pre["lines"]}줄 — '
            "어느 구간에도 안 들어간다. 검증이 이 자리를 아예 안 본다</summary>"
            f'<pre class="prev">{esc(pre.get("preview"))}</pre></details>')

    slug = _slug(r)
    others = r.get("only_others") or []
    if others:
        p.append(f'<details><summary>★다른 후보만 잡은 경계 {len(others)}개 — '
                 "선택된 규칙이 <b>놓친</b> 자리일 수 있다 (위치로 중복 제거함)"
                 "</summary><table>")
        p.append("<tr><th>후보</th><th>줄</th><th>그 줄</th><th>본문</th></tr>")
        for o in others:
            p.append(f'<tr><td>{esc(o["cand"])}</td><td>{o["line"]}</td>'
                     f'<td>{esc(o["heading"])}</td>'
                     f'<td><a href="text_{slug}.html#L{o["line"]}" '
                     'target="_blank">열기</a></td></tr>')
        p.append("</table></details>")

    only_sel = r.get("only_selected") or []
    if only_sel:
        p.append(f'<details><summary>★선택된 규칙만 잡은 경계 {len(only_sel)}개 — '
                 "다른 후보는 아무도 씬으로 안 본 자리다. <b>과잉 검출</b>일 수 있다"
                 "</summary><table>")
        p.append("<tr><th>줄</th><th>그 줄</th><th>못 잡은 후보</th><th>본문</th></tr>")
        for o in only_sel:
            p.append(f'<tr><td>{o["line"]}</td><td>{esc(o["heading"])}</td>'
                     f'<td>{esc(", ".join(o["missed_by"]))}</td>'
                     f'<td><a href="text_{slug}.html#L{o["line"]}" '
                     'target="_blank">열기</a></td></tr>')
        p.append("</table></details>")

    cross = r.get("_cross") or {}
    if cross:
        p.append(cross_section(r, cross, slug))

    for rd in (r.get("rounds") or []):
        if "error" in rd:
            p.append(f'<p class="err">{rd["round"]}회차 실패: {esc(rd["error"])}</p>')
            continue
        p.append(f'<details><summary>{rd["round"]}회차 후보 '
                 f'{len(rd.get("candidates") or [])}개 — {esc(rd.get("observed"))}'
                 "</summary><table>")
        p.append("<tr><th>이름</th><th>수</th><th>내용</th><th>연속성</th>"
                 "<th>번호그룹</th><th>판정</th><th>탈락 세부</th><th>정규식</th><th>근거로 든 줄</th></tr>")
        for c in rd.get("candidates") or []:
            mark = "통과" if c["ok"] else "✗ " + ",".join(c.get("failures") or [])
            cont = (c.get("continuity") if c.get("number_group") is not None
                    else "N/A")
            p.append(
                f'<tr><td>{esc(c["name"])}</td><td>{c["count"]}</td>'
                f'<td>{c.get("content")}</td><td>{esc(cont)}</td>'
                f'<td>{esc(c.get("number_group"))}</td><td>{esc(mark)}</td>'
                f'<td class="ra">{esc({k: v for k, v in (c.get("detail") or {}).items() if k.startswith("C5")}) if not c["ok"] else ""}</td>'
                f'<td><code>{esc(c["pattern"])}</code></td>'
                f'<td class="ra">{esc(c.get("rationale"))}</td></tr>')
        p.append("</table></details>")

    segs = r.get("segments") or []
    if segs:
        p.append(f'<details open><summary>잡은 구간 {len(segs)}개 — '
                 "★하나씩 훑어 씬이 아닌 것이 섞였는지, 긴 구간에 헤딩이 묻혔는지 본다"
                 f"</summary>{seg_table(segs, slug)}</details>")
    p.append("</section>")
    return "\n".join(p)


CSS = """
:root { color-scheme: light dark; }
body { font: 14px/1.55 -apple-system, "Apple SD Gothic Neo", sans-serif;
       margin: 0 auto; max-width: 1240px; padding: 24px; }
h1 { font-size: 21px; margin: 0 0 4px; }
.lead { color: #666; margin: 0 0 20px; }
.card { border: 1px solid #ccc; border-left: 5px solid #3a7; border-radius: 8px;
        padding: 14px 18px; margin: 0 0 18px; }
.card.bad { border-left-color: #c33; }
h2 { font-size: 16px; margin: 0 0 6px; }
h2 small { font-weight: 400; color: #777; font-size: 12px; }
.sum { margin: 4px 0 8px; }
.hint { color: #777; font-size: 12px; margin: 4px 0; }
.pat, .prev { background: rgba(128,128,128,.12); padding: 8px 10px;
              border-radius: 5px; overflow-x: auto; font-size: 12px;
              margin: 0 0 8px; white-space: pre-wrap; }
.prev { max-height: 220px; overflow-y: auto; }
.err { color: #c33; }
summary { cursor: pointer; margin: 6px 0; font-size: 13px; }
table { border-collapse: collapse; width: 100%; font-size: 12px; margin: 6px 0 10px; }
th, td { border: 1px solid #bbb; padding: 4px 7px; text-align: left;
         vertical-align: top; }
td code { font-size: 11px; word-break: break-all; }
.ra { white-space: pre-wrap; max-width: 240px; font-size: 11px; color: #666; }
table.segs { display: block; max-height: 520px; overflow-y: auto; }
tr.long td { background: rgba(220,70,70,.16); font-weight: 600; }
"""


def main() -> None:
    rows = load_rows()
    ok = sum(1 for r in rows if r.get("rule"))
    body = "\n".join(card(r) for r in rows)
    doc = (
        '<meta charset="utf-8">\n'
        "<title>씬 분해 규칙 저작 실측</title>\n"
        f"<style>{CSS}</style>\n"
        "<h1>씬 분해 규칙 저작 실측</h1>\n"
        f'<p class="lead">대본 {len(rows)}개 · 규칙을 찾은 것 {ok}개. '
        "<b>선택됐다는 것은 검증됐다는 뜻이 아니다.</b> 후보 중 상대적으로 나은 것을 "
        "고른 것뿐이라, 모두 틀려도 하나는 뽑힌다. 아래 셋을 직접 본다 — "
        "①잡은 구간에 씬 아닌 것이 섞였나 ②★표시된 긴 구간에 헤딩이 묻혔나 "
        "③다른 후보만 잡은 경계가 진짜 씬이었나.</p>\n"
        f"{body}\n"
    )
    for r in rows:
        view = build_text_view(r)
        if view:
            (OUT / f"text_{_slug(r)}.html").write_text(view, encoding="utf-8")
    target = OUT / "index.html"
    target.write_text(doc, encoding="utf-8")
    print(f"갤러리 → {target}  (대본 {len(rows)}개, 규칙 찾음 {ok})")


if __name__ == "__main__":
    main()
