#!/usr/bin/env python3
"""C-1 A/B **재실험** — 실제로 결함이 난 `regenerate` 경로에서 (2026-08-29).

## 왜 다시 하나

앞 판(`carried_name_ab.py`)은 **raw roll** 에서 쟀는데, 그 자리는 결함이
나는 곳이 아니었다 — `S2sh6_sel.png`(raw 승자)는 이미 민수가 남색이었고
회색 코트는 `S2sh6_fix.png`(**재생성본**)에서만 났다. 그래서 old 가 4/4
로 맞은 것은 당연했고 C-1 효과를 잴 수 없는 조건이었다.
★그 결과를 **폐기하지 않는다** — 「raw 단계 비회귀 4/4 대 4/4」로 남긴다.

## 이 판이 재는 것 (Codex 확정)

효과를 억지로 찾으려는 것이 아니다. **실제로 결함이 난 붐비는 regen
경로에서 이 8자 변경이 새 부작용을 만들지 않는지**를 병합 전에 본다.

## 동결하는 것

    · combined JIT 때 실제로 쓴 **`regen_prompt` 바이트 그대로**
      (critique 를 다시 돌려 새 faults 를 만들지 **않는다**)
    · 그때 재생성에 들어간 **전체 refs 를 순서·SHA 그대로**
      ★원본 selected 이미지는 참조로 안 넣는다 — 프로덕션 재생성도
       `labeled_refs` 만 넘겼다(`multiroll_select.py:1025`)
    · 모델·backend 동일

## 바꾸는 것

`CARRIED STATE` 안의 `노인: `·`민수: ` **두 삽입뿐.** diff 가 삽입 2곳
말고 하나라도 있으면 멈춘다.

## 안 태우는 것

critique 재호출 · fix_rejudge · cine. **direct regenerate 산출까지만.**

## 판정

복장 세 항목뿐 — 민수=남색 작업복 / 노인=회색 코트 / 교차 귀속.
거짓 방향 지적과 프레이밍은 **양 arm 에 같은 고정 조건**이지 판정축이
아니다. 옛 `S2sh6_fix.png` 는 **알려진 실패 sentinel** 로 따로 전시하되
fresh 8장 통계에 **안 넣는다**.

usage:
  carried_name_regen_ab.py          (기본 dry — preflight 만)
  carried_name_regen_ab.py --run    (fresh old 4 + new 4 = 8장)
"""
from __future__ import annotations

import difflib
import hashlib
import json
import pathlib
import sys
from concurrent.futures import ThreadPoolExecutor
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"
EXP = "carried_name_regen_ab"
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_regen_ab"
PER_ARM = 4


def _carried_pair(prompt: str) -> Tuple[str, str]:
    """저장된 regen 프롬프트를 재현하는 old CARRIED 문장 + 결속본.

    ★`visible_short_ids` 를 **짐작하지 않는다** — 부분집합 중 저장
     프롬프트를 글자 그대로 재현하는 것 하나만 고른다(앞 판과 같은 통제).
    """
    import itertools
    from unittest import mock

    import app.modules.pipeline.shot_continuity_author as sca

    row = json.loads(CONT.read_text())["data"]["carried"][TAG]
    sids = [str(p.get("character_short_id"))
            for p in (row.get("people") or [])]
    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("★어떤 부분집합으로도 regen 프롬프트를 재현 못 했다")
    top = [h for h in hits if len(h[0]) == len(hits[0][0])]
    if len(top) != 1:
        raise SystemExit(f"★재현되는 집합이 여럿이다: {[h[0] for h in top]}")
    vis, old = top[0]
    print(f"  ✓ visible_short_ids 를 **재현으로 확정**: {sorted(vis)}")
    new = sca.carried_text_for_shot(row, visible_short_ids=vis, bg_only=False)
    return old, new


