"""'마지막 임무' grok2 판 최종 리포트 + 과정 갤러리 — last1 빌더의 P/E fork.

산출(자립형, /Volumes/web/last2 배포용):
  artifact/20260818_last2_report/
    index.html    씬별 접기: 헤딩+대본 원문+샷별 최종 이미지·메타·캡션
    process.html  샷별 중간 과정(배경/도면/롤/수정/선정/변환)+판정 기록
    images/       최종 이미지 — 원본 PNG 그대로(_cine 우선, 없으면 _sel)
    procimg/      과정 이미지 — 원본 PNG 그대로

데이터: scene_still DB + scene_save CP(씬 원문) + recipe records.json.
이미지 변환은 build 후 별도 셸(sips 병렬)로 — 본 스크립트는 목록만 출력.

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

import html as H
import json
import re
import subprocess
import sys
from collections import Counter, defaultdict
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PROJ = "5bddbdfc-2681-42a6-9837-43f35f60049d"
EPI = "f5372927-bbec-405d-ad2c-100d587f5373"
RECIPE = ROOT / "projects" / PROJ / "images" / EPI / "scene" / "recipe"
CP_SAVE = (ROOT / "projects" / PROJ / "checkpoints" / "episodes" / EPI
           / "scene_save" / "manifest.json")
OUT_DIR = ROOT / "artifact" / "20260818_last2_report"

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

STYLE = """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif; background: #111; color: #e0e0e0; padding: 20px; }
h1 { text-align: center; padding: 30px 0; font-size: 28px; color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; }
.sub { text-align:center; color:#999; margin-bottom: 30px; font-size: 14px; }
.sub a { color:#8cf }
.scene { margin-bottom: 40px; border: 1px solid #333; border-radius: 8px; overflow: hidden; }
.scene h2 { background: #1a1a2e; padding: 14px 20px; font-size: 18px; color: #a0c4ff; user-select: none; }
.scene h2 .toggle-arrow { font-size: 12px; margin-left: 8px; transition: transform 0.2s; display: inline-block; }
.scene.open h2 .toggle-arrow { transform: rotate(90deg); }
.scene .scene-body { display: none; padding: 16px 20px; }
.scene.open .scene-body { display: block; }
.shot-count { color: #777; font-size: 14px; }
.para { background: #191919; border-left: 3px solid #444; padding: 12px 16px; margin-bottom: 18px; line-height: 1.7; font-size: 14px; color: #ccc; white-space: pre-wrap; }
.inline-shot { margin: 18px 0 26px; }
.img-wrap { position: relative; }
.img-wrap img { width: 100%; max-width: 960px; border-radius: 6px; cursor: zoom-in; display: block; }
.meta { margin-top: 6px; font-size: 12px; color: #8a8; }
.shot-caption { margin-top: 8px; font-size: 14px; color: #ddd; line-height: 1.6; max-width: 960px; }
.proc { font-size: 12px; margin-top: 4px; }
.proc a { color: #68a; text-decoration: none; }
.modal { display:none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 10; align-items: center; justify-content: center; flex-direction: column; }
.modal.active { display: flex; }
.modal img { max-width: 96vw; max-height: 88vh; }
.modal .m-meta { color:#aaa; font-size: 13px; margin-top: 10px; }
"""

MODAL = """
<div class="modal" id="modal" onclick="closeModal()">
  <img id="modal-img" src="">
  <div class="m-meta" id="modal-meta"></div>
</div>
<script>
function openModal(el) {
  const modal = document.getElementById('modal');
  document.getElementById('modal-img').src = el.src;
  const metaEl = el.parentElement.querySelector('.meta');
  document.getElementById('modal-meta').innerHTML = metaEl ? metaEl.innerHTML : '';
  modal.classList.add('active');
  document.body.style.overflow = 'hidden';
}
function closeModal() {
  document.getElementById('modal').classList.remove('active');
  document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
</script>
"""


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


def q(sql: str) -> list[list[str]]:
    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-F", "\x1f", "-c", sql],
        env={"PGPASSWORD": "theroad_dev_2026",
             "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"},
        capture_output=True, text=True, check=True)
    return [line.split("\x1f") for line in out.stdout.splitlines() if line]


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


def scan_files() -> dict[str, dict[str, str]]:
    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
    return files


# 검열 거부 판별 — 프로덕션과 같은 함수를 쓴다(글자 목록을 따로 두면 갈린다).
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app.modules.llm.image_moderation import is_moderation_text  # noqa: E402


def build_index(shots, segs, sel_of, files, manifest) -> int:
    body = []
    total = 0
    for si in sorted(shots):
        seg = segs.get(si) or {}
        heading = (seg.get("heading") or f"S#{si}.").strip()
        title = re.sub(r"^S#\d+\.\s*", "", heading)
        text = (seg.get("text") or "").strip()
        cells = []
        for shi, beat, desc, var in shots[si]:
            tag = f"S{si}sh{shi}"
            f = files.get(tag) or {}
            src = f.get("cine") or f.get("sel")
            if not src:
                continue
            total += 1
            manifest.append((RECIPE / src, f"images/{tag}_sel.png"))
            sel = var or sel_of.get(tag) or ""
            sel_txt = f" · 선정 롤 {esc(sel)}" if sel else ""
            cine_txt = " · 시네마틱 변환본" if f.get("cine") else " · 원본(변환 미적용)"
            cells.append(
                f"<div class='inline-shot' id='{tag}'>"
                f"<div class='img-wrap'>"
                f"<img loading='lazy' src='images/{tag}_sel.png' onclick='openModal(this)'>"
                f"<div class='meta'><span>{tag}{sel_txt}{cine_txt}</span><br>"
                f"<span>Beat: {esc(beat)}</span></div></div>"
                f"<div class='shot-caption'>{esc(desc)}</div>"
                f"<div class='proc'><a href='process.html#{tag}'>제작 과정 보기 →</a></div>"
                f"</div>")
        if not cells:
            continue
        body.append(
            "<section class='scene open'>"
            "<h2 onclick=\"this.parentElement.classList.toggle('open')\""
            " style='cursor:pointer'>"
            f"씬 {si}: {esc(title)} <span class='shot-count'>({len(cells)}샷)</span>"
            " <span class='toggle-arrow'>&#9654;</span></h2>"
            f"<div class='scene-body'><div class='para'>{esc(text)}</div>"
            f"{''.join(cells)}</div></section>")

    html = (
        "<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n"
        "<meta charset=\"UTF-8\">\n"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
        "<title>마지막 임무</title>\n"
        f"<style>{STYLE}</style>\n</head>\n<body>\n"
        "<h1>마지막 임무</h1>"
        f"<div class='sub'>{len(body)}씬 · 최종 스틸 {total}샷"
        " · <a href='process.html'>전체 제작 과정(중간 이미지·판정 기록) 보기</a></div>"
        + "\n".join(body) + MODAL + "\n</body>\n</html>")
    (OUT_DIR / "index.html").write_text(html, "utf-8")
    return total


def build_process(files, records, manifest) -> None:
    main_recs = {k: v for k, v in records.items()
                 if "::" not in k and isinstance(v, dict)}
    cine = {k.split("::")[0]: v for k, v in records.items()
            if k.endswith("::cine") and isinstance(v, dict)}
    tags = sorted(set(main_recs) | set(files), key=tag_key)

    routes = Counter()
    n_fix = n_cine = n_cine_fail = n_cine_declined = 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 = r.get("gq") or {}
        if not gq:
            jf = r.get("judge_flip") or {}
            gq = (jf.get("forward_raw") or {}).get("gq") or {}
        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
        cn = cine.get(tag) or {}
        cine_ok = bool(cn.get("applied")) and bool(f.get("cine"))
        # 2026-08-20: 포기(declined)는 **닫힌 결말**이지 실패가 아니다 —
        # 검열 거부가 기준만큼 쌓여 변환을 그만두고 원본을 최종본으로
        # 확정한 상태다. 실패로 그리면 아직 고칠 것이 남은 판으로 읽힌다.
        # 포기 표식이 붙기 시작한 것은 2026-08-20 이다. 그 전에 버려진 샷은
        # applied=false + 검열 거부 원문만 남아 있어, 표식만 보면 「아직 고칠
        # 것이 남았다」로 읽힌다. 옛 모양도 사실대로 읽는다 — 판별은 프로덕션이
        # 쓰는 것과 **같은 공용 함수**다(따로 적으면 둘이 갈린다).
        cine_declined = bool(cn.get("declined")) or (
            bool(cn) and not cn.get("applied")
            # 포기 셈이 들어오기 전(2026-08-20 이전)에 버려진 것만 이 갈래다.
            # 그 뒤 기록에는 셈(moderation_refusals)이 있고, 셈이 있는데
            # declined 가 없으면 **아직 기준에 못 미쳐 다시 시도할 샷**이다
            # — 그것을 포기로 그리면 결말이 아닌 것을 결말로 읽는다.
            and "moderation_refusals" not in cn
            and is_moderation_text(str(cn.get("error") or "")))
        cine_fail = bool(cn) and not cn.get("applied") and not cine_declined
        if cine_ok:
            n_cine += 1
        if cine_fail:
            n_cine_fail += 1
        if cine_declined:
            n_cine_declined += 1

        badges = [f"<span class='b prod'>선정 {esc(sel)}</span>"]
        if route:
            badges.append(f"<span class='b gq'>판정 {esc(route)}</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 cine_declined:
            badges.append(
                "<span class='b cinex'>변환 포기 — 원본 확정"
                "(검열 거부 누적)</span>")

        figs = []

        def fig(kind: str, label: str, hl: bool = False):
            name = f.get(kind)
            if not name:
                return
            jpg = f"procimg/{tag}_{kind}.png"
            manifest.append((RECIPE / name, jpg))
            style = " style='outline:3px solid #2a7'" if hl else ""
            figs.append(
                f"<figure><img loading='lazy' src='{jpg}'{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", "선정")
        fig("cine", "최종 — 시네마틱 변환", hl=cine_ok)

        det = []
        if gq:
            det.append(
                f"<div class='jd'>판정: route=<b>{esc(route)}</b> · "
                f"모델별 승자 {esc(gq.get('per_model_winner'))} · "
                f"totals {esc(r.get('totals'))}</div>")
        crit = r.get("critique") or {}
        issues = crit.get("issues") or []
        if issues:
            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)
            det.append(f"<div class='fix'><ul>{li}</ul></div>")
        if r.get("repair_mode"):
            det.append(
                f"<div class='fix'>수정: mode={esc(r.get('repair_mode'))}</div>")
        if cn:
            det.append(
                f"<div class='jd'>변환: applied={esc(cn.get('applied'))} · "
                f"{esc(cn.get('model'))}"
                f"{' · ' + esc(cn.get('error')) if cn.get('error') else ''}</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='row' 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>마지막 임무 — 전 과정 갤러리</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}.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"
        " 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>마지막 임무 — 전 과정 갤러리 ({len(tags)}샷)</h1>"
        f"<p>판정 route: {dict(routes)} · 수정 {n_fix}샷 · 시네마틱 변환 {n_cine}"
        + (f" · 변환 미적용 {n_cine_fail}" if n_cine_fail else "")
        + (f" · 변환 포기(원본 확정) {n_cine_declined}"
           if n_cine_declined else "")
        + " · <a href='index.html'>최종 리포트로 →</a></p>\n")
    (OUT_DIR / "process.html").write_text(head + summary + "\n".join(rows),
                                          "utf-8")


def main() -> None:
    rows = q(
        "SELECT scene_index, shot_index, coalesce(beat_title,''),"
        " coalesce(shot_description,''), coalesce(selected_variant,'')"
        f" FROM scene_still WHERE episode_id='{EPI}' AND is_selected=true"
        " ORDER BY scene_index, shot_index;")
    shots = defaultdict(list)
    for si, shi, beat, desc, var in rows:
        shots[int(si)].append((int(shi), beat, desc, var))

    segs = {s["scene_index"]: s for s in
            (json.loads(CP_SAVE.read_text("utf-8")).get("data") or {})
            .get("segments", [])}
    records = json.loads((RECIPE / "records.json").read_text("utf-8"))
    sel_of = {k: v.get("selected") for k, v in records.items()
              if "::" not in k and isinstance(v, dict)}
    files = scan_files()

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    (OUT_DIR / "images").mkdir(exist_ok=True)
    (OUT_DIR / "procimg").mkdir(exist_ok=True)

    manifest: list[tuple[Path, str]] = []
    total = build_index(shots, segs, sel_of, files, manifest)
    build_process(files, records, manifest)

    # 이미지 복사 목록 — 원본 PNG 그대로(압축 없음)
    lst = OUT_DIR / "_convert_list.tsv"
    lst.write_text(
        "\n".join(f"{src}\t{OUT_DIR / rel}" for src, rel in manifest),
        "utf-8")
    print(f"완료: index {total}샷 · 복사 대상 {len(manifest)}장 → {lst}")


if __name__ == "__main__":
    main()
