"""`shot_selection` 을 세 모델로 돌려 대조한다 (2026-08-28 사용자 지시).

usage:  compare_shot_selection_models.py <episode_id>          # 기본 dry, 유료 0
        compare_shot_selection_models.py <episode_id> --run    # 실제로 산다

## 왜 이걸 재나

`shot_selection` 만 `gpt-mini`(→ 실제로는 `gemini-3.7-flash`)다. 샷을 만드는
`shot_extract`, 검증하는 `shot_validator`, 이후 `scene_director` 는 전부
`gemini-pro` 다. **가장 값싼 모델이 「무엇을 남길지」를 정한다.**

이번 주행 실측: 17개 추출 → 6개 선정, 그런데 **세 씬이 다 2개**였다.
씬 무게가 다른데 같은 수가 나온 것은 개수를 코드가 정하기 때문이다
(`_effective_max = max(1, min(3, total//2))`).

## 재는 법 — 내 편향을 막는 것이 핵심

- **프로덕션 조립을 그대로 쓴다.** 프롬프트를 새로 짓지 않는다 — 새로 지으면
  다른 것을 재게 된다. `_select_for_scene` 이 만드는 `user_prompt` 를 같은
  방식으로 만들고 팩도 같은 것을 읽는다.
- **현행 선택을 안 알려 준다.** 알려 주면 답이 정해진 질문이 된다.
- **모델당 순서를 섞어 2회.** 자리 편향을 걷는다.
- **표를 합치지 않는다.** 셋이 갈리면 갈린 채로 남긴다 — 합치면 누가 무엇을
  봤는지가 사라진다.
- 기본이 dry 다. 실제로 사려면 `--run` 을 손으로 적는다.

★이 도구는 **모델만 바꾼다.** 개수 상한(`effective_max`)은 프로덕션과 같게
 둔다 — 그래야 「모델을 바꾸면 고르는 것이 달라지나」만 재진다.
 개수 재량까지 주는 것은 별개 실험이다.
"""
from __future__ import annotations

import json
import pathlib
import random
import sys
from collections import Counter

ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "backend"))

MODELS = ["gpt", "gemini-pro", "grok"]   # sol · 3.1 pro · grok 최신
ROUNDS = 2                                # 순서를 섞어 두 번

# ★`--rounds N` 과 `--scene K` 로 좁힐 수 있다 (2026-08-28).
#  1차 2라운드에서 **씬1만** 셋 다 현행과 갈렸고 셋 다 순서에 흔들렸다.
#  갈린 씬 하나만 더 돌려 「지속되는 차이인가 재추첨인가」를 가른다 —
#  갈리지 않은 씬까지 다시 사는 것은 낭비다.


