"""씬 경계를 헤딩으로 앵커링했을 때 100% 정합이 되는지 오프라인 검증.

현재 구현(`scene_extractor_v2.py:330`)은 LLM 이 준 `start_line_text` 를 원문에서
찾아 경계를 정한다. 그 문자열이 짧거나 흔하면 진짜 씬보다 앞에 걸리고, 다음
검색이 직전 씬의 **시작 +1** 에서 출발하므로 한 번 앞에 걸리면 계속 앞에 머문다
— 오차가 누적돼 에피소드 끝에서 1만 자(씬 15~20개 분량)까지 벌어진다. 실측:
자기 헤딩이 자기 범위 안에 있는 세그먼트가 113개 중 9개.

헤딩은 원문에서 유일하다. 다만 형식이 한 가지가 아니다 — 실측된 변형:
  "9. 다시 편의점 밖 - 밤"          (실내/실외 없음)
  "14. 실외/ 실내. 국도를 …"        (실외/실내 결합)
  "89. 경찰 순찰정 - 계속"          (시간 자리에 '계속')
  "114. 같은 장소 - 잠시 후"        (장소 자리에 '같은 장소')
  "39. 실외. 선착장"                (뒤 ' - 시간' 없음)
그래서 형식을 가정하지 않는다. LLM 이 준 헤딩 문자열을 그대로 찾고(공백 정규화),
실패하면 줄머리 씬 번호로 떨어진다.
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

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

import psycopg2  # noqa: E402

# 공백/전각·반각·유사문자 흔들림 흡수 — 위치만 찾으므로 의미 판단 아님
_WS = re.compile(r"\s+")
_SEP = str.maketrans({"／": "/", "－": "-", "–": "-", "—": "-", "·": ".",
                      "（": "(", "）": ")"})


def norm(s: str) -> str:
    return _WS.sub(" ", (s or "").translate(_SEP)).strip()


def build_index(fulltext: str):
    """정규화 문자열 → 원문 오프셋 역매핑."""
    out, back = [], []
    prev_ws = True
    for i, ch in enumerate(fulltext):
        c = ch.translate(_SEP)
        if c.isspace():
            if prev_ws:
                continue
            out.append(" ")
            back.append(i)
            prev_ws = True
        else:
            out.append(c)
            back.append(i)
            prev_ws = False
    return "".join(out), back


def scene_no(heading: str):
    m = re.match(r"\s*(\d+)\s*\.", heading or "")
    return int(m.group(1)) if m else None


def anchor(headings, fulltext: str):
    """헤딩 목록 → 원문 시작 위치. 단조증가 보장."""
    ntext, back = build_index(fulltext)
    # 줄머리 씬 번호 후보: "<num>." 앞이 문자열 처음이거나 공백
    numpos = {}
    for m in re.finditer(r"(?:(?<=^)|(?<=\s))(\d{1,3})\s*\.", ntext):
        numpos.setdefault(int(m.group(1)), []).append(m.start())

    starts, cursor, how = [], 0, []
    for h in headings:
        key = norm(h)
        pos = ntext.find(key, cursor)
        if pos >= 0:
            how.append("헤딩전체")
        else:
            n = scene_no(h)
            cand = [p for p in numpos.get(n, []) if p >= cursor] if n else []
            if not cand and n:                      # 커서 뒤에 없으면 전역
                cand = numpos.get(n, [])
            if cand:
                # 헤딩 나머지와 겹침이 가장 큰 후보
                rest = norm(re.sub(r"^\s*\d+\s*\.\s*", "", h))[:18]
                pos = max(cand, key=lambda p: _overlap(ntext, p, rest))
                how.append("씬번호")
            else:
                pos = cursor
                how.append("실패")
        starts.append(pos)
        cursor = pos + 1
    return [back[p] if p < len(back) else len(fulltext) for p in starts], how


def _overlap(ntext: str, p: int, rest: str) -> int:
    if not rest:
        return 0
    win = ntext[p:p + 60]
    best = 0
    for L in range(len(rest), 2, -1):
        if rest[:L] in win:
            best = L
            break
    return best


def main() -> None:
    c = psycopg2.connect(host="localhost", user="theroad",
                         password="theroad_dev_2026", dbname="theroad")
    cur = c.cursor()
    runs = (("7/28", "475e5694-7c25-4f3f-8dec-910412a1761d",
             "18f15068-4349-4818-82a9-3b9b173110e0"),
            ("8/04", "e716bafb-24bb-42b7-aea0-fdb383844ee8",
             "d6a9aa85-b75e-400c-980c-4ee7e876a15b"))
    for lb, proj, epi in runs:
        cur.execute("select fulltext from episode where id=%s", (epi,))
        ft = cur.fetchone()[0]
        seg = json.loads((Path("projects") / proj / "checkpoints" / "episodes"
                          / epi / "scene_segmentation" / "manifest.json")
                         .read_text("utf-8"))
        segs = (seg.get("data") or {}).get("segments") or []
        heads = [s.get("heading") or "" for s in segs]
        starts, how = anchor(heads, ft)
        ends = starts[1:] + [len(ft)]

        # 검증은 **원문 기준**으로 한다. LLM 이 헤딩의 오타를 고쳐 적는 경우가
        # 있어(대본 "39. 실외. 선척장" → LLM "선착장") LLM 문자열을 원문에서
        # 찾는 검사는 원리상 실패한다. 앵커가 맞았는지는 "범위가 그 씬의 번호
        # 표기에서 시작하는가"로 본다.
        ok = mono = 0
        bad = []
        for i, (h, a, b) in enumerate(zip(heads, starts, ends)):
            n = scene_no(h)
            head_at_start = bool(
                n is not None
                and re.match(rf"\s*{n}\s*\.", ft[a:a + 12].translate(_SEP)))
            if head_at_start:
                ok += 1
            else:
                bad.append((segs[i]["scene_index"], h, how[i],
                            repr(ft[a:a + 26])))
            if i == 0 or starts[i] > starts[i - 1]:
                mono += 1
        n = len(heads)
        print(f"【{lb}】 세그먼트 {n}")
        print(f"    ★자기 헤딩이 자기 범위 안 : {ok}/{n} ({100*ok/n:.0f}%)")
        print(f"    시작 위치 단조증가        : {mono}/{n}")
        print(f"    앵커 방식 — 헤딩전체 {how.count('헤딩전체')} · "
              f"씬번호 {how.count('씬번호')} · 실패 {how.count('실패')}")
        for si, h, w, ln in bad[:8]:
            print(f"      ✗ idx{si:3} [{w}] {h[:34]!r} → 범위시작 {ln}")


if __name__ == "__main__":
    main()
