#!/usr/bin/env python3
"""C-1 A/B — CARRIED 인물 이름 결속이 **복장 귀속**을 고치나 (2026-08-29).

## 무엇만 재나 (Codex 확정 범위)

**민수=남색 작업복 / 노인=회색 코트 / 두 복장의 교차 귀속** 셋만 센다.
전반적 미감·프레이밍은 이번 acceptance 에 **안 섞는다.** 그림 좋고
나쁨을 여기서 말하지 않는다.

## 무엇을 고정하나

    대상        S2sh6 — 남성 2명 · 서로 다른 복장 · 표준 갈래 ·
                이미 이 결함이 난 샷
    단계        **raw roll 만.** fix/regenerate·cine 은 안 탄다
    바꾸는 것   **이름 접두 두 자리뿐.** 나머지 프롬프트 바이트·참조
                파일 SHA·모델·roll_count 전부 같다 — preflight 가 증명한다
    표본        seed 가 없으므로 arm 당 **2회 × 롤 2장 = 4장**, 합 8장
                선정 결과는 보조표로만 둔다. **raw 8장이 본표다**

## 판정 규칙 (주행 뒤에 바꾸지 않는다)

    old 도 전부 맞다        → 「재현 안 됨」. 개선을 주장하지 않는다
    new 가 나빠졌다          → 기각
    new 에서 교차 귀속이 줄고
    정본 복장이 유지된다      → C-1 acceptance

usage:
  carried_name_ab.py              (기본 dry — preflight 만)
  carried_name_ab.py --run        (arm 당 4장, 합 8장 생성)
"""
from __future__ import annotations

import difflib
import hashlib
import json
import pathlib
import sys
from typing import Any, Dict, List, Tuple

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import _refs  # noqa: E402

ROOT = pathlib.Path(__file__).resolve().parents[3]
PROJ = "dd5dd235-5850-4157-9b90-a733760c5ae8"
EPI = "0b36f6db-9884-4206-8fb4-ff11cc3c2392"
TAG = "S2sh6"
SNAP = ROOT / "artifact" / "20260829_combined_jit_acceptance" / "recipe_after"
CONT = (ROOT / "projects" / PROJ / "checkpoints" / "episodes" / EPI
        / "shot_continuity" / "manifest.json")
OUT = ROOT / "artifact" / "20260829_carried_name_ab"
REPEATS = 2          # arm 당 반복 (seed 가 없어서)


def _both_carried_texts() -> Tuple[str, str, Dict[str, Any]]:
    """같은 행에서 **결속 전/후** 두 문장을 만든다.

    ★프로덕션 함수(`carried_text_for_shot`)를 그대로 쓴다. old 는
     `_named_state` 를 항등으로 바꿔 **종전 코드와 같은 것**을 낸다 —
     문장을 손으로 다시 쓰지 않는다(그러면 무엇을 재는지 달라진다).
    """
    import itertools
    from unittest import mock

    import app.modules.pipeline.shot_continuity_author as sca

    row = json.loads(CONT.read_text())["data"]["carried"][TAG]
    rec = json.loads((SNAP / "records.json").read_text())[TAG]
    prompt = list((rec.get("roll_prompts") or {}).values())[0]
    sids = [str(p.get("character_short_id"))
            for p in (row.get("people") or [])]

    # ★그 샷의 `visible_short_ids` 를 **짐작하지 않는다.** 후보 부분집합을
    #  전부 만들어, 저장된 프롬프트를 **글자 그대로 재현하는 것 하나**만
    #  고른다. 하나도 없거나 둘 이상이면 멈춘다 — 재현으로 확정하지 못한
    #  값을 실험의 전제로 쓰지 않는다.
    hits = []
    for r in range(len(sids) + 1):
        for combo in itertools.combinations(sids, r):
            with mock.patch.object(sca, "_named_state", lambda _p, s: s):
                cand = sca.carried_text_for_shot(
                    row, visible_short_ids=set(combo), bg_only=False)
            if cand and cand in prompt:
                hits.append((set(combo), cand))
    # 가장 많은 사람을 담으면서 재현되는 것 = 실제로 나간 집합
    hits.sort(key=lambda h: -len(h[0]))
    if not hits:
        raise SystemExit("★어떤 부분집합으로도 저장 프롬프트를 재현 못 했다")
    best_len = len(hits[0][0])
    top = [h for h in hits if len(h[0]) == best_len]
    if len(top) != 1:
        raise SystemExit(f"★재현되는 집합이 여럿이다: {[h[0] for h in top]}")
    vis, old = top[0]
    print(f"  ✓ visible_short_ids 를 **재현으로 확정**: {sorted(vis)} "
          f"(후보 {len(sids)}명 중)")
    new = sca.carried_text_for_shot(row, visible_short_ids=vis, bg_only=False)
    return old, new, rec


