#!/usr/bin/env python3
"""offline rejudge 갤러리 — **같은 그림**을 세 판정자가 어떻게 봤나.

## 왜 이 판이 따로 필요한가

새 선정 쌍(Gemini+GPT Sol)을 넣은 주행에서는 **롤(후보 a/b)까지 다시
구워진다** — 선정 쌍이 샷 지문에 접히기 때문이다. 그래서 그 주행의 그림
차이를 판정자 품질로 귀속하면 안 된다(Codex 조건). 판정자만 가르려면
**같은 저장 후보**를 다른 판정자로 다시 물어야 한다. 이 갤러리가 그
결과다.

  · 후보 A/B = 주행 **전**에 백업해 둔 그 파일 그대로(SHA 대조 완료)
  · 세 판정자 = Gemini 3.1 Pro · GPT-5.6 Sol · Grok 4.6, 창구는
    OpenRouter 하나로 통일(전송 조건을 같게)
  · 옛 프로덕션 판정(Gemini 정순 + Grok 역순)도 함께 놓는다

★기록에서만 읽는다 — 유료 호출 0.

usage: build_offline_rejudge_gallery.py <episode_id> [결과디렉토리] [백업recipe]
"""
from __future__ import annotations

import html
import json
import pathlib
import re
import subprocess
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import _db  # noqa: E402
import _refs  # noqa: E402

ROOT = pathlib.Path(__file__).resolve().parents[3]
SHORT = {"google/gemini-3.1-pro-preview": "Gemini 3.1 Pro",
         "openai/gpt-5.6-sol": "GPT-5.6 Sol",
         "x-ai/grok-4.6": "Grok 4.6"}
ORDER = ["Gemini 3.1 Pro", "GPT-5.6 Sol", "Grok 4.6"]
NOW_PAIR = {"Gemini 3.1 Pro", "GPT-5.6 Sol"}     # 병합 후 도는 쌍


def _secs(r) -> str:
    """★`duration_s` 는 **마지막 시도**만이다 — 누적 provider 시간을 쓴다."""
    pc = r.get("physical_calls")
    ps = r.get("provider_s", r.get("wall_s"))
    v = ps if ps is not None else r.get("duration_s")
    if v is None:                 # 짝이 없는 칸 — 0초로 꾸미지 않는다
        return "—"
    return f"{v:.1f}초" + (f" ×{pc}콜" if (pc or 1) > 1 else "")


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


def _shot_texts(episode_id: str) -> dict:
    out = {}
    for si, shi, txt in _db.rows(
            "SELECT scene_index, shot_index, "
            "replace(coalesce(shot_description,''), E'\\n',' ') "
            "FROM scene_still WHERE episode_id = :ep", {"ep": episode_id}):
        out[f"S{si}sh{shi}"] = txt
    return out


