"""'사랑했지만' 최종 215샷 제출 갤러리 (#91).

금월도 20260809_final_eval_gallery 형식을 따르되, 재평가 호출 없이
프로덕션 기록(records.json)만 읽는다 — 이번 run 은 G+Q 이중 판정이
프로덕션에서 전건 돌았으므로 그 기록을 그대로 보인다. 판정/생성
호출 0 = 비용 0.

각 샷 row:
  · 중간 과정 이미지 전부 — 배경(bgfirst), confined 도면, 롤 a/b/c,
    수정본(fix), 최종 선정(sel). 선정 롤은 테두리 강조.
  · 메타 — G+Q route(gap·모델별 승자), 점수(totals), critique
    (issues + qwen 관찰), confined 판별(발동/제외 사유·readback),
    수정(repair_mode), prompt 접기.
  · 필터 — confined 발동/제외, fix 수정, gq 경로별, 재생성(S25sh1).

사용:
    .venv/bin/python build_saranghae_final_gallery.py
"""
from __future__ import annotations

import html as H
import json
import re
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PROJ = "c7e3b2e7-c545-4516-93b2-62a51a74d794"
EPI = "7c902020-4451-4967-9eb6-1e53c2b9b717"
RECIPE = ROOT / "projects" / PROJ / "images" / EPI / "scene" / "recipe"
WEB = f"/projects/{PROJ}/images/{EPI}/scene/recipe"
OUT_DIR = ROOT / "artifact" / "20260812_사랑했지만_최종215_갤러리"
REGEN = {"S25sh1"}  # 8/12 partial 회수로 재생성된 샷

ROLL_ORDER = ["a", "b", "c", "d", "e"]


def tag_key(tag: str):
    s, sh = tag[1:].split("sh")
    return (int(s), int(sh))


def esc(x) -> str:
    return H.escape(str(x if x is not None else ""))


