"""최종 256샷 갤러리 + Gemini·Qwen 이중 재평가 (#64).

각 샷에 대해:
  · 후보 롤(a/b/c…) 과 프로덕션 선택 — records.json 에서
  · 수정사항 — 결함 검사 내용·수정 적용 여부·수정 재판정 결과
  · **재평가**: Gemini(gemini-pro, Router 구조화 호출)와 Qwen(3.8 Max,
    DashScope 우회)이 같은 계약(opus_pilot 의 SELECT_SYS + select_schema,
    같은 브리프·같은 참조·같은 후보 제시)으로 후보를 다시 고른다.
  · 지목 25건 태그 — 2026-08-07 육안 판정 문서의 샷 목록과 대조

돈 주의: 판정 호출만(이미지 생성 없음). 재실행하면 이미 판정된 샷은
건너뛴다(결과 json 에 있으면 skip — 증분 저장).

사용:
    .venv/bin/python build_final_eval_gallery.py            # 판정 + 갤러리
    .venv/bin/python build_final_eval_gallery.py --html     # 갤러리만 재생성
    .venv/bin/python build_final_eval_gallery.py --limit 6  # 시험 소량
"""
from __future__ import annotations

import argparse
import html as _html
import json
import sys
import threading
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

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

from opus_pilot import (  # noqa: E402
    EPI, PROJ, RECIPE, ROOT, SELECT_SYS, build_size_index, select_schema,
)
from qwen_vlm_pilot import ask_qwen, build_parts  # noqa: E402

OUT_DIR = ROOT / "artifact" / "20260809_final_eval_gallery"
RESULTS = OUT_DIR / "results.json"
INDEX = OUT_DIR / "index.html"
WORKERS = 6

# 2026-08-07 육안 판정 25건이 걸린 샷(문서에서 뽑은 태그 23개 —
# 일부 지목은 같은 샷을 두 번 다룬다)
FLAGGED = {
    "S1sh7", "S2sh5", "S13sh3", "S13sh4", "S15sh3", "S15sh5", "S18sh1",
    "S18sh5", "S42sh4", "S57sh3", "S61sh12", "S62sh4", "S73sh4", "S74sh8",
    "S76sh5", "S79sh1", "S88sh3", "S88sh6", "S93sh5", "S93sh8", "S97sh5",
    "S98sh6", "S104sh7",
}
# 이번 JIT 재실행에서 다시 만들어진 샷
REGEN_TODAY = {"S1sh5", "S1sh7", "S96sh1", "S96sh4", "S98sh2", "S98sh5",
               "S98sh6"}


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


def load_records() -> dict:
    return json.loads((RECIPE / "records.json").read_text("utf-8"))


def current_tags() -> set:
    """지금 선정된 스틸의 태그만 — records 에는 이전 선정분(스테일)도
    남아 있어 그대로 돌면 스테일 판정에 돈이 샌다."""
    import subprocess

    out = subprocess.run(
        ["psql", "-h", "localhost", "-U", "theroad", "-d", "theroad",
         "-t", "-A", "-c",
         "SELECT scene_index||'sh'||shot_index FROM scene_still "
         f"WHERE episode_id='{EPI}' AND is_selected AND still_index>=0 "
         "AND status!='stale';"],
        capture_output=True, text=True,
        env={"PGPASSWORD": "theroad_dev_2026", "PATH": "/usr/bin:/bin:"
             "/usr/local/bin:/opt/homebrew/bin"},
    )
    tags = {f"S{line.strip()}" for line in out.stdout.splitlines()
            if line.strip()}
    if not tags:
        raise RuntimeError(f"선정 스틸 조회 실패: {out.stderr[:200]}")
    return tags


def judge_gemini(stem: str, parts: list, labels: list) -> dict:
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        "gallery_reselect",
        SELECT_SYS,
        parts,
        select_schema(labels),
        project_config={"gallery_reselect": {"model": "gemini-pro"}},
        schema_name="reselect",
        opik_metadata={"operation_type": "gallery_reselect", "shot_tag": stem,
                       "project_id": PROJ, "episode_id": EPI},
        enable_fallback=False,
    )