def _half_cap(total: int) -> int:
    return max(1, total // 2)


def _effective_max(total: int, absolute_max: int) -> int:
    return max(1, min(absolute_max, _half_cap(total)))


def load_scenes(epi: str) -> list[dict]:
    """`shot_validator` 체크포인트에서 씬·샷을 읽는다 — 선정 스텝의 입력."""
    for p in ROOT.glob(f"projects/*/checkpoints/episodes/{epi}/shot_validator/manifest.json"):
        m = json.loads(p.read_text(encoding="utf-8"))
        for v in (m, m.get("payload"), m.get("data")):
            if isinstance(v, dict) and isinstance(v.get("scenes"), list):
                return v["scenes"]
            if isinstance(v, list) and v and isinstance(v[0], dict) and "shots" in v[0]:
                return v
    return []


def current_selection(epi: str) -> dict[int, set[int]]:
    """지금 시스템이 고른 것 — **판정자에게는 안 준다.** 대조용이다."""
    from sqlalchemy import create_engine, text

    from app.core.config import settings
    eng = create_engine(settings.database_url)
    out: dict[int, set[int]] = {}
    with eng.connect() as c:
        for si, shi in c.execute(text(
                "SELECT scene_index, shot_index FROM scene_still "
                f"WHERE episode_id='{epi}' AND is_selected IS TRUE")).fetchall():
            out.setdefault(int(si), set()).add(int(shi))
    return out


def _opt(name: str, default: int) -> int:
    for a in sys.argv[1:]:
        if a.startswith(f"--{name}="):
            return int(a.split("=", 1)[1])
    return default


def main() -> int:
    global ROUNDS
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    dry = "--run" not in sys.argv
    if not args:
        print(__doc__)
        return 2
    epi = args[0]
    ROUNDS = _opt("rounds", ROUNDS)
    only = _opt("scene", 0)

    from app.modules.prompt_loader import load_prompt, load_schema

    scenes = load_scenes(epi)
    if only:
        scenes = [s for s in scenes if s.get("scene_index") == only]
        if not scenes:
            print(f"씬{only} 을 못 찾았다 — 있는 씬만 골라라")
            return 1
    if not scenes:
        print("shot_validator 체크포인트에서 씬을 못 읽었다")
        return 1

    # ★팩도 프로덕션과 같은 것을 읽는다
    system = load_prompt("shot_selection", "system")
    user_template = load_prompt("shot_selection", "user")
    schema = load_schema("shot_selection", "selection_schema")
    absolute_max = 3

    cur = current_selection(epi)
    print(f"씬 {len(scenes)}개 · 모델 {MODELS} · 라운드 {ROUNDS} "
          f"· {'dry (유료 0)' if dry else '★실제 호출'}\n")

    plan = []
    for sc in scenes:
        shots = sc.get("shots", [])
        total = len(shots)
        eff = _effective_max(total, absolute_max)
        plan.append((sc["scene_index"], total, eff))
        print(f"  씬{sc['scene_index']}: 샷 {total} → 고를 수 {eff} "
              f"· 현행 선택 {sorted(cur.get(sc['scene_index'], []))}")
    print(f"\n호출 수 = 씬 {len(scenes)} × 모델 {len(MODELS)} × 라운드 {ROUNDS} "
          f"= {len(scenes)*len(MODELS)*ROUNDS}건")

    if dry:
        print("\n기본이 dry 라 호출 0 — 실제로 사려면 --run")
        return 0

    from app.modules.llm.llm_client import call_structured

    rng = random.Random(20260828)
    picks: dict[tuple[int, str, int], list[int]] = {}
    reasons: dict[tuple[int, str, int], dict[int, str]] = {}

    for sc in scenes:
        si = sc["scene_index"]
        shots = list(sc.get("shots", []))
        total = len(shots)
        for r in range(ROUNDS):
            order = shots[:] if r == 0 else rng.sample(shots, len(shots))
            shots_block = "\n".join(
                f"Shot {sh['shot_index']} (beat:{sh.get('based_on_beat', 0)}): "
                f"{sh['description']}" for sh in order)
            user_prompt = user_template.format(
                scene_index=si, scene_heading=sc.get("scene_heading", ""),
                max_n=absolute_max, total_shots=total,
                half_cap=_half_cap(total), shots_block=shots_block)
            for model in MODELS:
                try:
                    res = call_structured(
                        step="shot_selection_compare",
                        system_prompt=system, user_prompt=user_prompt,
                        response_schema=schema,
                        project_config={"model": model},
                        schema_name=f"cmp_{si}_{model}_{r}",
                        num_retries=0, enable_fallback=False)
                    raw = res.get("selected_shots") or [
                        {"shot_index": i, "reason": ""}
                        for i in (res.get("selected_shot_indices") or [])]
                    idxs, why = [], {}
                    for it in raw:
                        if isinstance(it, dict) and it.get("shot_index") is not None:
                            idxs.append(int(it["shot_index"]))
                            why[int(it["shot_index"])] = str(it.get("reason") or "")
                    picks[(si, model, r)] = idxs
                    reasons[(si, model, r)] = why
                    print(f"  씬{si} {model:11} r{r}: {sorted(idxs)}")
                except Exception as exc:  # noqa: BLE001
                    picks[(si, model, r)] = []
                    print(f"  씬{si} {model:11} r{r}: ✗ {exc}")

    print("\n===== 대조 (합치지 않는다) =====")
    for si, total, eff in plan:
        print(f"\n씬{si} (샷 {total}, 고를 수 {eff})")
        print(f"  현행(gemini-3.7-flash) {sorted(cur.get(si, []))}")
        for model in MODELS:
            got = [sorted(picks.get((si, model, r), [])) for r in range(ROUNDS)]
            same = "순서 무관" if got[0] == got[1] else "★순서에 흔들림"
            print(f"  {model:11} r0={got[0]} r1={got[1]}  ({same})")
        votes = Counter(i for m in MODELS for r in range(ROUNDS)
                        for i in picks.get((si, m, r), []))
        print(f"  표 합계: {dict(sorted(votes.items()))}")
    out = ROOT / "artifact" / "20260828_shot_selection_models"
    out.mkdir(parents=True, exist_ok=True)
    # ★공유 색인은 **읽어서 잇는다** — 빈 dict 에서 시작하면 안 된다.
    #  2026-08-28: 한 쌍만 다시 굽는 도구가 `mapping.json` 을 빈 목록에서
    #  시작해 **다른 20건을 날렸고**, 그 뒤 판정이 8장만 돌고도 초록이었다.
    #  여기서도 `--scene=1` 로 좁혀 돌리면 씬2·씬3 결과가 통째로 사라진다.
    path = out / "picks.json"
    merged: dict = {}
    if path.exists():
        try:
            merged = json.loads(path.read_text(encoding="utf-8"))
        except Exception as exc:  # noqa: BLE001
            print(f"★기존 picks.json 을 못 읽었다 ({exc}) — 덮어쓰지 않고 멈춘다")
            return 1
    before = len(merged)
    merged.update({f"{k[0]}|{k[1]}|{k[2]}": v for k, v in picks.items()})
    path.write_text(json.dumps(merged, ensure_ascii=False, indent=2),
                    encoding="utf-8")
    print(f"\n적었다: {path}  (기존 {before}칸 + 이번 {len(picks)}칸 "
          f"→ {len(merged)}칸)")
    return 0


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