def _preflight() -> Tuple[str, str, List[Tuple[str, pathlib.Path]], Dict]:
    old_c, new_c, rec = _both_carried_texts()
    prompt_old = list((rec.get("roll_prompts") or {}).values())[0]

    print("═══ preflight — 「이름 접두 말고는 안 다르다」를 증명한다 ═══")
    # ① 저장된 프롬프트가 **old 문장을 글자 그대로** 담고 있어야 한다.
    #    아니면 내가 만든 old 가 그때 나간 것과 다르다는 뜻이다.
    if old_c not in prompt_old:
        raise SystemExit(
            "★저장된 프롬프트에 old CARRIED 문장이 그대로 없다 — 내가 만든\n"
            "  old 가 그때 나간 것과 다르다. 여기서 멈춘다.\n"
            f"  만든 것: {old_c[:120]}…")
    print(f"  ✓ 저장 프롬프트가 old 문장을 그대로 담는다 ({len(old_c):,}자)")

    prompt_new = prompt_old.replace(old_c, new_c)
    if prompt_new == prompt_old:
        raise SystemExit("★new 가 old 와 같다 — 결속이 안 걸렸다")

    # ② 두 프롬프트의 차이가 **삽입뿐**인지 본다. 지운 것이 하나라도
    #    있으면 이 실험은 「이름 결속」이 아닌 다른 것을 재는 것이다.
    sm = difflib.SequenceMatcher(None, prompt_old, prompt_new, autojunk=False)
    ins, dele, repl = [], [], []
    for op, i1, i2, j1, j2 in sm.get_opcodes():
        if op == "insert":
            ins.append(prompt_new[j1:j2])
        elif op == "delete":
            dele.append(prompt_old[i1:i2])
        elif op == "replace":
            repl.append((prompt_old[i1:i2], prompt_new[j1:j2]))
    if dele or repl:
        raise SystemExit(
            f"★삽입 말고 다른 변경이 있다 — 지움 {dele!r} / 바꿈 {repl!r}")
    print(f"  ✓ 차이는 **삽입뿐** — {len(ins)}곳: {ins!r}")
    print(f"  ✓ 길이 {len(prompt_old):,} → {len(prompt_new):,}자 "
          f"(+{len(prompt_new) - len(prompt_old)})")

    # ③ 참조는 **사본에서** 푼다 — 바이트를 못 박는다
    refs = _refs.labeled_refs(rec.get("refs") or [], snapshot_recipe=SNAP)
    print(f"  ✓ 참조 {len(refs)}장 (사본 고정)")
    for lab, p in refs:
        print(f"      {p.name:22s} sha={hashlib.sha256(p.read_bytes()).hexdigest()[:12]}")

    # ④ 모델·롤 수
    from app.core.config import settings
    print(f"  ✓ backend={getattr(settings, 'still_image_backend', 'nb2')} · "
          f"roll_count={settings.still_recipe_roll_count} · "
          f"arm 당 {REPEATS}회 × 롤 2 = 4장 · 합 8장")
    print(f"  ✓ raw roll 만 — fix/regenerate·cine 은 안 탄다")
    return prompt_old, prompt_new, refs, rec


def main() -> int:
    dry = "--run" not in sys.argv
    prompt_old, prompt_new, refs, rec = _preflight()
    if dry:
        print("\n기본이 dry — 실제로 구우려면 `--run` "
              "(유료: 8장)")
        return 0

    # ★provider 를 만들기 **전에** 자리를 잡는다 — 기존 표본을 조용히 덮지
    #  않는다 (2026-08-29 Codex BLOCK). 여기서 서면 유료 호출은 0이다.
    from _refs import claim_output_dir, run_id_from_argv

    expected = [f"{arm}_r{rep}_{label}.png"
                for arm in ("old", "new")
                for rep in range(1, REPEATS + 1)
                for label in ("a", "b")]
    out_dir = claim_output_dir(OUT, expected, run_id=run_id_from_argv(sys.argv))

    from app.core.config import settings
    from app.modules.llm.gemini_image_client import GeminiImageClient
    from app.modules.pipeline.multiroll_gemini import make_nb2_gen_fn

    if getattr(settings, "still_image_backend", "nb2") == "grok2":
        from app.modules.llm.grok_image_client import GrokImageClient
        client: Any = GrokImageClient()
    else:
        client = GeminiImageClient()
    gen = make_nb2_gen_fn(
        project_id=PROJ, episode_id=EPI,
        operation_type="still_recipe_roll",     # 프로덕션과 같은 태그
        gemini_client=client,
        context_extra={"still_id": f"{TAG}_carried_name_ab"},
    )

    rows: List[Dict[str, Any]] = []
    for arm, prompt in (("old", prompt_old), ("new", prompt_new)):
        for rep in range(1, REPEATS + 1):
            for label in ("a", "b"):
                name = f"{arm}_r{rep}_{label}.png"
                out = out_dir / name
                print(f"  굽는다 {name} …", flush=True)
                try:
                    gen(f"{TAG}_{arm}_r{rep}", prompt, list(refs), out)
                    ok, err = out.is_file(), None
                except Exception as exc:              # noqa: BLE001
                    ok, err = False, f"{exc!r}"[:200]
                rows.append({"arm": arm, "rep": rep, "label": label,
                             "file": name, "ok": ok, "error": err,
                             "sha": (hashlib.sha256(out.read_bytes())
                                     .hexdigest()[:16] if ok else None)})
                print(f"     {'✓' if ok else '★ ' + str(err)}")
    (out_dir / "runs.json").write_text(
        json.dumps({"tag": TAG, "repeats": REPEATS,
                    "prompt_old_len": len(prompt_old),
                    "prompt_new_len": len(prompt_new),
                    "refs": [p.name for _l, p in refs],
                    "rows": rows}, ensure_ascii=False, indent=1))
    n_ok = sum(1 for r in rows if r["ok"])
    print(f"\n{n_ok}/{len(rows)}장 성공 → {out_dir}")
    print("★다음: 순서를 가린 갤러리로 **복장 귀속만** 센다.")
    return 0 if n_ok == len(rows) else 1


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