def run_judges(limit: int = 0) -> None:
    records = load_records()
    size_idx = build_size_index()
    done: dict = {}
    if RESULTS.exists():
        done = json.loads(RESULTS.read_text("utf-8"))

    live = current_tags()
    tags = sorted(
        (t for t, r in records.items()
         if isinstance(r, dict) and "::" not in t and r.get("selected")
         and t in live),
        key=tag_sort_key)
    jobs = [t for t in tags
            if done.get(t, {}).get("gemini") is None
            or done.get(t, {}).get("qwen") is None]
    if limit:
        jobs = jobs[:limit]
    print(f"판정 대상 {len(jobs)} / 전체 {len(tags)} (이미 완료 "
          f"{len(tags) - len(jobs)})", flush=True)

    lock = threading.Lock()
    count = [0]

    def one(stem: str):
        rec = records[stem]
        parts, labels = build_parts(stem, rec, size_idx)
        entry = done.get(stem) or {}
        if not labels:
            return stem, {"skipped": "후보 롤 파일 없음"}
        if entry.get("gemini") is None:
            try:
                entry["gemini"] = judge_gemini(stem, parts, labels)
            except Exception as exc:  # noqa: BLE001
                entry["gemini_error"] = repr(exc)[:300]
        if entry.get("qwen") is None:
            try:
                payload, meta = ask_qwen(SELECT_SYS, parts,
                                         select_schema(labels))
                entry["qwen"] = payload
                entry["qwen_meta"] = {
                    "attempts": len(meta.get("attempts", []))}
            except Exception as exc:  # noqa: BLE001
                entry["qwen_error"] = repr(exc)[:300]
        entry["labels"] = labels
        return stem, entry

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    with ThreadPoolExecutor(max_workers=WORKERS) as ex:
        futs = {ex.submit(one, t): t for t in jobs}
        for f in as_completed(futs):
            stem = futs[f]
            try:
                k, v = f.result()
                done[k] = v
            except Exception:  # noqa: BLE001
                done[stem] = {"error": traceback.format_exc()[-400:]}
            with lock:
                count[0] += 1
                if count[0] % 5 == 0 or count[0] == len(jobs):
                    RESULTS.write_text(
                        json.dumps(done, ensure_ascii=False, indent=1),
                        "utf-8")
                    g_ok = sum(1 for v in done.values() if v.get("gemini"))
                    q_ok = sum(1 for v in done.values() if v.get("qwen"))
                    print(f"  {count[0]}/{len(jobs)}  G성공 {g_ok} "
                          f"Q성공 {q_ok}", flush=True)
    RESULTS.write_text(json.dumps(done, ensure_ascii=False, indent=1),
                       "utf-8")


# ── 갤러리 ──────────────────────────────────────────────────────────

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


def img_url(name: str) -> str:
    return f"/projects/{PROJ}/images/{EPI}/scene/recipe/{name}"


