"""씬 분해 실험대 — 규칙 실행·검증 코어를 실제 대본 22개에 건다.

설계 = docs/superpowers/specs/2026-08-08-scene-segmentation-rule-authoring-design.md

## 이 스크립트의 자리

프로덕션에 붙이지 않는다. `scene_extractor_v2.py` 는 손대지 않는다.

여기 있는 **후보 규칙 목록은 일반해가 아니다.** 관찰된 8형식에서 손으로 옮긴
것이고, 코어(`segment_rule`)가 실제 대본에서 도는지 먼저 재기 위한 자다.
최종적으로 이 목록 자리는 **LLM 이 그 대본을 읽고 저작한 규칙**이 대신한다 —
고정 목록을 프로덕션에 넣으면 새 형식이 올 때마다 또 놓친다.

사용:
    cd backend && .venv/bin/python segmenter_lab.py            # PDF 전수
    cd backend && .venv/bin/python segmenter_lab.py --cleaned  # 정리본(DB)
"""
from __future__ import annotations

import argparse
import hashlib
import json
import sys
from pathlib import Path

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

from app.modules.pipeline.segment_rule import (  # noqa: E402
    SegmentRule, apply_rule, choose_rule, verify,
)
from segmenter_common import (  # noqa: E402
    check_names, load_json, merge_by_name, save_json, save_text_addressed)

ROOT = Path(__file__).resolve().parent.parent
ORG = ROOT / "org"
OUT = ROOT / "artifact" / "20260808_세그먼테이션_저작"

# ── 손으로 옮긴 후보 (실험용 자 — 프로덕션 아님) ──────────────────────
HAND_CANDIDATES = [
    SegmentRule("줄머리 번호", r"^[ \t]*(\d+)[ \t]*\.", 1),
    SegmentRule("S#번호", r"^[ \t]*S[ \t]*#[ \t]*(\d+)", 1),
    SegmentRule("볼드 번호", r"^[ \t]*\*\*[ \t]*(\d+)[ \t]*\.", 1),
    SegmentRule("#번호", r"^[ \t]*#[ \t]*(\d+)[ \t]*\.", 1),
    SegmentRule("INT/EXT", r"^[ \t]*(?:INT|EXT|I/E)[\.\s]", None),
    SegmentRule("안/밖", r"^[ \t]*(?:안|밖)[\.\s]", None),
]


def pdf_text(path: Path, max_pages: int = 200) -> str:
    import fitz

    doc = fitz.open(path)
    try:
        return "\n".join(doc[i].get_text()
                         for i in range(min(doc.page_count, max_pages)))
    finally:
        doc.close()


def cleaned_texts() -> list[tuple[str, str]]:
    """DB·체크포인트에 있는 정리본 — 파이프라인이 실제로 나누는 그 텍스트."""
    import psycopg2

    conn = psycopg2.connect(host="localhost", user="theroad",
                            password="theroad_dev_2026", dbname="theroad")
    cur = conn.cursor()
    cur.execute("SELECT id, project_id, coalesce(title,''), fulltext FROM episode "
                "WHERE fulltext IS NOT NULL AND length(fulltext) > 5000")
    # ★이름에 원문 지문을 붙인다. 제목만 쓰면 **같은 제목의 에피소드 둘이
    #  같은 이름**이 된다(실측: 컨트리로드가 9,354자와 5,518자로 두 건). 이름이
    #  기록의 키라서 겹치면 하나가 다른 하나를 덮거나, 화면이 이름으로 접을 때
    #  옛것이 새것을 가린다. 겹칠 때만 붙이면 대본이 늘고 줄 때마다 이름이
    #  달라지므로 항상 붙인다.
    picked: dict[str, tuple[str, str]] = {}
    for epi, proj, title, ft in cur.fetchall():
        sha = hashlib.sha256(ft.encode()).hexdigest()[:12]
        p = (ROOT / "projects" / proj / "checkpoints" / "episodes" / epi
             / "text_cleanup" / "manifest.json")
        if p.exists():
            cl = (json.loads(p.read_text("utf-8")).get("data") or {}).get("cleaned_text")
            if cl:
                picked[sha] = (f"{title} [정리본 {sha[:6]}]", cl)
        picked.setdefault(sha, (f"{title} [원문 {sha[:6]}]", ft))
    return list(picked.values())