def main() -> None:
    records = json.loads((RECIPE / "records.json").read_text("utf-8"))
    main_recs = {k: v for k, v in records.items()
                 if "::" not in k and isinstance(v, dict)}
    apt = {k.split("::")[0]: v for k, v in records.items()
           if k.endswith("::confined_fp_apt")}
    # #108: i2i 시네마틱 변환 기록 — 최종 자산은 변환본(applied 시)
    cine = {k.split("::")[0]: v for k, v in records.items()
            if k.endswith("::cine") and isinstance(v, dict)}

    files: dict[str, dict[str, str]] = {}
    for p in sorted(RECIPE.iterdir()):
        m = re.match(r"^(S\d+sh\d+)(__bgfirst_bg|_confinedfp|_fix|_sel|_cine|_([a-e]))\.png$", p.name)
        if not m:
            continue
        kind = m.group(3) or m.group(2).lstrip("_")
        files.setdefault(m.group(1), {})[kind] = p.name

    tags = sorted(set(main_recs) | set(files), key=tag_key)

    routes = Counter()
    n_fix = n_cf_on = n_cf_off = 0
    n_cine = n_cine_fail = 0
    rows: list[str] = []
    cur_scene = None
    for tag in tags:
        r = main_recs.get(tag) or {}
        f = files.get(tag) or {}
        si = tag_key(tag)[0]
        if si != cur_scene:
            cur_scene = si
            rows.append(f"<h2 id='S{si}'>S{si}</h2>")

        # gq 기록 위치 두 갈래 — 단판 샷은 본체, 정·역 교차 샷은 judge_flip
        gq = r.get("gq") or {}
        flip = ""
        if not gq:
            jf = r.get("judge_flip") or {}
            gq = (jf.get("forward_raw") or {}).get("gq") or {}
            if gq:
                flip = " 정역"
        route = gq.get("route") or ""
        if route:
            routes[route] += 1
        sel = r.get("selected") or "?"
        fixed = bool(r.get("repair_mode") or f.get("fix"))
        if fixed:
            n_fix += 1
        cf = r.get("confined_fp")
        cf_apt = apt.get(tag) or {}
        cf_on = bool(cf) or bool(f.get("confinedfp"))
        cf_off = (not cf_on) and cf_apt.get("applies") is False
        if cf_on:
            n_cf_on += 1
        if cf_off:
            n_cf_off += 1
        cn = cine.get(tag) or {}
        cine_ok = bool(cn.get("applied")) and bool(f.get("cine"))
        cine_fail = bool(cn) and not cn.get("applied")
        if cine_ok:
            n_cine += 1
        if cine_fail:
            n_cine_fail += 1

        cls = ["row"]
        if cf_on:
            cls.append("cfon")
        if cf_off:
            cls.append("cfoff")
        if fixed:
            cls.append("fixed")
        if route:
            cls.append(f"rt_{route}")
        if tag in REGEN:
            cls.append("regen")

        badges = [f"<span class='b prod'>선정 {esc(sel)}</span>"]
        if route:
            badges.append(
                f"<span class='b gq'>G+Q {esc(route)}{flip}"
                f"{' gap ' + esc(gq.get('gap')) if gq.get('gap') else ''}</span>")
        if cf_on:
            badges.append("<span class='b cf'>confined 발동</span>")
        if cf_off:
            badges.append("<span class='b cfx'>confined 제외</span>")
        if fixed:
            badges.append("<span class='b fx'>수정</span>")
        if cine_ok:
            badges.append("<span class='b cine'>시네마틱 변환</span>")
        if cine_fail:
            badges.append(
                "<span class='b cinex'>변환 실패(원본 fallback)</span>")
        if tag in REGEN:
            badges.append("<span class='t rg'>8/12 재생성</span>")

        figs = []

        def fig(kind: str, label: str, hl: bool = False):
            name = f.get(kind)
            if not name:
                return
            style = " style='outline:3px solid #2a7'" if hl else ""
            figs.append(
                f"<figure><img loading='lazy' src='{WEB}/{name}'{style}>"
                f"<figcaption>{esc(label)}</figcaption></figure>")

        fig("bgfirst_bg", "배경(bgfirst)")
        fig("confinedfp", "confined 도면")
        for roll in ROLL_ORDER:
            fig(roll, f"롤 {roll.upper()}", hl=(sel == roll.upper()))
        fig("fix", "수정본")
        fig("sel", "nb2 선정")
        # #108: 변환 적용 시 최종 자산 = _cine (원본 _sel 은 체인 앵커)
        fig("cine", "최종 — 시네마틱 변환(grok i2i)", hl=cine_ok)

        det = []
        if gq:
            det.append(
                f"<div class='jd'>G+Q: route=<b>{esc(route)}</b> · "
                f"모델별 승자 {esc(gq.get('per_model_winner'))} · "
                f"totals {esc(r.get('totals'))} · 순위 {esc(r.get('ranking'))}</div>")
        crit = r.get("critique") or {}
        issues = crit.get("issues") or []
        qobs = crit.get("qwen_observations") or []
        if issues or qobs:
            li = "".join(
                f"<li>[검사] {esc(i.get('issue_ko') or i)}"
                f" <i>({esc(i.get('severity'))})</i></li>"
                if isinstance(i, dict) else f"<li>[검사] {esc(i)}</li>"
                for i in issues)
            li += "".join(
                f"<li>[qwen] {esc(o.get('issue_ko'))}"
                f" <i>({esc(o.get('severity'))})</i></li>" for o in qobs)
            det.append(f"<div class='fix'><ul>{li}</ul></div>")
        if cf_on and isinstance(cf, dict):
            det.append(
                f"<div class='jd'>confined: {esc(cf.get('apt_reason'))} · "
                f"어긋남 {esc(cf.get('mismatches'))}</div>")
        if cf_off:
            det.append(
                f"<div class='jd'>confined 제외 사유: "
                f"{esc(cf_apt.get('reason_ko'))}</div>")
        if r.get("repair_mode"):
            det.append(
                f"<div class='fix'>수정: mode={esc(r.get('repair_mode'))} · "
                f"fix_ref {esc(r.get('fix_ref_count'))}</div>")
        if cn:
            det.append(
                f"<div class='jd'>cine: applied={esc(cn.get('applied'))} · "
                f"팩 {esc(cn.get('pack'))} · {esc(cn.get('model'))}"
                f"{' · ' + esc(cn.get('error')) if cn.get('error') else ''}"
                f"</div>")
        if r.get("prompt"):
            det.append(
                f"<details><summary>prompt</summary><pre>{esc(r.get('prompt'))}"
                f"</pre></details>")

        rows.append(
            f"<section class='{' '.join(cls)}' id='{tag}'>"
            f"<h3>{tag} {' '.join(badges)}</h3>"
            f"<div class='imgs'>{''.join(figs)}</div>"
            f"{''.join(det)}</section>")

    head = (
        "<meta charset=\"utf-8\">\n"
        "<title>사랑했지만 1화 최종 215샷 — 전 과정 갤러리 (2026-08-12)</title>\n"
        "<style>\n"
        " body{font-family:sans-serif;margin:16px;background:#111;color:#ddd}\n"
        " .row{border-top:1px solid #333;padding:12px 0}\n"
        " .imgs{display:flex;gap:8px;overflow-x:auto}\n"
        " figure{margin:0;text-align:center}\n"
        " img{height:190px;border-radius:4px}\n"
        " figcaption{font-size:12px;color:#aaa}\n"
        " .b{padding:1px 6px;border-radius:3px;font-size:11px;color:#fff}\n"
        " .prod{background:#2a7}.gq{background:#36c}.cf{background:#a60}\n"
        " .cfx{background:#555}.fx{background:#846}\n"
        " .cine{background:#c73}.cinex{background:#a33}\n"
        " .jd{margin:6px 0;padding:6px 10px;background:#1a1a1a;"
        "border-radius:6px;font-size:13px}\n"
        " .fix{margin:6px 0;padding:6px 10px;background:#20180d;"
        "border-radius:6px;font-size:13px}\n"
        " .t{font-size:11px;padding:1px 6px;border-radius:3px;margin-left:6px}\n"
        " .t.rg{background:#375;color:#fff}\n"
        " details{margin:3px 0 3px 8px} summary{cursor:pointer}\n"
        " pre{white-space:pre-wrap;font-size:12px;color:#bbb}\n"
        " h2{color:#8cf;margin:22px 0 4px} a{color:#8cf}\n"
        " ul{margin:2px 0;padding-left:18px}\n"
        "</style>\n")
    summary = (
        f"<h1>사랑했지만 1화 최종 {len(tags)}샷 — 중간 과정 포함 전수</h1>"
        f"<p>G+Q route: {dict(routes)} · confined 발동 {n_cf_on}"
        f"·제외 {n_cf_off} · 수정 시도 {n_fix}샷"
        f" · 시네마틱 변환 {n_cine}"
        + (f"·실패 {n_cine_fail}" if n_cine_fail else "")
        + f" · 재생성 {sorted(REGEN)}"
        f" · 시대 1982-1991 (전 샷 육안 점검용)</p>"
        "<p>필터: <a href='#' onclick=\"return flt('')\">전체</a>"
        " · <a href='#' onclick=\"return flt('cfon')\">confined 발동</a>"
        " · <a href='#' onclick=\"return flt('cfoff')\">confined 제외</a>"
        " · <a href='#' onclick=\"return flt('fixed')\">수정</a>"
        " · <a href='#' onclick=\"return flt('rt_combined')\">gq combined</a>"
        " · <a href='#' onclick=\"return flt('rt_gemini_priority')\">gq "
        "gemini_priority</a>"
        " · <a href='#' onclick=\"return flt('regen')\">재생성</a></p>\n"
        "<script>function flt(c){document.querySelectorAll('.row')"
        ".forEach(r=>{r.style.display=(!c||r.classList.contains(c))?'':'none';})"
        ";return false}</script>\n")

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    (OUT_DIR / "index.html").write_text(head + summary + "\n".join(rows),
                                        "utf-8")
    print(f"완료: {len(tags)}샷 · route {dict(routes)} · confined on/off "
          f"{n_cf_on}/{n_cf_off} · fix {n_fix}")
    print(f"경로: {OUT_DIR / 'index.html'}")


if __name__ == "__main__":
    main()