def main() -> int:
    argv = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not argv:
        print(__doc__)
        return 2
    episode_id = argv[0]
    base = pathlib.Path(argv[1]) if len(argv) > 1 else (
        ROOT / "artifact" / "20260829_offline_rejudge")
    bk = pathlib.Path(argv[2]) if len(argv) > 2 else (
        ROOT / "artifact" / "20260829_prejit_baseline"
        / "images" / "scene" / "recipe")

    rows = []
    for d in sorted(base.iterdir()) if base.is_dir() else []:
        c = d / "calls.json"
        if c.is_file():
            rows.extend(json.loads(c.read_text()))
    if not rows:
        print(f"★calls.json 을 못 찾았다: {base} — 「없다」로 읽지 마라")
        return 1
    old = json.loads((bk / "records.json").read_text())
    texts = _shot_texts(episode_id)
    tags = sorted({r["tag"] for r in rows})

    P: list[str] = ['<meta charset="utf-8">',
                    "<title>같은 그림, 세 판정자</title>", """<style>
:root{color-scheme:dark}
body{background:#111;color:#ddd;margin:0;padding:28px 32px;max-width:1600px;
 font:15px/1.65 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}
h1{font-size:25px;margin:0 0 6px} .n{color:#8a8a8a;font-size:13px}
h2{font-size:19px;margin:30px 0 8px;border-bottom:1px solid #333;
 padding-bottom:6px}
.shot{border:1px solid #2a2a2a;border-radius:9px;padding:16px;margin:20px 0;
 background:#161616}
.txt{color:#e8e8e8;margin:6px 0 10px;padding:10px 12px;background:#1c1c1c;
 border-left:3px solid #4a7;border-radius:4px}
.txt code{color:#ffd88a;font-size:13px}
table{border-collapse:collapse;margin:10px 0;font-size:14px;width:100%}
td,th{border:1px solid #333;padding:7px 10px;text-align:left;
 vertical-align:top}
th{background:#1b1b1b} .win{color:#6fcf6f;font-weight:700}
.hv{color:#ff8f6f} .zero{color:#666}
.live{display:inline-block;font-size:11px;padding:1px 7px;border-radius:99px;
 border:1px solid #2c5;color:#6fcf6f;margin-left:6px}
.imgs{display:flex;gap:14px;flex-wrap:wrap;margin-top:12px}
.imgs figure{margin:0;flex:1 1 420px;max-width:640px}
.imgs img{width:100%;border-radius:6px;border:2px solid #333;display:block}
.imgs .picked img{border-color:#6fcf6f}
.imgs figcaption{font-size:13px;color:#999;margin-top:5px}
.imgs .picked figcaption{color:#6fcf6f;font-weight:700}
.warn{background:#2a1c14;border-left:3px solid #b86;padding:11px 13px;
 border-radius:4px;margin:14px 0}
</style>"""]
    P.append("<h1>같은 그림을 세 판정자에게 다시 물었다</h1>")
    P.append(f'<p class="n">에피소드 {esc(episode_id)} · 샷 {len(tags)}개 × '
             f'모델 3 = 판정 {len(rows)}건 · 후보는 주행 <b>전</b> 백업본 '
             "그대로(SHA 대조 완료) · 창구는 OpenRouter 하나로 통일</p>")
    # ★참조가 붙었는지 — 안 붙었으면 「판정자만 갈랐다」고 쓰면 안 된다
    #  (2026-08-29 Codex BLOCK-1). 프로덕션 judge_fn 은 샷마다 참조 2~3장을
    #  붙인다. 안 붙이고 잰 것을 옛 프로덕션과 나란히 놓으면 **판정자와
    #  참조 유무가 함께 바뀐 대조**다.
    # ★판정은 `_refs.ref_mode` **한 군데**에만 있다 — 0 과 양수가 섞이면
    #  멈추고, 양수끼리 다른 것(샷마다 인물 수가 달라 2~3장)은 통과시킨다.
    no_ref, ref_show = _refs.ref_mode(rows)
    P.append('<div class="warn">'
             "<b>왜 이렇게 쟀나.</b> 새 선정 쌍을 넣은 주행에서는 "
             "<b>롤(후보 a/b)까지 다시 구워진다</b> — 선정 쌍이 샷 지문에 "
             "접히기 때문이다. 그 주행의 그림 차이를 판정자 품질로 귀속하면 "
             "안 된다. 그래서 <b>같은 저장 후보</b>를 다시 물었다.<br>"
             "★샷 " + str(len(tags)) + "개 · 모델당 한 판이다. "
             "<b>개수 그대로</b> 읽고 백분율로 쓰지 않는다."
             "</div>")
    if no_ref:
        P.append('<div class="warn">'
                 "★★<b>이 판은 참조 이미지를 안 붙였다.</b> 프로덕션 "
                 "<code>judge_fn</code> 은 샷마다 참조를 <b>2~3장</b> 붙인다"
                 "(location_plate / bgfirst_group_bg / prev_still + "
                 "character_ref). 그래서 아래 결과를 옛 프로덕션 판정과 "
                 "<b>직접 견주면 안 된다</b> — 판정자와 참조 유무가 "
                 "<b>함께</b> 바뀐 대조다. 여기서 읽을 수 있는 것은 "
                 "<b>「참조 없이 텍스트만으로 무엇을 잡는가」</b>뿐이다."
                 "</div>")
    else:
        P.append('<p class="n">참조 <b>' + esc(ref_show) + "장</b>을 "
                 "프로덕션과 같은 자리·같은 순서로 붙였다. 샷마다 인물 수가 "
                 "달라 장수가 갈린다.</p>")

    # ── 총계 ────────────────────────────────────────────────────────
    tot, filed, ok = {}, {}, {}
    for r in rows:
        m = SHORT.get(r["model"], r["model"])
        if not r.get("winner"):
            continue
        ok[m] = ok.get(m, 0) + 1
        n = sum(len(v) for v in (r.get("hard_by_label") or {}).values())
        tot[m] = tot.get(m, 0) + n
        if n:
            filed[m] = filed.get(m, 0) + 1
    P.append("<h2>같은 그림에서 누가 무엇을 올렸나</h2>")
    P.append("<table><tr><th>모델</th><th>성공</th><th>하드위반 총계</th>"
             "<th>낸 샷</th></tr>")
    for m in ORDER:
        if m not in ok:
            continue
        badge = ('<span class="live">지금 도는 쌍</span>'
                 if m in NOW_PAIR else "")
        P.append(f'<tr><td>{esc(m)}{badge}</td><td>{ok[m]}</td>'
                 f'<td class="{"zero" if not tot.get(m) else "hv"}">'
                 f'{tot.get(m, 0)}건</td>'
                 f"<td>{filed.get(m, 0)}/{ok[m]}</td></tr>")
    P.append("</table>")

    # ── effort 를 올려 본 판 (있으면) ───────────────────────────────
    hi = base.parent / (base.name + "_solhigh")
    hirows = []
    for d in sorted(hi.iterdir()) if hi.is_dir() else []:
        c = d / "calls.json"
        if c.is_file():
            hirows.extend(json.loads(c.read_text()))
    if hirows:
        P.append("<h2>★같은 effort 가 아니다 — Sol 을 올려 봤다</h2>")
        P.append('<div class="warn">'
                 "위 표는 <b>reasoning 파라미터를 안 보낸</b> 판이다 — 각 "
                 "모델 기본값이고, <b>프로덕션이 실제로 도는 조건</b>이기도 "
                 "하다(`llm_client.py` 의 <code>gpt</code> Router 등록에 "
                 "effort 설정이 없다). 그런데 Grok 은 OpenRouter 기본이 "
                 "<code>high</code> 라, 추론 토큰이 Sol 636~1,909 대 Grok "
                 "2,677~6,499 로 갈렸다. 그래서 <b>Sol 이 적게 낸 것이 모델 "
                 "차이인지 덜 생각한 탓인지</b> 위 표로는 못 가른다.<br>"
                 "아래는 Sol 만 <code>effort=high</code> 로 올려 다시 물은 "
                 "것이다."
                 "</div>")
        P.append("<table><tr><th>샷</th><th>기본</th><th>high</th>"
                 "<th>바뀌었나</th></tr>")
        for r in sorted(hirows, key=lambda x: x["tag"]):
            base_r = next((x for x in rows if x["tag"] == r["tag"]
                           and x["model"] == r["model"]), None)
            b_n = sum(len(v) for v in
                      ((base_r or {}).get("hard_by_label") or {}).values())
            h_n = sum(len(v) for v in (r.get("hard_by_label") or {}).values())
            # ★두 칸 다 `_secs()` 로 — `duration_s` 를 바로 쓰면 재시도
            #  첫 시도 시간과 물리 콜 수가 사라진다 (2026-08-29 Codex 재리뷰:
            #  BLOCK-4 를 본표에서만 고치고 이 보조표에 남겨 뒀다).
            P.append(
                f'<tr><td>{esc(r["tag"])}</td>'
                f'<td>{b_n}건 · {esc(_secs(base_r or {}))} · 추론 '
                f'{esc(((base_r or {}).get("usage") or {}).get("reasoning_tokens"))}</td>'
                f'<td>{h_n}건 · {esc(_secs(r))} · 추론 '
                f'{esc((r.get("usage") or {}).get("reasoning_tokens"))}</td>'
                f'<td>{"★바뀜" if b_n != h_n else "그대로"}</td></tr>')
        P.append("</table>")
        P.append('<p class="n">★표본 두 개다. 「올리면 더 잡는다」로 '
                 "일반화하지 않는다.</p>")

    if no_ref:
        P.append('<p class="n">★참조를 안 붙였으므로 <b>옛 프로덕션 선정과의'
                 " 대조표는 싣지 않는다</b> — 같은 물음이 아니다.</p>")

    # ── 샷별 ────────────────────────────────────────────────────────
    P.append("<h2>샷별</h2>")
    for t in tags:
        o = old.get(t) or {}
        cm = o.get("cross_model_order") or {}
        sel = str(o.get("selected") or "")
        prompt = ((o.get("roll_prompts") or {}).get("A") or "")
        keys = [ln.strip() for ln in prompt.splitlines()
                if re.search(r"FRAMING SCALE|one hanging fluorescent|"
                             r"A single fluorescent|upside down|"
                             r"LOCATION \(lock\)", ln)]
        P.append('<div class="shot">')
        head = (f'<span class="n"> · 옛 프로덕션 선정 <b>{esc(sel)}</b> '
                f'({esc(cm.get("route"))}, 그때 둘째 슬롯은 Grok)</span>'
                if not no_ref else
                '<span class="n"> · 참조 없이 잰 판 — 옛 프로덕션과 '
                '직접 견주지 않는다</span>')
        P.append(f'<h3 style="margin:0">{esc(t)}{head}</h3>')
        if texts.get(t):
            P.append(f'<div class="txt"><b>샷 텍스트</b><br>'
                     f"{esc(texts[t])}</div>")
        if keys:
            P.append('<div class="txt"><b>프롬프트가 못박은 것</b><br>'
                     + "<br>".join(f"<code>{esc(k[:280])}</code>"
                                   for k in keys) + "</div>")
        P.append("<table><tr><th>판정자</th><th>고른 것</th><th>점수 A / B</th>"
                 "<th>하드위반</th><th>시간</th></tr>")
        for m in ORDER:
            r = next((x for x in rows
                      if x["tag"] == t and SHORT.get(x["model"]) == m), None)
            if r is None:
                continue
            if not r.get("winner"):
                P.append(f'<tr><td>{esc(m)}</td><td colspan="4">✗ 실패 '
                         f'{esc(str(r.get("error"))[:80])}</td></tr>')
                continue
            s = r.get("scores") or {}
            hv = []
            for lab, lst in (r.get("hard_by_label") or {}).items():
                for x in lst:
                    hv.append(f"<b>후보 {esc(lab)}</b> — {esc(x)}")
            badge = ('<span class="live">지금 도는 쌍</span>'
                     if m in NOW_PAIR else "")
            P.append(
                f'<tr><td>{esc(m)}{badge}</td>'
                f'<td class="win">{esc(r["winner"])}</td>'
                f'<td>{esc(s.get("A"))} / {esc(s.get("B"))}</td>'
                + (f'<td class="hv">{"<br>".join(hv)}</td>' if hv
                   else '<td class="zero">한 건도 안 냄</td>')
                + f'<td>{esc(_secs(r))}</td></tr>')
        P.append("</table>")
        P.append('<div class="imgs">')
        for lab in ("A", "B"):
            f = bk / f"{t}_{lab.lower()}.png"
            if not f.is_file():
                continue
            rel = f.resolve().relative_to(ROOT)
            pick = " picked" if lab == sel else ""
            P.append(f'<figure class="{pick.strip()}">'
                     f'<a href="/{esc(rel)}" target="_blank">'
                     f'<img src="/{esc(rel)}" loading="lazy"></a>'
                     f"<figcaption>후보 {lab}"
                     + (" ← 옛 프로덕션 선정" if pick else "")
                     + "</figcaption></figure>")
        P.append("</div></div>")

    outdir = base
    (outdir / "index.html").write_text("\n".join(P), encoding="utf-8")
    print(f"만듦: {outdir/'index.html'} · 샷 {len(tags)}개 · 판정 {len(rows)}건")
    # ★파일을 다 쓴 **뒤**의 주소 안내다. 저장소 밖에 만들면 8940 으로
    #  못 여는데, 종전에는 여기서 `relative_to` 가 던져 **성공한 실행이
    #  0 아닌 코드로 끝났다** — 「갤러리가 실패했다」로 읽힌다.
    try:
        rel = (outdir / "index.html").resolve().relative_to(ROOT)
    except ValueError:
        print("주소: (저장소 밖이라 8940 으로는 못 연다 — 위 경로로 직접 열어라)")
        return 0
    ip = subprocess.run(["ipconfig", "getifaddr", "en0"],
                        capture_output=True, text=True).stdout.strip()
    print(f"주소: http://{ip}:8940/{rel}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
