#!/usr/bin/env python3
"""#104(B-2d) — **롤이 이미 넓었나, cine 변환이 넓혔나.**

★2026-08-28 발견: 최종 `scene` 자산의 `prompt_used` 는 **cine 변환 프롬프트**
 (`Rework this still into a cinematic key frame…`, `reve/2.1/edit`)다.
 롤 프롬프트가 아니다. 즉 내 앞선 6샷 측정은

     롤 프롬프트의 선언  →  (롤 생성)  →  (cine 변환)  →  최종 이미지

 의 **양 끝**을 이어 잰 것이고, 가운데 cine 변환이 끼어 있다는 것을
 보고서에 안 적었다. 넓어진 것이 롤에서 넓어진 것인지 cine 이 넓힌 것인지
 **그 측정으로는 못 가른다.**

다행히 재생성이 필요 없다 — 디스크에 단계별 이미지가 다 있다:

    S{s}sh{h}_sel.png   선택된 롤 (cine 전)
    S{s}sh{h}_fix.png   수정본이 있으면 그것이 cine 입력
    S{s}sh{h}_cine.png  변환 후 = 최종

같은 눈가림 판정을 **두 단계에 각각** 걸어 어디서 벌어지는지 본다.

- 롤이 이미 넓다 → 뿌리는 롤 프롬프트·staging 배정
- 롤은 맞는데 cine 이 넓혔다 → 뿌리는 **변환**이고 전혀 다른 수정이다
  (#67 「cine 이 무검증 primary」의 재발)

판정 계약은 `judge_framing_scale.py` 와 같다 — 계약 줄을 **안 보여 주고**
표준 7단계 중 하나를 고르게 한다. 대조는 코드가 한다.

usage:  judge_framing_stage_split.py <episode_id> [--dry]
"""
from __future__ import annotations

import json
import pathlib
import re
import sys

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

from tools.prompt_measure.judge_framing_scale import (  # noqa: E402
    SCALES, SCHEMA, SYSTEM, normalize)

ORDER = {s: i for i, s in enumerate(SCALES)}
HEAD = ("Look at this photograph and classify its framing scale.\n"
        "Choose exactly one from: " + ", ".join(SCALES) + ".\n"
        "- scale: the framing scale you see\n"
        "- subject_en: what the frame is actually of\n"
        "- why_en: one short sentence on how much of the subject "
        "fills the frame")


def stages(recipe: pathlib.Path, tag: str,
           selected: str | None = None) -> list[tuple[str, pathlib.Path]]:
    """이 샷의 단계별 이미지.

    ## 단계가 **셋**이다 (2026-08-28 Codex 2차 정정, 수용)

        롤원본  `{tag}_{selected}.png`   선정된 롤 후보 — 생성 직후
        cine전  `{tag}_sel.png`          critique/fix/rejudge 뒤 canonical
        cine후  `{tag}_cine.png`         변환 후 = 최종

    ★첫 판은 `_sel` 을 「롤」로 읽었다. **틀렸다** — `_sel` 은 수정·재판정을
     **거친 뒤**의 값이다. 이번 주행 실측: 6샷 중 **4샷에서 fix 가 이겨
     `_sel` 바이트가 선정 원본과 다르다**(S1sh3·S2sh3·S2sh5·S3sh3).
     그래서 「cine 전에 이미 넓다」는 **cine 이 주범이 아니다**까지만 말하고,
     그 넓어짐이 **초기 롤**에서 왔는지 **fix 단계**에서 왔는지는 못 가른다.

    cine 입력은 언제나 `_sel` 이다:

      - `still_recipe_service.py:4922-4924` 가 `sel_path` **만** 넘긴다
      - `multiroll_select.py:848-851` `_atomic_place` 가 `_sel` 에 놓는다
      - 재판정에서 원본이 이기면 `:1049` 가 **원본**을 `_sel` 로 되돌린다
        (`fix_won = winner == "B"`) → `_fix.png` 는 **진 후보일 수 있다**
    """
    out = []
    if selected:
        orig = recipe / f"{tag}_{str(selected).lower()}.png"
        sel_now = recipe / f"{tag}_sel.png"
        # ★원본과 `_sel` 이 **같으면 안 넣는다** — 같은 그림을 두 번 사고
        #  분모만 부풀린다. 다른 샷만 「롤원본」이 별도 단계가 된다.
        if orig.exists() and (
                not sel_now.exists()
                or orig.read_bytes() != sel_now.read_bytes()):
            out.append(("롤원본", orig))
    sel = recipe / f"{tag}_sel.png"
    cine = recipe / f"{tag}_cine.png"
    if sel.exists():
        out.append(("cine전", sel))
    if cine.exists():
        out.append(("cine후", cine))
    return out