def corpus_texts() -> list[tuple[str, str]]:
    """`build_cleaned_corpus.py` 가 만든 정리본 — 프로덕션과 같은 경로로 나온 것.

    규칙은 정리본에서 저작하고 정리본에 실행해야 한다. PDF 직접 추출에는 쪽
    번호가 남고 헤딩이 두 줄로 갈리는데 정리본에서는 사라진다.
    """
    d = OUT / "cleaned"
    if not d.exists():
        return []
    out = []
    for p in sorted(d.glob("*.txt")):
        if p.name.endswith(".error.txt"):
            continue
        t = p.read_text("utf-8")
        if t.strip():
            out.append((p.stem, t))
    return out


def run(sources: list[tuple[str, str]]) -> list[dict]:
    rows = []
    for name, text in sources:
        rule, v = choose_rule(text, HAND_CANDIDATES)
        # 고른 규칙 말고 다른 후보가 어떻게 나왔는지도 남긴다 — 왜 그것이
        # 뽑혔는지 사람이 볼 수 있어야 한다.
        others = []
        for c in HAND_CANDIDATES:
            try:
                vv = verify(text, apply_rule(text, c), c)
            except ValueError as exc:
                others.append((c.name, f"거부: {exc}"))
                continue
            others.append((c.name, f"{vv.match_count}개 내용{vv.heading_content:.2f}"
                                   + ("" if vv.ok else f" ✗{','.join(vv.failures)}")))
        rows.append({
            "name": name, "chars": len(text),
            "rule": rule.name if rule else None,
            "count": v.match_count if v else 0,
            "continuity": round(v.number_continuity, 3) if v else None,
            "heading_content": round(v.heading_content, 3) if v else None,
            "others": others,
        })
    return rows


def run_authored(sources: list[tuple[str, str]], model: str,
                 max_rounds: int = 3) -> list[dict]:
    """LLM 이 저작한 규칙으로 잰다 — 실패하면 이유를 주고 다시 쓰게 한다.

    ★저작과 판정을 갈라 둔다. 판정은 `segment_rule` 의 기계 계약이 하고,
    저작기는 자기 답을 채점하지 않는다.
    """
    from app.modules.pipeline.segment_rule_author import author_and_choose

    # ★막힐 것은 **돈을 쓰기 전에** 전부 막는다(Codex 지적, 재현함). 전에는
    #  대본 하나를 저작한 뒤에야 이름 겹침이나 잘린 원문 파일을 알아채고
    #  멈췄다 — 다시 실행해도 같은 자리에서 같은 값을 또 낸다. 실측으로
    #  `--cleaned` 경로에 같은 이름 두 건이 있었다(컨트리로드, 서로 다른 원문).
    check_names([{"name": n} for n, _ in sources], why="잴 대본 목록")
    for name, text in sources:
        save_text_addressed(used_path(name, text), text)

    rows = []
    for name, text in sources:
        # ★루프는 프로덕션과 **같은 것**을 쓴다. 실험에서 찾은 결함 대응이
        #  한쪽에만 남으면 조용히 갈린다.
        chosen, verdict, rounds = author_and_choose(
            text, model=model, max_rounds=max_rounds)
        rows.append(_measure(name, text, model, chosen, verdict, rounds))
    return rows


def used_path(name: str, text: str):
    """정리본을 남길 자리 — **한 곳에서만 계산한다.**

    ★사전 검사와 저장이 각자 경로를 만들면 서로 다른 파일을 보게 된다
    (Codex 지적). 그러면 미리 봐 준 것이 실제로 쓰는 파일이 아니게 된다.
    """
    import re as _re
    safe = _re.sub(r"[^\w가-힣 .\-]", "_", name)[:80]
    sha = hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
    return OUT / "used" / f"{safe}__{sha}.txt"