def _preflight():
    rec = json.loads((SNAP / "records.json").read_text())[TAG]
    regen = rec.get("regen_prompt") or ""
    if not regen.strip():
        raise SystemExit("★`regen_prompt` 가 비었다 — 동결할 것이 없다")

    print("═══ preflight — regen 프롬프트를 동결하고 삽입만 확인 ═══")
    print(f"  ✓ 실제 주행의 regen_prompt 를 그대로 쓴다 ({len(regen):,}자)")
    print(f"  ✓ critique 재호출 없음 — FAULTS "
          f"{rec.get('regen_issue_count')}건 동결")
    old_c, new_c = _carried_pair(regen)

    prompt_new = regen.replace(old_c, new_c)
    sm = difflib.SequenceMatcher(None, regen, prompt_new, autojunk=False)
    ins, bad = [], []
    for op, i1, i2, j1, j2 in sm.get_opcodes():
        if op == "insert":
            ins.append(prompt_new[j1:j2])
        elif op in ("delete", "replace"):
            bad.append((op, regen[i1:i2], prompt_new[j1:j2]))
    if bad:
        raise SystemExit(f"★삽입 말고 다른 변경이 있다: {bad!r}")
    if not ins:
        raise SystemExit("★삽입이 하나도 없다 — 결속이 안 걸렸다")
    print(f"  ✓ 차이는 **삽입뿐** — {len(ins)}곳: {ins!r}")
    print(f"  ✓ 길이 {len(regen):,} → {len(prompt_new):,}자 "
          f"(+{len(prompt_new) - len(regen)})")

    # ★프로덕션 재생성이 넘긴 것과 같은 refs — 원본 산출은 안 넣는다
    refs = _refs.labeled_refs(rec.get("refs") or [], snapshot_recipe=SNAP)
    print(f"  ✓ 참조 {len(refs)}장 (순서·SHA 고정 · 원본 selected 미포함)")
    for lab, p in refs:
        print(f"      {p.name:24s} "
              f"sha={hashlib.sha256(p.read_bytes()).hexdigest()[:12]}")
    sent = SNAP / f"{TAG}_fix.png"
    print(f"  ✓ 실패 sentinel: {sent.name} "
          f"({'있음' if sent.is_file() else '★없음'}) — 통계엔 **안 넣는다**")

    from app.core.config import settings
    print(f"  ✓ backend={getattr(settings, 'still_image_backend', 'nb2')} · "
          f"arm 당 {PER_ARM}장 · 합 {PER_ARM * 2}장")
    print("  ✓ direct regenerate 까지만 — critique·fix_rejudge·cine 안 탐")
    return regen, prompt_new, refs, rec


def main() -> int:
    dry = "--run" not in sys.argv
    p_old, p_new, refs, rec = _preflight()
    if dry:
        print(f"\n기본이 dry — 실제로 구우려면 `--run` (유료: {PER_ARM * 2}장)")
        return 0

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

    expected = [f"{arm}_{i}.png"
                for arm in ("old", "new")
                for i in range(1, PER_ARM + 1)]
    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}_{EXP}"},   # probe 제외 키
    )
    jobs = [(arm, i, p_old if arm == "old" else p_new)
            for arm in ("old", "new") for i in range(1, PER_ARM + 1)]

    def one(job):
        arm, i, prompt = job
        name = f"{arm}_{i}.png"
        out = out_dir / name
        try:
            gen(f"{TAG}_{EXP}_{arm}{i}", prompt, list(refs), out)
            ok, err = out.is_file(), None
        except Exception as exc:                     # noqa: BLE001
            ok, err = False, f"{exc!r}"[:200]
        print(f"  {name} {'✓' if ok else '★ ' + str(err)}", flush=True)
        return {"arm": arm, "n": i, "file": name, "ok": ok, "error": err,
                "sha": (hashlib.sha256(out.read_bytes()).hexdigest()[:16]
                        if ok else None)}

    with ThreadPoolExecutor(max_workers=4) as ex:
        rows: List[Dict[str, Any]] = list(ex.map(one, jobs))

    (out_dir / "runs.json").write_text(json.dumps({
        "experiment_id": EXP, "tag": TAG, "per_arm": PER_ARM,
        "note": ("실제 주행의 regen_prompt 동결 · critique 재호출 없음 · "
                 "direct regenerate 까지만"),
        "prompt_old_len": len(p_old), "prompt_new_len": len(p_new),
        "refs": [p.name for _l, p in refs],
        "sentinel": f"{TAG}_fix.png (옛 실패본 — 통계 제외)",
        "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}")
    return 0 if n_ok == len(rows) else 1


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