def main() -> int:
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    dry = "--dry" in sys.argv
    # `--stage=롤원본` 으로 한 단계만 — 이미 잰 단계를 다시 사지 않는다
    want_stage = ""
    for a in sys.argv[1:]:
        if a.startswith("--stage="):
            want_stage = a.split("=", 1)[1]
    if not args:
        print(__doc__)
        return 2
    epi = args[0]

    recs = list(ROOT.glob(f"projects/*/images/{epi}/scene/recipe/records.json"))
    if not recs:
        print("★records.json 을 못 찾았다 — 결론 내지 마라.")
        return 1
    recipe = recs[0].parent
    rec = json.loads(recs[0].read_text(encoding="utf-8"))

    jobs = []
    for tag in sorted(k for k in rec if "::" not in k
                      and isinstance(rec[k], dict)
                      and isinstance(rec[k].get("roll_prompts"), dict)):
        roll = next(iter(rec[tag]["roll_prompts"].values()))
        m = re.search(r"^- FRAMING SCALE: (.+)$", roll, re.M)
        if not m:
            print(f"  {tag}: 롤 프롬프트에 선언이 없다 — 건너뛴다")
            continue
        raw = m.group(1).strip()
        for stage, path in stages(recipe, tag, rec[tag].get("selected")):
            if want_stage and stage != want_stage:
                continue
            jobs.append((tag, raw, normalize(raw), stage, path))

    if not jobs:
        print("★단계별 이미지를 하나도 못 찾았다 — 경로를 확인하라.")
        return 1

    per_shot = len({j[0] for j in jobs})
    print(f"샷 {per_shot} · 단계 판정 {len(jobs)}건 × 모델 2 = "
          f"{len(jobs)*2} 호출")
    for tag, raw, norm, stage, path in jobs:
        print(f"  {tag:8} {stage:6} 선언={norm:10} {path.name}")
    if dry:
        print("\n--dry 라 호출 0")
        return 0

    from app.modules.llm.dual_vlm import ask_both
    from app.modules.pipeline.multiroll_gemini import png_part

    out = []
    for i, (tag, raw, norm, stage, path) in enumerate(jobs, 1):
        parts = [{"type": "text", "text": HEAD},
                 {"type": "text", "text": "PHOTOGRAPH:"},
                 png_part(path.read_bytes())]
        dual = ask_both("framing_stage", SYSTEM, parts, SCHEMA,
                        schema_name="framing_stage")
        row = {"shot": tag, "stage": stage, "declared": norm,
               "declared_raw": raw, "file": path.name, "by_model": {}}
        for call in dual.calls:
            row["by_model"][call.alias] = {
                "ok": call.ok, "payload": call.payload,
                "error": getattr(call, "error", None)}
        out.append(row)
        seen = {a: (v["payload"] or {}).get("scale")
                for a, v in row["by_model"].items() if v["ok"]}
        print(f"  [{i}/{len(jobs)}] {tag} {stage} 선언={norm} 판정={seen}")

    d = ROOT / "artifact" / "20260828_framing_stage_split"
    d.mkdir(parents=True, exist_ok=True)
    path = d / "verdicts.json"
    # ★공유 색인은 **읽어서 잇는다** — 빈 목록에서 시작하면 안 된다.
    #  2026-08-28: `--stage=롤원본` 으로 좁혀 돌렸더니 앞선 24콜 결과를
    #  **통째로 덮어썼다.** 같은 날 `mapping.json` 으로 20건을 날린 것과
    #  같은 부류고, `compare_shot_selection_models.py` 에는 막는 코드를 넣고
    #  이 도구에는 안 넣어서 다시 났다.
    #  키 = (샷, 단계) — 같은 자리를 다시 재면 새 값이 이긴다.
    merged: dict[str, dict] = {}
    if path.exists():
        try:
            for r in json.loads(path.read_text(encoding="utf-8")):
                merged[f"{r['shot']}|{r['stage']}"] = r
        except Exception as exc:  # noqa: BLE001
            print(f"★기존 verdicts.json 을 못 읽었다 ({exc}) — 덮어쓰지 않고 멈춘다")
            return 1
    before = len(merged)
    for r in out:
        merged[f"{r['shot']}|{r['stage']}"] = r
    rows = list(merged.values())
    path.write_text(json.dumps(rows, ensure_ascii=False, indent=1),
                    encoding="utf-8")
    print(f"\n적었다: {path.relative_to(ROOT)}  "
          f"(기존 {before} + 이번 {len(out)} → {len(rows)}행)")

    print("\n═══ 어디서 벌어지나 (합치지 않는다) ═══")
    by = {}
    for r in rows:
        by.setdefault(r["shot"], {})[r["stage"]] = r
    # ★단계는 셋이다. 한 단계만 돌려도 표가 무너지지 않게 **있는 것만** 쓴다
    #  (종전엔 `pre or post` 만 봐서 `--stage=롤원본` 만 돌리면 전부 `—` 였다).
    STAGE_ORDER = ("롤원본", "cine전", "cine후")
    for shot in sorted(by):
        got = by[shot]
        # 선언은 어느 단계에서 읽어도 같다 — 있는 것 아무거나
        decl = next((got[s]["declared"] for s in STAGE_ORDER if s in got), None)
        di = ORDER.get(decl or "")
        print(f"\n{shot}  선언={decl}")
        for alias in ("gemini-pro", "grok"):
            def delta(r):
                if not r:
                    return "(안 잼)"
                v = (r["by_model"].get(alias) or {})
                if not v.get("ok"):
                    return "✗"
                gi = ORDER.get((v.get("payload") or {}).get("scale") or "")
                if di is None or gi is None:
                    return "?"
                d_ = gi - di
                return (f"{(v.get('payload') or {}).get('scale')}"
                        f"({'+' if d_ > 0 else ''}{d_})")
            cells = "  ".join(
                f"{s}={delta(got.get(s)):22}" for s in STAGE_ORDER)
            print(f"    {alias:11} {cells}")
    return 0


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