#!/usr/bin/env python3
"""심판을 더한 ②선정 판정 비교 갤러리 (2026-09-03).

`build_judge_ab_gallery.py` 와 같은 판을 그리되 심판을 **몇이든** 더한다.
프로덕션 두 슬롯은 기록에서만 읽고(유료 호출 0), 더한 심판은
`muse_judge_pilot.py` 가 만든 results.json 에서 읽는다.

★결과 키는 **모델 → 샷 → 순서**다. 앞의 둘로만 키를 잡으면 다른 모델로
 같은 판을 다시 살 때 앞 모델 것을 조용히 덮는다(2026-09-03 실측 직전에
 잡았다). 그래서 이 갤러리도 모델 수를 미리 정하지 않는다.

## 이 갤러리가 답하는 것

  · 더한 심판이 프로덕션 선정과 **같은 것을 골랐나** (샷별·모델별)
  · 더한 심판이 **자리에 흔들리나** — 정순·역순 승자가 갈리는가
    (프로덕션은 모델과 순서가 묶여 있어 이걸 못 잰다)
  · 누가 **하드위반을 몇 건** 내는가 — 판정 수가 다르므로 판정당까지
    함께 본다. 0 인 심판은 「위반 합집합」에 아무것도 못 보탠다.

사용:
  build_muse_compare_gallery.py <project_id> <episode_id> [결과디렉토리]
"""
from __future__ import annotations

import html
import json
import pathlib
import sys
from typing import Any, Dict, List, Optional, Tuple

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

ROOT = pathlib.Path(__file__).resolve().parents[3]
DEFAULT_RES = ROOT / "artifact" / "20260903_muse_judge_pilot"

#: 모델 id → 사람이 읽을 이름. ★기록의 `slots[].model` 이 SOT 다 —
#: 「둘째 슬롯은 Grok」 같은 **자리 기반** 이름표를 쓰지 않는다. 그렇게
#: 적었다가 판정자가 Sol 로 바뀐 주행을 Grok 이라 부른 갤러리가 있다.
#: 여기 없는 id 는 id 그대로 찍는다 — 모르는 것을 지어내지 않는다.
PRETTY = {
    "gemini-pro": "Gemini 3.1 Pro",
    "gpt": "GPT-5.6 Sol",
    "qwen-vlm": "Qwen 3.8 Max",
    "meta/muse-spark-1.3-contributor": "Muse Spark 1.3 Contributor",
    "meta/muse-spark-1.3": "Muse Spark 1.3",
    "google/gemini-3.8-flash": "Gemini 3.8 Flash",
}


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


def pretty(model: str) -> str:
    return PRETTY.get(model, model)


def _shot_texts(episode_id: str) -> Dict[str, str]:
    out: Dict[str, str] = {}
    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 _hv_of(normalized: Optional[Dict[str, Any]]) -> List[str]:
    """이 판정이 적은 하드위반 — 「후보라벨: 문장」 목록."""
    out: List[str] = []
    for rr in ((normalized or {}).get("readings") or []):
        for x in (rr.get("hard_violations") or []):
            out.append(f"{rr.get('label')}: {x}")
    return out


def _score_str(normalized: Optional[Dict[str, Any]]) -> str:
    return " · ".join(
        f"{esc(v.get('label'))} {esc(v.get('score'))}"
        for v in ((normalized or {}).get("verdicts") or []))


def _judge_row(who: str, order: str, winner: str, sel: str,
               normalized: Optional[Dict[str, Any]],
               is_new: bool = False) -> str:
    hv = _hv_of(normalized)
    hv_html = ("<br>".join(esc(x) for x in hv) if hv
               else '<span class="zero">없음</span>')
    cls = "win" if (winner and winner == sel) else "lose"
    tr = '<tr class="new">' if is_new else "<tr>"
    tag = f'{esc(who)}<span class="n"> (더한 심판)</span>' if is_new \
        else esc(who)
    return (f'{tr}<td>{tag}</td><td>{esc(order)}</td>'
            f'<td class="{cls}">{esc(winner)}</td>'
            f'<td>{_score_str(normalized)}</td><td>{hv_html}</td></tr>')


def _combine(fwd: Dict[str, Any], rev: Dict[str, Any],
             labels: List[str]) -> Tuple[str, Dict[str, Any]]:
    """정·역 결합 — **프로덕션 함수 그대로**(합의=그 승자, 불일치=점수
    합산 최대, 동점=labels 순). 규칙을 여기 다시 적지 않는다."""
    from app.modules.pipeline.multiroll_select import combine_flip_verdicts

    return combine_flip_verdicts(fwd, rev, labels, labels)