def render(records: dict, results: dict) -> str:
    rows = []
    live = current_tags()
    tags = sorted(
        (t for t, r in records.items()
         if isinstance(r, dict) and "::" not in t and r.get("selected")
         and t in live),
        key=tag_sort_key)

    n = {"g_agree": 0, "q_agree": 0, "gq_agree": 0, "g_ok": 0, "q_ok": 0,
         "g_fail_flag": 0, "q_fail_flag": 0, "fixed": 0}
    for tag in tags:
        rec = records[tag]
        res = results.get(tag) or {}
        sel = str(rec.get("selected", "")).upper()
        g = res.get("gemini") or {}
        q = res.get("qwen") or {}
        g_win = str(g.get("winner", "")).upper()
        q_win = str(q.get("winner", "")).upper()
        if g_win:
            n["g_ok"] += 1
            n["g_agree"] += int(g_win == sel)
            n["g_fail_flag"] += int(bool(g.get("all_candidates_fail")))
        if q_win:
            n["q_ok"] += 1
            n["q_agree"] += int(q_win == sel)
            n["q_fail_flag"] += int(bool(q.get("all_candidates_fail")))
        if g_win and q_win:
            n["gq_agree"] += int(g_win == q_win)

        crit = rec.get("critique") or {}
        issues = [i.get("issue_ko", "") for i in (crit.get("issues") or [])]
        fixed = "fix_prompt" in rec
        n["fixed"] += int(fixed)
        rj = rec.get("fix_rejudge") or {}

        labels = res.get("labels") or sorted(
            p.stem.split("_")[-1].upper()
            for p in RECIPE.glob(f"{tag}_[abc].png"))
        cls = []
        disagree = (g_win and g_win != sel) or (q_win and q_win != sel)
        if disagree:
            cls.append("disagree")
        if tag in FLAGGED:
            cls.append("flagged")
        if tag in REGEN_TODAY:
            cls.append("regen")

        def reading_block(name, payload, err):
            if err:
                return (f"<div class='jd err'><b>{name}</b> 오류: "
                        f"{esc(err)}</div>")
            if not payload:
                return f"<div class='jd err'><b>{name}</b> 결과 없음</div>"
            win = str(payload.get("winner", "")).upper()
            mark = "일치" if win == sel else f"불일치(프로덕션 {sel})"
            fail = (" · <b class='warn'>전 후보 실패 판정</b>"
                    if payload.get("all_candidates_fail") else "")
            body = [f"<div class='jd'><b>{name} → {esc(win)}</b> "
                    f"<span class='{'ok' if win == sel else 'no'}'>"
                    f"{mark}</span>{fail}<br>{esc(payload.get('why', ''))}"]
            for r in payload.get("readings", []):
                body.append(
                    f"<details><summary>{esc(r.get('label'))} — "
                    f"{esc(r.get('score'))}점, 위반 "
                    f"{len(r.get('hard_violations') or [])}건</summary>"
                    f"<p>방향: {esc(r.get('direction'))}</p>"
                    f"<p>구조: {esc(r.get('built_space'))}</p>"
                    f"<p>대상: {esc(r.get('entities'))}</p>"
                    f"<p>위반: {esc('; '.join(r.get('hard_violations') or []) or '없음')}</p>"
                    f"</details>")
            body.append("</div>")
            return "".join(body)

        thumbs = []
        for lab in labels:
            badges = []
            if lab == sel:
                badges.append("<span class='b prod'>선택</span>")
            if lab == g_win:
                badges.append("<span class='b gem'>G</span>")
            if lab == q_win:
                badges.append("<span class='b qwn'>Q</span>")
            thumbs.append(
                f"<figure><img loading='lazy' "
                f"src='{img_url(f'{tag}_{lab.lower()}.png')}'>"
                f"<figcaption>{lab} {''.join(badges)}</figcaption></figure>")
        final_imgs = (
            f"<figure><img loading='lazy' src='{img_url(f'{tag}_sel.png')}'>"
            f"<figcaption>최종(_sel{'·수정 반영' if fixed else ''})"
            f"</figcaption></figure>")

        fix_html = ""
        if issues or fixed:
            rj_txt = ""
            if rj:
                rj_txt = (" · 수정 재판정: "
                          + ("수정본 승" if rj.get("winner") == "B"
                             else f"원본 승({esc(rj.get('winner'))})"))
            fix_html = (
                "<div class='fix'><b>수정사항</b> — 결함 "
                f"{len(issues)}건{', 수정 적용' if fixed else ', 수정 없음'}"
                f"{rj_txt}<ul>"
                + "".join(f"<li>{esc(i)}</li>" for i in issues)
                + "</ul></div>")

        tags_html = "".join(
            [("<span class='t flag'>지목 25건</span>" if tag in FLAGGED else ""),
             ("<span class='t rg'>오늘 재생성</span>"
              if tag in REGEN_TODAY else "")])

        rows.append(
            f"<section class='row {' '.join(cls)}' id='{tag}'>"
            f"<h3>{tag} {tags_html} <small>{esc(rec.get('ref_mode', ''))}"
            f"</small></h3>"
            f"<div class='imgs'>{''.join(thumbs)}{final_imgs}</div>"
            f"{fix_html}"
            f"{reading_block('Gemini', g, res.get('gemini_error'))}"
            f"{reading_block('Qwen', q, res.get('qwen_error'))}"
            f"</section>")

    def pct(a, b):
        return f"{100 * a / b:.0f}%" if b else "-"

    head = (
        f"<p><b>{len(tags)}샷</b> · 수정 적용 {n['fixed']}샷 · "
        f"Gemini 재선택 성공 {n['g_ok']} (프로덕션과 일치 "
        f"{pct(n['g_agree'], n['g_ok'])}, 전 후보 실패 {n['g_fail_flag']}) · "
        f"Qwen 성공 {n['q_ok']} (일치 {pct(n['q_agree'], n['q_ok'])}, "
        f"전 후보 실패 {n['q_fail_flag']}) · G=Q 상호 일치 "
        f"{pct(n['gq_agree'], min(n['g_ok'], n['q_ok']))}</p>"
        "<p>필터: <a href='#' onclick=\"return flt('')\">전체</a> · "
        "<a href='#' onclick=\"return flt('disagree')\">재선택 불일치만</a> · "
        "<a href='#' onclick=\"return flt('flagged')\">지목 25건만</a> · "
        "<a href='#' onclick=\"return flt('regen')\">오늘 재생성만</a></p>")

    return f"""<meta charset="utf-8">
<title>금월도 최종 256샷 — 선택·수정·이중 재평가 (2026-08-09)</title>
<style>
 body{{font-family:sans-serif;margin:16px;background:#111;color:#ddd}}
 .row{{border-top:1px solid #333;padding:12px 0}}
 .imgs{{display:flex;gap:8px;overflow-x:auto}}
 figure{{margin:0;text-align:center}}
 img{{height:190px;border-radius:4px}}
 figcaption{{font-size:12px;color:#aaa}}
 .b{{padding:1px 6px;border-radius:3px;font-size:11px;color:#fff}}
 .prod{{background:#2a7}}.gem{{background:#36c}}.qwn{{background:#a4c}}
 .jd{{margin:6px 0;padding:6px 10px;background:#1a1a1a;border-radius:6px;
     font-size:13px}}
 .jd.err{{color:#e88}}
 .ok{{color:#6d6}}.no{{color:#e77}}.warn{{color:#fa3}}
 .fix{{margin:6px 0;padding:6px 10px;background:#20180d;border-radius:6px;
     font-size:13px}}
 .t{{font-size:11px;padding:1px 6px;border-radius:3px;margin-left:6px}}
 .t.flag{{background:#a33;color:#fff}}.t.rg{{background:#375;color:#fff}}
 details{{margin:3px 0 3px 8px}} summary{{cursor:pointer}}
 h3 small{{color:#888;font-weight:normal;font-size:12px}}
 a{{color:#8cf}}
</style>
<h1>금월도 최종 256샷 — 선택·수정사항·Gemini/Qwen 재평가</h1>
{head}
<script>
function flt(c){{document.querySelectorAll('.row').forEach(r=>{{
  r.style.display=(!c||r.classList.contains(c))?'':'none';}});return false}}
</script>
{''.join(rows)}
"""


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--html", action="store_true", help="갤러리만 재생성")
    ap.add_argument("--limit", type=int, default=0)
    args = ap.parse_args()

    if not args.html:
        run_judges(limit=args.limit)

    records = load_records()
    results = json.loads(RESULTS.read_text("utf-8")) if RESULTS.exists() else {}
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    INDEX.write_text(render(records, results), "utf-8")
    print(f"갤러리: {INDEX}")


if __name__ == "__main__":
    main()
