"""확정된 최종 스틸을 심판 3모델이 각각 검사 — 이번 산출이 실제로 나아졌나.

선정 재판정(`reselect_v6.py`)이 "후보 중 더 나은 것이 있었나"를 묻는다면
이쪽은 **확정본 자체에 무엇이 남아 있나**를 묻는다. 목적이 다르므로 둘 다
필요하다.

프로덕션 critique 경로를 그대로 쓴다(`make_gemini_critique_fn` + 스틸 팩
selector) — 모델만 강제해 세 심판을 같은 조건에 세운다. 셋이 **함께 짚은**
결함은 실재할 가능성이 높고, **한 모델만 짚은** 것은 그 모델의 축이거나
과탐지다. 그 구분이 육안으로 볼 샷을 좁혀 준다.

이미지 생성 0 — 검사 콜만.

사용
  .venv/bin/python critique_all_3models.py <out.json> [--stems ...] [--workers 6]
"""
from __future__ import annotations

import argparse
import json
import sys
import threading
import traceback
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List

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

from app.modules.pipeline.multiroll_gemini import (  # noqa: E402
    JUDGE_MODEL, SELECT_JUDGE_MODEL, SELECT_JUDGE_MODEL_2,
    STILL_JUDGE_PACK_VERSION, resolve_judge_pack_version, resolve_judge_texts,
)
from app.modules.pipeline.multiroll_select import (  # noqa: E402
    build_critique_schema, roll_labels,
)
from reselect_v6 import RECIPE, build_size_index, resolve_refs  # noqa: E402

JUDGES = [SELECT_JUDGE_MODEL, SELECT_JUDGE_MODEL_2, JUDGE_MODEL]
LABEL = {SELECT_JUDGE_MODEL: "Opus", SELECT_JUDGE_MODEL_2: "Sol",
         JUDGE_MODEL: "Gemini"}


def critique_with(model: str, sys_prompt: str, schema, prompt: str,
                  refs, image: Path) -> Dict[str, Any]:
    """프로덕션 critique 과 같은 제시, 모델만 강제."""
    from app.modules.llm.llm_client import call_structured
    from app.modules.pipeline.multiroll_gemini import png_part, ref_parts

    tag = "still_recipe_critique_sweep"
    parts: List[Dict[str, Any]] = [
        {"type": "text", "text": "THE PROMPT:\n" + prompt}]
    parts += ref_parts(refs)
    parts.append({"type": "text", "text": "Photograph to examine:"})
    parts.append(png_part(image))
    return call_structured(
        tag, sys_prompt, parts, schema,
        project_config={tag: {"model": model}}, schema_name=tag)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("out", type=Path)
    ap.add_argument("--stems", default="")
    ap.add_argument("--workers", type=int, default=6)
    args = ap.parse_args()

    records = json.loads((RECIPE / "records.json").read_text("utf-8"))
    size_idx = build_size_index()
    stems = ([s.strip() for s in args.stems.split(",") if s.strip()]
             or [k for k, v in records.items()
                 if "::" not in k and isinstance(v, dict) and v.get("selected")])

    pack = resolve_judge_pack_version(STILL_JUDGE_PACK_VERSION)
    print(f"팩 {pack} · 심판 {[LABEL[j] for j in JUDGES]}", flush=True)

    jobs, skipped = [], []
    for stem in sorted(stems, key=lambda s: (len(s), s)):
        rec = records.get(stem) or {}
        sel = RECIPE / f"{stem}_sel.png"
        if not sel.exists():
            skipped.append({"stem": stem, "why": "최종본 없음"})
            continue
        refs, unresolved = resolve_refs(rec.get("refs") or [], size_idx)
        if unresolved:
            skipped.append({"stem": stem, "why": "참조 복원 실패"})
            continue
        n = len(rec.get("verdicts") or []) or 3
        for j in JUDGES:
            jobs.append((stem, rec, refs, sel, n, j))

    print(f"검사 {len(jobs)}콜 (샷 {len(jobs)//len(JUDGES)} × {len(JUDGES)}모델)"
          f" · 건너뜀 {len(skipped)}", flush=True)

    results: Dict[str, Dict[str, Any]] = {}
    lock, done = threading.Lock(), [0]

    def run(job):
        stem, rec, refs, sel, n, model = job
        texts = resolve_judge_texts(
            n, judge_name="judge_still", pack_version=STILL_JUDGE_PACK_VERSION)
        res = critique_with(model, texts["critique_sys"],
                            build_critique_schema(),
                            rec.get("prompt", ""), refs, sel)
        return stem, model, res

    with ThreadPoolExecutor(max_workers=args.workers) as ex:
        futs = {ex.submit(run, j): (j[0], j[5]) for j in jobs}
        for f in as_completed(futs):
            stem, model = futs[f]
            slot = results.setdefault(stem, {})
            try:
                _s, _m, res = f.result()
                slot[model] = res
            except Exception as exc:  # noqa: BLE001
                slot[model] = {"error": repr(exc),
                               "trace": traceback.format_exc()[-500:]}
            with lock:
                done[0] += 1
                if done[0] % 25 == 0 or done[0] == len(jobs):
                    print(f"  [{done[0]}/{len(jobs)}]", flush=True)

    # 집계 — 모델별 지적 수, 그리고 몇 모델이 함께 짚었는가
    per_model = Counter()
    agree_hist = Counter()
    hot: List[tuple] = []
    for stem, slot in results.items():
        counts = {}
        for m in JUDGES:
            r = slot.get(m) or {}
            k = len(r.get("issues") or []) if "error" not in r else -1
            counts[m] = k
            if k > 0:
                per_model[m] += k
        flagged = sum(1 for m in JUDGES if counts.get(m, 0) > 0)
        agree_hist[flagged] += 1
        if flagged == len(JUDGES):
            hot.append((stem, sum(max(0, counts[m]) for m in JUDGES)))

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(
        {"pack": pack, "judges": JUDGES, "results": results,
         "skipped": skipped}, ensure_ascii=False, indent=1), "utf-8")

    print("\n" + "=" * 58)
    print("모델별 지적 총계")
    for m in JUDGES:
        print(f"  {LABEL[m]:8s} {per_model[m]}건")
    print("\n몇 모델이 함께 짚었나 (샷 수)")
    for k in sorted(agree_hist, reverse=True):
        print(f"  {k}모델: {agree_hist[k]}샷")
    hot.sort(key=lambda x: -x[1])
    print(f"\n★3모델 전원이 짚은 샷 {len(hot)}건 — 지적 많은 순 상위 20")
    print("  " + ", ".join(f"{s}({c})" for s, c in hot[:20]))
    print(f"\n기록 → {args.out}")


if __name__ == "__main__":
    main()