def _load_extra(res_path: pathlib.Path) -> Dict[str, Any]:
    """더한 심판들의 결과 — 파일이 옛 평평한 모양이면 옮겨 담아 읽는다."""
    from muse_judge_pilot import _load_results  # 같은 디렉토리

    return _load_results(res_path)


def main() -> int:
    argv = [a for a in sys.argv[1:] if not a.startswith("--")]
    if len(argv) < 2:
        print(__doc__)
        return 2
    project_id, episode_id = argv[0], argv[1]
    resdir = pathlib.Path(argv[2]) if len(argv) > 2 else DEFAULT_RES

    from app.core.config import settings

    rdir = (pathlib.Path(settings.projects_dir) / project_id / "images"
            / episode_id / "scene" / "recipe")
    recs = json.loads((rdir / "records.json").read_text("utf-8"))
    extra = _load_extra(resdir / "results.json")
    if not extra:
        print(f"★{resdir / 'results.json'} 가 없거나 비었다 — 먼저 "
              f"muse_judge_pilot.py 를 돌려라. 「판정이 없다」와 "
              f"「아직 안 물었다」는 다르다.")
        return 1
    models = sorted(extra)
    texts = _shot_texts(episode_id)

    covered = {t for m in models for t in extra[m]}
    rows = sorted((k, r) for k, r in recs.items()
                  if isinstance(r, dict) and r.get("cross_model_order")
                  and k in covered)
    if not rows:
        print("★내 조회로는 프로덕션 판정과 더한 판정이 **둘 다** 있는 "
              "샷을 못 찾았다 — 「없다」로 읽지 마라")
        return 1

    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:24px;margin:0 0 4px} .n{color:#8a8a8a;font-size:13px}
h2{font-size:19px;margin:34px 0 6px;border-bottom:1px solid #333;padding-bottom:6px}
.shot{border:1px solid #2a2a2a;border-radius:9px;padding:16px;margin:18px 0;background:#161616}
.txt{color:#e8e8e8;font-size:15px;margin:4px 0 12px;padding:10px 12px;
 background:#1c1c1c;border-left:3px solid #4a7;border-radius:4px}
table{border-collapse:collapse;margin:10px 0;font-size:14px}
td,th{border:1px solid #333;padding:6px 10px;text-align:left;vertical-align:top}
th{background:#1b1b1b}
.win{color:#6fcf6f;font-weight:700} .lose{color:#888}
.hv{color:#ff8f6f;font-weight:700} .zero{color:#666}
.new{background:#191d24}
.imgs{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;align-items:flex-start}
.imgs figure{margin:0;max-width:330px}
.imgs img{width:100%;border-radius:6px;border:2px solid #333;display:block}
.imgs .picked img{border-color:#6fcf6f}
.imgs figcaption{font-size:12px;color:#999;margin-top:5px}
.imgs .picked figcaption{color:#6fcf6f;font-weight:700}
.route{display:inline-block;font-size:12px;padding:2px 9px;border-radius:99px;
 border:1px solid #444;background:#1e1e1e;margin-left:8px}
.agree{color:#6fcf6f;border-color:#2c5} .comb{color:#e8c26a;border-color:#a83}
.bad{color:#ff8f6f}
</style>"""]

    hv_tot: Dict[str, int] = {}
    #: ★분모를 같이 센다. 프로덕션 두 슬롯은 샷당 **한 번씩**이고 더한
    #  심판은 정·역 **두 번씩**이라, 위반 수만 나란히 놓으면 두 배로 볼
    #  기회를 가진 쪽이 적게 냈다는 사실이 지워진다.
    call_tot: Dict[str, int] = {}
    stat: Dict[str, Dict[str, int]] = {
        m: {"match": 0, "counted": 0, "stable": 0, "stable_n": 0,
            "first": 0, "first_n": 0} for m in models}

    per_shot: List[Dict[str, Any]] = []
    for tag, r in rows:
        c = r["cross_model_order"]
        sel = str(r.get("selected") or "")
        entry: Dict[str, Any] = {"tag": tag, "rec": r, "sel": sel,
                                 "route": c["route"], "prod": [],
                                 "extra": {}, "combined": {}}
        for s in c["slots"]:
            if s.get("ok"):
                n = s.get("normalized") or {}
                hv_tot[s["model"]] = hv_tot.get(s["model"], 0) + len(_hv_of(n))
                call_tot[s["model"]] = call_tot.get(s["model"], 0) + 1
            entry["prod"].append(s)

        for mdl in models:
            slots = (extra[mdl].get(tag) or {})
            got: List[Dict[str, Any]] = []
            winners: Dict[str, str] = {}
            for order_name in ("forward", "reverse"):
                s = slots.get(order_name)
                if not s:
                    continue
                got.append(s)
                if s.get("ok"):
                    n = s.get("normalized") or {}
                    hv_tot[mdl] = hv_tot.get(mdl, 0) + len(_hv_of(n))
                    call_tot[mdl] = call_tot.get(mdl, 0) + 1
                    winners[order_name] = str(n.get("winner") or "")
                    # 먼저 보인 후보를 골랐나 — 정순은 display A=canonical A,
                    # 역순은 display A=canonical B 다.
                    shown_first = "A" if order_name == "forward" else "B"
                    stat[mdl]["first_n"] += 1
                    stat[mdl]["first"] += int(
                        winners[order_name] == shown_first)
            entry["extra"][mdl] = got
            if len(winners) == 2:
                stat[mdl]["stable_n"] += 1
                stat[mdl]["stable"] += int(
                    winners["forward"] == winners["reverse"])
                fwd = slots["forward"]["normalized"]
                rev = slots["reverse"]["normalized"]
                labels = sorted(fwd["ranking"])
                try:
                    csel, comb = _combine(fwd, rev, labels)
                    entry["combined"][mdl] = (csel, comb)
                    stat[mdl]["counted"] += 1
                    stat[mdl]["match"] += int(csel == sel)
                except Exception as exc:  # noqa: BLE001
                    entry["combined"][mdl] = (f"결합 실패: {exc!r}"[:120], None)
        per_shot.append(entry)

    P.append("<h1>②선정 판정 — <b>심판을 더해서</b></h1>")
    P.append(
        f'<p class="n">에피소드 {esc(episode_id)} · 샷 {len(rows)}개 · '
        f'더한 심판 {len(models)}종 · 프로덕션 두 슬롯은 <b>기록에서만</b> '
        f'읽었다(유료 호출 0)</p>')

    P.append("<h2>모델별 하드위반</h2>")
    P.append('<p class="n">★<b>판정 수가 다르다.</b> 프로덕션 두 슬롯은 '
             '샷당 한 번이고 더한 심판은 정·역 두 번이다 — 위반 수만 '
             '견주면 두 배로 볼 기회를 가진 쪽이 적게 냈다는 것이 '
             '지워진다. 판정당 수까지 함께 본다.</p>')
    P.append("<table><tr><th>모델</th><th>판정</th><th>하드위반</th>"
             "<th>판정당</th><th></th></tr>")
    for mdl, v in sorted(hv_tot.items(),
                         key=lambda kv: -kv[1] / max(call_tot.get(kv[0], 1),
                                                     1)):
        c_n = call_tot.get(mdl, 0)
        note = ("★한 건도 안 냈다 — 그러면 「위반 합집합」이 다른 모델 "
                "것만으로 정해진다") if v == 0 else ""
        per = f"{v / c_n:.2f}" if c_n else "—"
        P.append(f'<tr{" class=\'new\'" if mdl in models else ""}>'
                 f'<td>{esc(pretty(mdl))}</td><td>{c_n}회</td>'
                 f'<td class="{"zero" if v == 0 else "hv"}">{v}건</td>'
                 f"<td>{per}</td><td>{esc(note)}</td></tr>")
    P.append("</table>")

    P.append("<h2>더한 심판은 프로덕션과 같은 것을 골랐나</h2>")
    P.append("<table><tr><th>모델</th>"
             "<th>결합 승자 = 프로덕션 최종</th>"
             "<th>정순 승자 = 역순 승자</th>"
             "<th>먼저 보인 것을 고름</th></tr>")
    for mdl in models:
        st = stat[mdl]
        P.append(f'<tr class="new"><td>{esc(pretty(mdl))}</td>'
                 f'<td>{st["match"]}/{st["counted"]}</td>'
                 f'<td>{st["stable"]}/{st["stable_n"]}</td>'
                 f'<td>{st["first"]}/{st["first_n"]}</td></tr>')
    P.append("</table>")
    P.append('<p class="n">「먼저 보인 것을 고름」이 판정 수의 절반 언저리면 '
             '자리와 무관하게 고른 것이고, 전부에 가까우면 그 심판은 내용이 '
             '아니라 <b>순서</b>를 보고 있다는 뜻이다. '
             '★수는 표본 6샷짜리다 — 비율로 읽지 말고 개수 그대로 읽어라.</p>')

    P.append("<h2>샷별</h2>")
    for e in per_shot:
        tag, r, sel = e["tag"], e["rec"], e["sel"]
        cls = "agree" if e["route"] == "cross_slot_agree" else "comb"
        P.append('<div class="shot">')
        heads = [f'<span class="n"> · 프로덕션 최종 <b>{esc(sel)}</b></span>']
        for mdl in models:
            cs = e["combined"].get(mdl)
            if not cs:
                continue
            csel = cs[0]
            good = (csel == sel)
            heads.append(
                f'<span class="n"> · {esc(pretty(mdl))} '
                f'<b class="{"win" if good else "bad"}">{esc(csel)}</b>'
                f'{"" if good else " ← 갈림"}</span>')
        P.append(f'<h3 style="margin:0">{esc(tag)}'
                 f'<span class="route {cls}">{esc(e["route"])}</span>'
                 + "".join(heads) + "</h3>")
        if texts.get(tag):
            P.append(f'<div class="txt">{esc(texts[tag])}</div>')

        P.append("<table><tr><th>심판</th><th>본 순서</th><th>고른 것</th>"
                 "<th>점수</th><th>하드위반</th></tr>")
        for s in e["prod"]:
            if not s.get("ok"):
                P.append(f'<tr><td>{esc(pretty(s["model"]))}</td>'
                         f'<td>{esc(s["order"])}</td>'
                         '<td colspan="3">응답 실패 — 이 슬롯은 빠졌다</td>'
                         '</tr>')
                continue
            n = s.get("normalized") or {}
            P.append(_judge_row(pretty(s["model"]), s["order"],
                                str(n.get("winner") or ""), sel, n))
        for mdl in models:
            for s in e["extra"].get(mdl) or []:
                if not s.get("ok"):
                    P.append(f'<tr class="new"><td>{esc(pretty(mdl))}'
                             f'<span class="n"> (더한 심판)</span></td>'
                             f'<td>{esc(s.get("order"))}</td>'
                             f'<td colspan="3" class="bad">실패 — '
                             f'{esc(str(s.get("error"))[:200])}</td></tr>')
                    continue
                n = s.get("normalized") or {}
                P.append(_judge_row(pretty(mdl), str(s.get("order") or ""),
                                    str(n.get("winner") or ""), sel, n,
                                    is_new=True))
            cs = e["combined"].get(mdl)
            if cs and cs[1]:
                fin = " · ".join(f"{esc(k)} {esc(v)}"
                                 for k, v in (cs[1].get("totals") or {}).items())
                P.append(f'<tr class="new"><td colspan="3">'
                         f'<b>{esc(pretty(mdl))} 정·역 합산</b></td>'
                         f'<td colspan="2">{fin}</td></tr>')
        P.append("</table>")

        P.append('<div class="imgs">')
        picks = {mdl: (e["combined"].get(mdl) or ("",))[0] for mdl in models}
        for lab in ("A", "B"):
            f = rdir / f"{tag}_{lab.lower()}.png"
            if not f.is_file():
                continue
            rel = f.resolve().relative_to(ROOT)
            is_prod = (lab == sel)
            cap = f"후보 {lab}"
            if is_prod:
                cap += " ← 프로덕션 선정"
            for mdl, pk in picks.items():
                if pk == lab:
                    cap += f" ← {pretty(mdl)}"
            P.append(f'<figure class="{"picked" if is_prod else ""}">'
                     f'<a href="/{esc(rel)}" target="_blank">'
                     f'<img src="/{esc(rel)}" loading="lazy"></a>'
                     f"<figcaption>{esc(cap)}</figcaption></figure>")
        f = rdir / f"{tag}_cine.png"
        if f.is_file():
            rel = f.resolve().relative_to(ROOT)
            P.append(f'<figure><a href="/{esc(rel)}" target="_blank">'
                     f'<img src="/{esc(rel)}" loading="lazy"></a>'
                     f"<figcaption>최종 (cine 변환)</figcaption></figure>")
        P.append("</div></div>")

    resdir.mkdir(parents=True, exist_ok=True)
    (resdir / "index.html").write_text("\n".join(P), encoding="utf-8")
    rel = (resdir / "index.html").resolve().relative_to(ROOT)
    print(f"만듦: {resdir / 'index.html'}")
    print(f"주소: http://192.168.231.91:8940/{rel}")
    return 0


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