def _measure(name, text, model, chosen, verdict, rounds) -> dict:
    """판정에 필요한 것을 **전부** 남긴다.

    ★개수만 보면 안 된다(실측). 경계가 조용히 바뀌어도 개수·번호 연속성·헤딩
    내용이 모두 완벽할 수 있다. 그리고 잡은 것을 늘어놓는 것만으로는 부족하다 —
    **놓친 진짜 헤딩은 그 목록에 아예 없어서** 눈에 안 띈다(Codex 지적).

    그래서 두 방향을 다 남긴다:
      · 잡은 것 — 헤딩 원문(자르지 않는다) + 원문 줄 번호
      · 놓쳤을 가능성 — 구간 길이(놓친 헤딩이 합쳐지면 그 구간이 튄다),
        첫 경계 앞 머리말 크기(C3 가 아예 안 보는 자리),
        다른 후보만 잡은 경계(선택된 후보의 사각지대)
    """
    row = {
        "name": name, "chars": len(text), "model": model,
        "lines": text.count("\n") + 1,
        "rule": chosen.name if chosen else None,
        "pattern": chosen.pattern if chosen else None,
        "number_group": chosen.number_group if chosen else None,
        "count": verdict.match_count if verdict else 0,
        # ★number_group 이 없으면 연속성은 **측정한 값이 아니라 기본값**이다.
        #  1.0 으로 보이면 통과한 것처럼 읽힌다 — None 으로 구분한다.
        "content": round(verdict.heading_content, 3) if verdict else None,
        "continuity": (round(verdict.number_continuity, 3)
                       if verdict and chosen and chosen.number_group is not None
                       else None),
        "detail": (verdict.detail if verdict else {}),
        "rounds": rounds,
    }
    if not chosen:
        row.update(headings=[], segments=[], prefix=None, only_others=[])
        return row

    segs = apply_rule(text, chosen)
    # 원문 줄 번호 — 사람이 정리본을 열어 그 자리를 직접 볼 수 있게
    line_of = {}
    ln = 1
    for i, ch in enumerate(text):
        line_of[i] = ln
        if ch == "\n":
            ln += 1
    row["headings"] = [s.heading for s in segs]          # ★자르지 않는다
    row["segments"] = [
        {"no": s.scene_no, "heading": s.heading,
         "line": line_of.get(s.start_char, 0),
         "chars": s.end_char - s.start_char,
         "lines": s.text.count("\n") + 1}
        for s in segs]
    row["prefix"] = {"chars": segs[0].start_char,
                     "lines": text[:segs[0].start_char].count("\n"),
                     "preview": text[:segs[0].start_char][-400:]}

    # ★정리본을 그대로 남긴다. 갤러리가 이것을 읽어 **경계를 표시한 본문**을
    #  만든다. 줄 번호만 적어 두면 "긴 구간에 헤딩이 묻혔나"를 화면에서 확인할
    #  방법이 없다 — 다른 창에서 파일을 열어 그 줄을 찾아야 한다.
    #
    # ★파일명에 내용 지문을 넣는다(Codex 3차 지적). 대본 이름만 쓰면 다른 저작
    #  모델이나 새 text_cleanup 이 **같은 파일을 덮어써서**, 옛 기록이 가리키던
    #  근거가 소급으로 바뀐다. 그러면 지문 대조도 소용이 없다 — 양쪽이 함께
    #  바뀌니까. 내용이 다르면 다른 파일이 되어 옛 기록은 자기 원문을 계속
    #  가리킨다. 파일은 더하기만 하고 덮지 않는다.
    tp = used_path(name, text)
    save_text_addressed(tp, text)       # 있으면 내용 대조, 없으면 원자적으로
    row["text_file"] = tp.name
    row["text_sha"] = hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]

    # ★후보 간 차이를 **양방향**으로 본다(Codex 지적). 한쪽만 보면
    #  "선택된 규칙이 놓친 자리"는 보여도 "선택된 규칙만 잡은 자리"는 안 보인다.
    #  뒤쪽이 과잉 검출(씬 아닌 것을 씬으로)의 신호다.
    seen = {s.start_char for s in segs}
    others: dict[int, dict] = {}      # 위치로 중복 제거 — 같은 경계가 여러
    only_sel: dict[int, set] = {}     # 후보·라운드에서 반복돼 칸을 잡아먹었다
    all_others: set[int] = set()
    for rd in rounds:
        for c in rd.get("candidates") or []:
            # ★계약을 통과한 후보만 견준다. "선택된 규칙이 놓친 자리일 수
            #  있다"는 주장은 **쓸 만하다고 판정된 후보**가 그 자리를 잡았을
            #  때만 근거가 된다. 탈락한 후보(예: 한 줄만 잡은 것)를 섞으면
            #  본문 아무 줄이나 "놓친 씬"으로 올라온다.
            if not c.get("ok") or c["pattern"] == chosen.pattern:
                continue
            try:
                osegs = apply_rule(text, SegmentRule(
                    c["name"], c["pattern"], c.get("number_group")))
            except ValueError:
                continue
            opos = {s.start_char for s in osegs}
            all_others |= opos
            for s in osegs:
                if s.start_char not in seen and s.start_char not in others:
                    others[s.start_char] = {
                        "cand": c["name"], "line": line_of.get(s.start_char, 0),
                        "heading": s.heading}
            for pos in seen - opos:
                only_sel.setdefault(pos, set()).add(c["name"])

    row["only_others"] = [others[k] for k in sorted(others)]
    by_pos = {s.start_char: s for s in segs}
    row["only_selected"] = [
        {"line": line_of.get(pos, 0), "heading": by_pos[pos].heading,
         "missed_by": sorted(only_sel[pos])}
        for pos in sorted(only_sel)
        if all_others and pos not in all_others]
    return row


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--cleaned", action="store_true",
                    help="PDF 대신 DB 정리본으로 잰다")
    ap.add_argument("--corpus", action="store_true",
                    help="build_cleaned_corpus.py 가 만든 정리본 파일로 잰다")
    ap.add_argument("--author", metavar="MODEL",
                    help="LLM 이 저작한 규칙으로 잰다 (gpt / gpt-terra)")
    ap.add_argument("--only", metavar="키워드",
                    help="이름에 이 말이 든 대본만")
    args = ap.parse_args()

    if args.corpus:
        sources = corpus_texts()
    elif args.cleaned:
        sources = cleaned_texts()
    else:
        sources = [(p.name, pdf_text(p)) for p in sorted(ORG.glob("*.pdf"))]
    if args.only:
        sources = [s for s in sources if args.only in s[0]]

    if args.author:
        OUT.mkdir(parents=True, exist_ok=True)
        path = OUT / f"authored_{args.author}.json"
        # ★쓸 수 있는 자리인지 **돈을 쓰기 전에** 확인한다(Codex 재현). 저작은
        #  대본 전문을 22번 보내는 가장 비싼 경로라, 다 끝난 뒤에 기존 파일을
        #  못 읽어 멈추면 그 값을 통째로 버린다.
        load_json(path)
        rows = run_authored(sources, args.author)
        print(f"대본 {len(rows)}개 · 저작 모델 {args.author}\n")
        for r in rows:
            print(f"{r['name'][:44]:<44} {r['chars']:>8,}자  "
                  f"{(r['rule'] or '★실패'):<20} {r['count']:>4}개"
                  + (f"  내용 {r['content']}  연속성 {r['continuity']}"
                     if r["content"] is not None else ""))
            for rd in r["rounds"]:
                if "error" in rd:
                    print(f"      {rd['round']}회차 ✗ {rd['error']}")
                    continue
                print(f"      {rd['round']}회차 — 관찰: {rd['observed'][:80]}")
                for c in rd["candidates"]:
                    mark = "✓" if c["ok"] else f"✗{','.join(c['failures'])}"
                    print(f"        · {c['name'][:22]:<22} {c['count']:>4}개 "
                          f"내용{c['content']} {mark}  {c['pattern'][:44]}")
        merged, kept = merge_by_name(path, rows)
        save_json(path, merged)
        print(f"\n기록 → {path}"
              + (f" (새로 잰 {len(rows)}개 갱신, 기존 {kept}개 그대로)"
                 if kept else ""))
        return

    rows = run(sources)
    picked = sum(1 for r in rows if r["rule"])
    print(f"대본 {len(rows)}개 · 규칙이 잡힌 것 {picked}개\n")
    w = min(46, max(len(r["name"]) for r in rows))
    for r in rows:
        head = (f"{r['name'][:46]:<{w}} {r['chars']:>8,}자  "
                f"{(r['rule'] or '★없음'):<12} {r['count']:>4}개")
        if r["continuity"] is not None:
            head += f"  연속성 {r['continuity']}  내용 {r['heading_content']}"
        print(head)
        for cname, res in r["others"]:
            print(f"      · {cname:<14} {res}")

    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / ("lab_cleaned.json" if args.cleaned else "lab_pdf.json")).write_text(
        json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"\n기록 → {OUT}")


if __name__ == "__main__":
    main()
