"""게이트 **2단계** — 못 쓸 산출을 다시 산다 (2026-09-20).

1단계는 「못 쓴다」고 **분류하고 멈추는** 것까지였다. 실측으로 이 화
43샷은 **무료 재선택이 하나도 안 됐다**(둘 다 못 쓰는 것) — 그래서 여기서
처음으로 **새 후보를 산다**.

## 이 시험이 잠그는 것 (Codex 2단계 판단)

    · 상한은 **같은 입력·정책의 canonical 샷당 누적 1회** — 재개까지 합산
    · **발송 전에** 시도를 남긴다. 결과 불명이면 **새 1회를 안 준다**
    · 재판정 배선이 없으면 **사기 전에** 멈춘다(돈 쓰고 판정 못 하면 버린 돈)
    · 재롤 뒤에도 허용 후보가 없으면 **`unresolved` 유지** — least-bad 금지
    · 허용 집합 **안에서만** 순위 비교(허용이 점수보다 먼저)
    · 재판정만 실패한 재개는 **같은 재롤 파일을 다시 판정**한다(새 이미지 0)
    · 주행 단위 승인 상한
"""
from __future__ import annotations

import copy
from pathlib import Path
from typing import Any, Dict, List

import pytest

from app.modules.pipeline.multiroll_select import (
    GATE_CLEAN,
    GATE_INCOMPLETE,
    GATE_RESELECTED,
    GATE_RESOLVED,
    GATE_UNRESOLVED,
    gate_pick_within_admissible,
    gate_reroll_ready,
    gate_winner_violations,
    reroll_attempt_matches,
    roll_labels,
    run_multiroll_select,
)

_MISSING = object()

L2 = roll_labels(2)          # A, B  — 초기 후보
L3 = roll_labels(3)          # A, B, C — C 가 재롤 후보
RR = L3[-1]


# ── 함수 단위 ──────────────────────────────────────────────────────

def test_the_prescription_is_the_judges_own_words():
    """★판정기가 쓴 말을 **그대로** 되돌려 준다 — 글자에서 뜻을 캐지 않는다."""
    jr = {"readings": [{"label": "A", "hard_violations": ["팔이 셋", "떠 있다"]},
                       {"label": "B", "hard_violations": []}]}
    assert gate_winner_violations(jr, "A") == ["팔이 셋", "떠 있다"]
    assert gate_winner_violations(jr, "B") == []
    assert gate_winner_violations({}, "A") == []


def test_admissible_comes_before_score():
    """★허용이 **점수보다 먼저**다 — 위반이 달린 1위를 확정하지 않는다."""
    jr = {"readings": [{"label": "A", "hard_violations": ["x"]},
                       {"label": "B", "hard_violations": []}]}
    pick, adm, unchecked = gate_pick_within_admissible(jr, L2, ["A", "B"])
    assert pick == "B" and adm == ["B"]


def test_nothing_admissible_returns_none():
    """하나도 허용 안 되면 **아무것도 안 고른다** — least-bad 금지."""
    jr = {"readings": [{"label": l, "hard_violations": ["x"]} for l in L2]}
    pick, adm, _ = gate_pick_within_admissible(jr, L2, ["A", "B"])
    assert pick is None and adm == []


def test_the_rejudge_wiring_is_checked_before_paying():
    """★**사기 전에** 본다 — 돈 쓰고 판정 못 하면 그 돈은 버린 것이다."""
    _t = _regen_texts()
    assert gate_reroll_ready(None, _t)[0] is False

    def _plain(*a, **k):
        return {}
    assert gate_reroll_ready(_plain, _t)[0] is False, "선언 없는 콜백을 통과"

    _plain.emits_readings = True
    assert gate_reroll_ready(_plain, _t)[0] is False, "cross-model 이 아닌데 통과"
    _plain.owns_order = True
    assert gate_reroll_ready(_plain, _t) == (True, "")

    # ★★**스틸 문안이 없으면 안 산다** — 비우고 부르면 `build_regen_prompt`
    #  가 구조물용 기본값으로 떨어져 「장소 사진 … 건물 **층수**」가 나간다.
    for bad in (None, {}, {"regen_head": "h"},
                {"regen_head": "h", "regen_tail": "t"}):
        ok, why = gate_reroll_ready(_plain, bad)
        assert ok is False and "문안" in why, f"빈 문안을 통과시켰다: {bad}"


def test_one_attempt_per_input_and_policy():
    """★「샷당 1회」는 **같은 입력·정책** 기준 — 재개까지 합산한다."""
    prior = {"policy": "p1", "for_fingerprint": "fp1"}
    assert reroll_attempt_matches(prior, policy="p1", fingerprint="fp1")
    # 입력이 바뀌면 다른 물음이다 — 새 1회
    assert not reroll_attempt_matches(prior, policy="p1", fingerprint="fp2")
    assert not reroll_attempt_matches(prior, policy="p2", fingerprint="fp1")
    assert not reroll_attempt_matches(None, policy="p1", fingerprint="fp1")


# ── 실제 루프 ──────────────────────────────────────────────────────

class _Gen:
    def __init__(self):
        self.calls: List[str] = []

    def __call__(self, tag, prompt, labeled_refs, out_path: Path):
        self.calls.append(out_path.stem.rsplit("_", 1)[-1])
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(f"img:{out_path.stem}".encode())
        return out_path


def _judge(violations: Dict[str, List[str]], *, ranking=None):
    calls: List[Any] = []

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls.append(list(labels))
        rank = list(ranking or labels)
        return {
            "winner": rank[0], "ranking": rank,
            "verdicts": [{"label": l,
                          "score": 10 - rank.index(l),
                          "verdict_ko": "ok"} for l in labels],
            "readings": [{"label": l, "hard_violations": violations.get(l, [])}
                         for l in labels],
        }

    judge_fn.owns_order = True
    judge_fn.emits_readings = True
    judge_fn.calls = calls
    return judge_fn


def _regen_texts():
    """★**프로덕션이 싣는 그 팩**을 그대로 쓴다.

    대역이 빈 문안을 보내면 `build_regen_prompt` 가 **구조물용 기본값**
    으로 떨어진다 — 그 상태로 시험이 통과하면 「팔 결함을 건물 층수로
    설명하고 사는」 경로를 정답으로 잠그는 것이다(Codex BLOCK 3).
    """
    from app.modules.pipeline.multiroll_gemini import (
        resolve_still_regen_texts,
    )

    return resolve_still_regen_texts()


def _run(tmp_path, *, record, gen, judge, rejudge, saved,
         allow=None, reroll=True, regen_texts=_MISSING):
    (tmp_path / "ref.png").write_bytes(b"ref")
    return run_multiroll_select(
        tag="t1", prompt="PROMPT",
        labeled_refs=[("REF", tmp_path / "ref.png")],
        out_stem=tmp_path / "out" / "s1",
        gen_fn=gen, judge_fn=judge, critique_fn=None, fix_gen_fn=None,
        roll_count=2, critique_enabled=False,
        fix_head="H", fix_tail="T", fix_label="L",
        record=record, winner_gate_applicable=True,
        gate_reroll_enabled=reroll, gate_rejudge_fn=rejudge,
        gate_reroll_allow_fn=allow,
        gate_regen_texts=(_regen_texts() if regen_texts is _MISSING
                          else regen_texts),
        gate_rejudge_identity={"model_physical": "m", "policy": "p"},
        # ★★**깊은 복사로 받는다.** 그냥 `saved.append` 면 같은 dict 를
        #  가리켜서, 「영속했나」가 아니라 「지금 메모리가 어떤가」를 보게
        #  된다 — 실제로 그 대역이 「발송 전에 남겼나」 시험을 통과시켰다
        #  (영속을 뒤로 옮겨도 빨간불이 안 떴다).
        persist_record_fn=lambda r: saved.append(copy.deepcopy(r)))


def test_a_bought_reroll_that_wins_becomes_the_final(tmp_path):
    """★재롤본이 허용되면 **그것이 최종**이다 — 종착 `resolved`."""
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})              # 둘 다 못 쓴다
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    sel, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                    rejudge=rejudge, saved=[])

    assert gen.calls == ["a", "b", RR.lower()], "재롤을 **한 장만** 산다"
    assert len(rejudge.calls) == 1 and rejudge.calls[0] == L3
    assert rec["gate"]["outcome"] == GATE_RESOLVED
    assert rec["selected"] == RR
    assert sel.read_bytes() == (
        tmp_path / "out" / f"s1_{RR.lower()}.png").read_bytes()
    assert rec["gate_reroll"]["state"] == "judged"
    assert rec["gate_reroll"]["sha256"], "무엇을 샀는지 sha 가 없다"


def test_a_reroll_that_loses_still_records_the_purchase(tmp_path):
    """★재롤이 져도 **구매는 기록한다** — 산 것을 없던 일로 하지 않는다.

    그리고 최종은 허용되는 **초기 후보**다(종착 `reselected`).
    """
    gen = _Gen()
    judge = _judge({"A": ["x"], "B": ["y"]})
    rejudge = _judge({"A": ["x"], "B": [], RR: ["z"]},
                     ranking=[RR, "A", "B"])
    sel, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                    rejudge=rejudge, saved=[])

    assert RR.lower() in gen.calls, "재롤을 안 샀다"
    assert rec["gate"]["outcome"] == GATE_RESELECTED
    assert rec["selected"] == "B"
    assert rec["gate_reroll"]["adopted"] == "B"
    assert rec["gate_reroll"]["sha256"], "진 후보의 구매 기록이 없다"
    assert sel.read_bytes() == (tmp_path / "out" / "s1_b.png").read_bytes()


def test_no_admissible_after_the_reroll_stays_unresolved(tmp_path):
    """★★재롤 뒤에도 허용 후보가 없으면 **`unresolved` 유지**.

    least-bad 를 정상본으로 내보내지 않는다 — 그게 지금의 병이다.
    """
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: ["x"] for l in L3})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=[])

    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert rec["gate"]["reroll"] == "no_admissible"
    assert rec["gate_reroll"]["adopted"] is None


def test_a_clean_shot_never_buys_a_reroll(tmp_path):
    """정상 샷은 **종전 그대로** — 재롤 배선이 멀쩡한 샷을 건드리지 않는다."""
    gen = _Gen()
    judge = _judge({})
    rejudge = _judge({})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=[])
    assert gen.calls == ["a", "b"], "정상 샷에 재롤을 샀다"
    assert rejudge.calls == [], "정상 샷을 다시 판정했다"
    assert rec["gate"]["outcome"] == GATE_CLEAN
    assert "gate_reroll" not in rec


def test_the_lever_off_keeps_stage_one(tmp_path):
    """★레버가 꺼지면 **1단계 그대로** — 멈추기만 하고 안 산다."""
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=[], reroll=False)
    assert gen.calls == ["a", "b"]
    assert rejudge.calls == []
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert "gate_reroll" not in rec


# ── 돈 — 두 번 사지 않는다 ─────────────────────────────────────────

def test_a_rejudge_only_resume_buys_no_new_image(tmp_path):
    """★★재판정만 실패한 재개는 **같은 재롤 파일을 다시 판정**한다.

    새 이미지를 사지 않는다 — 이미 산 그림이 디스크에 있다.
    """
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})

    class _Boom:
        owns_order = True
        emits_readings = True
        calls: List[Any] = []

        def __call__(self, *a, **k):
            raise RuntimeError("재판정 실패")

    # ★`run_multiroll_select` 는 넘긴 record 를 **복사**한다 — 중간 상태는
    #  영속 콜백으로 본다(프로덕션도 그 콜백으로 재개 기록을 만든다).
    saved: List[Dict[str, Any]] = []
    with pytest.raises(RuntimeError):
        _run(tmp_path, record=None, gen=gen, judge=judge,
             rejudge=_Boom(), saved=saved)
    assert RR.lower() in gen.calls, "재롤을 안 샀다"
    rec = dict(saved[-1])
    assert rec["gate_reroll"]["state"] == "produced"
    bought = (tmp_path / "out" / f"s1_{RR.lower()}.png").read_bytes()

    # ── 재개: 같은 입력·같은 정책. 그림은 그대로 있다.
    gen2 = _Gen()
    judge2 = _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({l: (["x"] if l in L2 else []) for l in L3},
                      ranking=[RR, "A", "B"])
    sel2, rec2 = _run(tmp_path, record=dict(rec), gen=gen2, judge=judge2,
                      rejudge=rejudge2, saved=saved)

    assert gen2.calls == [], "재개가 **새 이미지를 샀다**"
    assert rec2["gate"]["outcome"] == GATE_RESOLVED
    assert sel2.read_bytes() == bought, "산 그림이 아닌 것을 최종으로 썼다"


def test_an_unknown_send_does_not_get_a_second_purchase(tmp_path):
    """★★**발송 결과가 불명이면 새 1회를 주지 않는다** (Codex).

    발송 기록은 있는데 파일이 없다 = 돈이 나갔는지 모른다. 자동으로 다시
    사면 같은 그림을 두 번 사는 길이 열린다.
    """
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=[])
    assert rec["gate"]["outcome"] == GATE_RESOLVED

    # 재롤 파일만 사라졌다(발송했는지 못 받았는지 모르는 상태 모사)
    (tmp_path / "out" / f"s1_{RR.lower()}.png").unlink()
    (tmp_path / "out" / "s1_sel.png").unlink()
    rec2 = dict(rec)
    rec2["gate"] = {"outcome": GATE_UNRESOLVED,
                    "policy": rec["gate"]["policy"],
                    "initial_selected": "A"}
    rec2.pop("critique_skipped", None)

    gen2 = _Gen()
    judge2 = _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({l: [] for l in L3})
    _, rec3 = _run(tmp_path, record=rec2, gen=gen2, judge=judge2,
                   rejudge=rejudge2, saved=[])

    assert RR.lower() not in gen2.calls, "결과 불명인데 **또 샀다**"
    assert rejudge2.calls == [], "사지도 않고 재판정만 했다"
    assert rec3["gate"]["outcome"] == GATE_UNRESOLVED
    assert "불명" in rec3["gate"]["reroll_skipped"]


def test_a_run_level_cap_stops_the_purchase(tmp_path):
    """★주행 단위 승인 상한 — 샷당 1회와 **다른 축**이다."""
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: [] for l in L3})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=[], allow=lambda: False)

    assert RR.lower() not in gen.calls, "상한에 닿았는데 샀다"
    assert rejudge.calls == []
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert "상한" in rec["gate"]["reroll_skipped"]


def test_a_broken_rejudge_wiring_stops_before_paying(tmp_path):
    """★★**사기 전에** 막는다 — 판정 못 할 그림을 사지 않는다."""
    for bad in (None, object()):
        gen = _Gen()
        judge = _judge({l: ["x"] for l in L2})
        _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                      rejudge=bad, saved=[])
        assert RR.lower() not in gen.calls, "판정 배선이 없는데 샀다"
        assert rec["gate"]["outcome"] == GATE_UNRESOLVED
        assert rec["gate"]["reroll_skipped"]


def test_the_attempt_is_recorded_before_the_image_is_sent(tmp_path):
    """★★**발송 전에** 시도를 남긴다 — 여기서 죽으면 새 1회를 안 준다."""
    seen: List[Any] = []
    saved: List[Dict[str, Any]] = []

    class _DyingGen(_Gen):
        def __call__(self, tag, prompt, labeled_refs, out_path: Path):
            if out_path.stem.endswith(RR.lower()):
                # ★**그림을 보내기 직전**에 영속 기록을 들여다본다.
                seen.append(dict(saved[-1].get("gate_reroll") or {}))
                raise RuntimeError("발송 중 죽었다")
            return super().__call__(tag, prompt, labeled_refs, out_path)

    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: [] for l in L3})
    with pytest.raises(RuntimeError):
        _run(tmp_path, record=None, gen=_DyingGen(), judge=judge,
             rejudge=rejudge, saved=saved)

    assert seen and seen[0]["state"] == "sent", (
        "그림을 보내기 전에 시도를 안 남겼다")
    assert seen[0]["attempt_id"], "시도 식별자가 없다"
    assert saved[-1]["gate_reroll"]["state"] == "sent"


def test_the_finished_reroll_is_not_re_judged_by_the_cached_path(tmp_path):
    """★★재롤이 끝난 기록을 **옛 판정으로 덮어쓰지 않는다**.

    소급이 쓰는 근거는 `record["readings"]` = **초기 판정**이다. 다시 보면
    재롤 결과를 옛 판정으로 덮어써 **산 그림을 버리게 된다**.
    """
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    saved: List[Any] = []
    sel, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                    rejudge=rejudge, saved=saved)
    assert rec["gate"]["outcome"] == GATE_RESOLVED
    n = len(saved)

    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    sel2, rec2 = _run(tmp_path, record=dict(rec), gen=gen2, judge=judge2,
                      rejudge=_judge({}), saved=saved)
    assert gen2.calls == [] and judge2.calls == []
    assert rec2["gate"]["outcome"] == GATE_RESOLVED, (
        "옛 판정이 재롤 결과를 덮어썼다 — 산 그림을 버린다")
    assert sel2.read_bytes() == sel.read_bytes()
    assert len(saved) == n, "캐시 방문이 기록을 다시 썼다"

    # ★★**정책이 올라가도** 마찬가지다 (여기가 진짜 위험한 자리).
    #  위까지는 근거가 같아 소급이 아예 안 돈다. 정책 버전이 바뀌면
    #  소급이 **다시 평가**하는데, 그때 쓰는 근거는 `record["readings"]`
    #  = **초기 판정**이라 「둘 다 못 쓴다」다. 막지 않으면 재롤 결과를
    #  그 옛 판정으로 덮어써 **산 그림을 버린다**.
    _bumped = dict(rec2)
    _bumped["gate"] = {**rec2["gate"], "policy": "옛-정책",
                       "evidence": {"옛": "근거"}}
    gen3, judge3 = _Gen(), _judge({l: ["x"] for l in L2})
    sel3, rec3 = _run(tmp_path, record=_bumped, gen=gen3, judge=judge3,
                      rejudge=_judge({}), saved=saved)
    assert gen3.calls == [] and judge3.calls == []
    assert rec3["gate"]["outcome"] == GATE_RESOLVED, (
        "정책이 올라가자 옛 판정이 재롤 결과를 덮어썼다 — 산 그림을 버린다")
    assert rec3["selected"] == RR
    assert sel3.read_bytes() == sel.read_bytes()


# ── 재개가 재롤을 **이어서** 한다 (Codex BLOCK 2) ──────────────────

def test_a_cap_refusal_is_not_a_dead_end(tmp_path):
    """★★상한에 걸린 샷이 **다음 방문에 다시 올 수 있어야** 한다.

    새 판정 자리에만 재롤을 걸면, 상한에 걸린 샷은 `_sel`+`critique_skipped`
    가 저장돼 다음 방문에 **캐시 갈래로 먼저 나간다** — 승인 여유가 생겨도
    영영 못 산다.
    """
    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=_judge({}), saved=saved, allow=lambda: False)
    assert RR.lower() not in gen.calls
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert "gate_reroll" not in rec, "상한 거절이 시도로 남았다(종착이 아니다)"

    # ── 다음 방문: 승인 여유가 생겼다. 그림·지문은 그대로.
    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({l: (["x"] if l in L2 else []) for l in L3},
                      ranking=[RR, "A", "B"])
    sel2, rec2 = _run(tmp_path, record=dict(rec), gen=gen2, judge=judge2,
                      rejudge=rejudge2, saved=saved, allow=lambda: True)

    assert judge2.calls == [], "초기 판정을 **다시 샀다**"
    assert gen2.calls == [RR.lower()], "재롤 한 장만 사야 한다"
    assert rec2["gate"]["outcome"] == GATE_RESOLVED
    assert sel2.read_bytes() == (
        tmp_path / "out" / f"s1_{RR.lower()}.png").read_bytes()


def test_a_rejudge_failure_resume_does_not_rebuy_the_initial_judge(tmp_path):
    """★★재개가 **초기 판정도 안 산다** (Codex BLOCK 2b).

    종전에는 `_sel` 이 없어서 재개가 초기 A/B 판정을 다시 사고 나서야
    A/B/C 재판정을 샀다 — cross-model 이면 VLM 이 2+2 다.
    """
    class _Boom:
        owns_order = True
        emits_readings = True

        def __call__(self, *a, **k):
            raise RuntimeError("재판정 실패")

    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    with pytest.raises(RuntimeError):
        _run(tmp_path, record=None, gen=gen, judge=judge,
             rejudge=_Boom(), saved=saved)
    rec = dict(saved[-1])
    assert rec["gate_reroll"]["state"] == "produced"
    bought = (tmp_path / "out" / f"s1_{RR.lower()}.png").read_bytes()
    assert (tmp_path / "out" / "s1_sel.png").exists(), (
        "선정본이 없어 재개가 초기 판정부터 다시 사게 된다")

    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({l: (["x"] if l in L2 else []) for l in L3},
                      ranking=[RR, "A", "B"])
    sel2, rec2 = _run(tmp_path, record=rec, gen=gen2, judge=judge2,
                      rejudge=rejudge2, saved=saved)

    assert gen2.calls == [], "재개가 새 이미지를 샀다"
    assert judge2.calls == [], "재개가 **초기 판정을 다시 샀다**"
    assert rec2["gate"]["outcome"] == GATE_RESOLVED
    assert sel2.read_bytes() == bought


# ── 문안·계보가 소비자에 닿는가 (Codex BLOCK 3·4) ──────────────────

def test_the_reroll_prompt_is_a_still_prompt_not_a_building_one(tmp_path):
    """★★**팔 결함을 건물 층수 결함으로 설명하고 사지 않는다**.

    master 가 꺼져 있으면 재생성 문안이 안 해석된다. 그 상태로 재롤이
    `build_regen_prompt` 를 부르면 **구조물용 기본값**이 내려간다.
    """
    sent: List[str] = []

    class _Watch(_Gen):
        def __call__(self, tag, prompt, labeled_refs, out_path: Path):
            if out_path.stem.endswith(RR.lower()):
                sent.append(prompt)
            return super().__call__(tag, prompt, labeled_refs, out_path)

    judge = _judge({"A": ["팔이 셋이다"], "B": ["떠 있다"]})
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    _, rec = _run(tmp_path, record=None, gen=_Watch(), judge=judge,
                  rejudge=rejudge, saved=[])

    assert sent, "재롤을 안 샀다"
    body = sent[0]
    # ★구조물 문안이 섞이면 안 된다
    for bad in ("storey count", "massing", "footprint",
                "location photograph"):
        assert bad not in body, f"구조물 문안이 나갔다: {bad}"
    # ★판정기가 쓴 말이 **그대로** 실린다
    assert "팔이 셋이다" in body
    # ★실제로 나간 전문이 기록에 남는다 — 하류가 읽는 자리에도
    assert rec["gate_reroll"]["prompt"] == body
    assert rec["roll_prompts"][RR] == body, (
        "하류가 읽는 roll_prompts 에 재롤 전문이 없다 — 자산에 엉뚱한 "
        "문안이 저장된다")


def test_a_reroll_without_still_texts_is_not_bought(tmp_path):
    """★문안이 없으면 **안 산다** — 잘못된 문안으로 사느니 안 산다."""
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=_judge({}), saved=[], regen_texts=None)
    assert RR.lower() not in gen.calls
    assert "문안" in rec["gate"]["reroll_skipped"]


# ── 「못 봤다」 ≠ 「못 쓴다」 (Codex BLOCK 5) ───────────────────────

def test_an_unchecked_candidate_is_not_a_disqualification(tmp_path):
    """★★판정문에 그 후보 **행이 없는 것**은 미검사이지 실격이 아니다.

    그대로 `unresolved` 로 닫으면 「C 를 못 봤다」가 「C 까지 검사해 못
    쓴다」가 되고, 소급도 영구히 건너뛰어 **복구할 길이 막힌다**.
    """
    def _partial(tag, prompt, labeled_refs, cand_paths, labels):
        # C 행이 **없다** — 판정기가 그 후보를 안 봤다
        return {
            "winner": "A", "ranking": list(labels),
            "verdicts": [{"label": l, "score": 5, "verdict_ko": "ok"}
                         for l in labels],
            "readings": [{"label": l, "hard_violations": ["x"]} for l in L2],
        }
    _partial.owns_order = True
    _partial.emits_readings = True
    _partial.calls = []

    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    _, rec = _run(tmp_path, record=None, gen=gen, judge=_judge(
        {l: ["x"] for l in L2}), rejudge=_partial, saved=saved)

    assert RR.lower() in gen.calls, "재롤은 샀다"
    assert rec["gate"]["outcome"] == GATE_INCOMPLETE, (
        "못 본 것을 못 쓴다로 확정했다")
    assert rec["gate"]["reroll_unchecked"] == [RR]
    assert "reroll" not in rec["gate"], (
        "reroll 표식이 남으면 소급이 영구히 건너뛰어 복구가 막힌다")

    # ── 재판정만 다시 하면 살아난다. **새 이미지 0**.
    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({l: (["x"] if l in L2 else []) for l in L3},
                      ranking=[RR, "A", "B"])
    sel2, rec2 = _run(tmp_path, record=dict(rec), gen=gen2, judge=judge2,
                      rejudge=rejudge2, saved=saved)
    assert gen2.calls == [] and judge2.calls == []
    assert rec2["gate"]["outcome"] == GATE_RESOLVED


def test_the_master_on_combination_is_refused_before_paying(tmp_path):
    """★아직 지원 안 하는 조합은 **사기 전에** 막는다.

    master ON 에서 C 가 이기면 `_crit_prompt(C)` 가 A/B 뿐인
    `roll_prompts` 에서 KeyError 로 죽는다 — 돈을 쓰고 죽는다.
    """
    (tmp_path / "ref.png").write_bytes(b"ref")
    gen = _Gen()
    saved: List[Dict[str, Any]] = []
    _, rec = run_multiroll_select(
        tag="t1", prompt="PROMPT",
        labeled_refs=[("REF", tmp_path / "ref.png")],
        out_stem=tmp_path / "out" / "s1",
        gen_fn=gen, judge_fn=_judge({l: ["x"] for l in L2}),
        critique_fn=lambda *a, **k: {"issues": []},
        fix_gen_fn=gen,
        roll_count=2, critique_enabled=True,      # ★master ON
        fix_head="H", fix_tail="T", fix_label="L",
        record=None, winner_gate_applicable=True,
        gate_reroll_enabled=True, gate_rejudge_fn=_judge({}),
        gate_regen_texts=_regen_texts(),
        persist_record_fn=lambda r: saved.append(copy.deepcopy(r)))
    assert RR.lower() not in gen.calls, "지원 안 하는 조합인데 샀다"
    assert "critique master" in rec["gate"]["reroll_skipped"]


# ── 확정된 실패는 **다시 판정하지 않는다** (Codex BLOCK 1) ─────────

def test_a_settled_failure_is_not_re_judged_every_visit(tmp_path):
    """★★전부 보고서 전부 실격이면 그것이 **종착**이다.

    매 방문 재판정을 또 사면 ①돈이 나가고 ②주행 상한은 새 구매에서만
    보므로 못 막고 ③재답이 우연히 달라지면 **종착까지 뒤집힌다**.
    """
    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: ["x"] for l in L3})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=rejudge, saved=saved)
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert rec["gate"]["reroll"] == "no_admissible"
    assert rec["gate_reroll"]["state"] == "judged"
    assert len(rejudge.calls) == 1

    # ── 둘째 방문: 아무것도 사지 않는다. 종착도 그대로.
    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    # ★재답이 **우연히 달라져도** 종착이 안 뒤집혀야 한다
    rejudge2 = _judge({l: [] for l in L3}, ranking=[RR, "A", "B"])
    _, rec2 = _run(tmp_path, record=dict(rec), gen=gen2, judge=judge2,
                   rejudge=rejudge2, saved=saved)

    assert gen2.calls == [], "확정된 실패에 그림을 샀다"
    assert judge2.calls == [], "초기 판정을 샀다"
    assert rejudge2.calls == [], "확정된 실패를 **다시 판정했다**"
    assert rec2["gate"]["outcome"] == GATE_UNRESOLVED, "종착이 뒤집혔다"


# ── 저자는 「선택이 바뀌었나」가 아니다 (Codex BLOCK 2) ─────────────

def test_the_author_is_whoever_made_the_final_bytes(tmp_path, monkeypatch):
    """★★**최종 bytes 를 만든 방문**이 저자다 — 라벨 변경이 아니다.

    ① 옛 초기 후보 B 로 바뀐 것뿐인데 이번 방문을 저자로 찍으면 안 된다
    ② 이번에 **실제로 산** C 인데 캐시 갈래라고 놓쳐도 안 된다
    """
    from app.modules.llm import opik_trace

    uid = {"v": "U1"}
    monkeypatch.setattr(opik_trace, "current_shot_uid", lambda: uid["v"])

    # ── U1: C 를 실제로 산다. C 가 이긴다 → 저자는 U1.
    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    _, rec = _run(tmp_path, record=None, gen=gen,
                  judge=_judge({l: ["x"] for l in L2}),
                  rejudge=rejudge, saved=saved)
    assert rec["selected"] == RR
    assert rec["gate_reroll"]["produced_by_visit"] == "U1"
    assert rec["shot_run_produced"] is True, "이번에 산 재롤인데 저자가 아니다"

    # ── U2: 아무것도 안 산다(확정). 저자는 **U1 그대로**.
    uid["v"] = "U2"
    gen2 = _Gen()
    _, rec2 = _run(tmp_path, record=dict(rec), gen=gen2,
                   judge=_judge({l: ["x"] for l in L2}),
                   rejudge=_judge({}), saved=saved)
    assert gen2.calls == []
    assert rec2["shot_run_produced"] is False, (
        "옛 파일을 그대로 쓰는 방문이 저자를 가로챘다")


def test_an_old_initial_candidate_does_not_make_this_visit_the_author(
        tmp_path, monkeypatch):
    """★재롤이 **져서** 옛 초기 후보 B 가 최종이면, 저자는 이번이 아니다."""
    from app.modules.llm import opik_trace

    uid = {"v": "U1"}
    monkeypatch.setattr(opik_trace, "current_shot_uid", lambda: uid["v"])

    saved: List[Dict[str, Any]] = []
    # U1: 초기 롤을 만들고 재롤도 샀는데 **B 가 이긴다** → 이번 방문이
    #     초기 롤을 만들었으니 저자는 U1 이 맞다.
    gen = _Gen()
    _, rec = _run(tmp_path, record=None, gen=gen,
                  judge=_judge({"A": ["x"], "B": ["y"]}),
                  rejudge=_judge({"A": ["x"], "B": [], RR: ["z"]},
                                 ranking=[RR, "A", "B"]), saved=saved)
    assert rec["selected"] == "B" and rec["shot_run_produced"] is True

    # U2: 같은 기록으로 다시 걷는다 — 아무것도 안 만든다 → 저자 아님.
    uid["v"] = "U2"
    gen2 = _Gen()
    _, rec2 = _run(tmp_path, record=dict(rec), gen=gen2,
                   judge=_judge({}), rejudge=_judge({}), saved=saved)
    assert gen2.calls == []
    assert rec2["shot_run_produced"] is False, (
        "옛 초기 후보를 쓰는 방문이 저자를 가로챘다")


# ── 선정본 교체 실패 (Codex BLOCK 3) ───────────────────────────────

def test_a_failed_sel_swap_does_not_confirm_the_record(tmp_path):
    """★★**기록은 C · 그림은 A** 를 만들지 않는다.

    `dde62916` 에서 닫은 것의 재롤판이다. 재롤 앞에는 `_sel` = A 가
    **항상 있으므로**, 기록을 먼저 확정하고 복사가 실패하면 다음 방문은
    `gate.reroll` 때문에 소급도 이음도 건너뛰고 **실격 A 를 그대로
    내보낸다**.
    """
    import shutil as _sh

    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])

    _orig_copy = _sh.copy
    calls = {"n": 0}

    def _boom(src, dst):
        # 재롤 채택의 복사만 터뜨린다(초기 `_sel` 물질화는 통과)
        calls["n"] += 1
        if calls["n"] >= 2:
            raise OSError("디스크 가득")
        return _orig_copy(src, dst)

    import app.modules.pipeline.multiroll_select as _ms
    _ms.shutil.copy = _boom
    try:
        with pytest.raises(OSError):
            _run(tmp_path, record=None, gen=gen, judge=judge,
                 rejudge=rejudge, saved=saved)
    finally:
        _ms.shutil.copy = _orig_copy

    rec = dict(saved[-1])
    sel_bytes = (tmp_path / "out" / "s1_sel.png").read_bytes()
    a_bytes = (tmp_path / "out" / "s1_a.png").read_bytes()
    assert sel_bytes == a_bytes, "선정본이 이미 바뀌었다(이 반례가 무의미)"
    assert rec["gate"]["outcome"] != GATE_RESOLVED, (
        "선정본을 못 바꿨는데 기록을 **확정했다** — 실격 A 가 나간다")
    assert rec["gate_reroll"]["pending_pick"] == RR, (
        "결정을 예고로 안 남겨서 재개가 복구할 근거가 없다")

    # ── 재개: **물질화만** 복구한다. 이미지·판정 0.
    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({})
    sel2, rec2 = _run(tmp_path, record=rec, gen=gen2, judge=judge2,
                      rejudge=rejudge2, saved=saved)
    assert gen2.calls == [] and judge2.calls == [] and rejudge2.calls == []
    assert rec2["gate"]["outcome"] == GATE_RESOLVED
    assert sel2.read_bytes() == (
        tmp_path / "out" / f"s1_{RR.lower()}.png").read_bytes()


def test_the_second_cached_branch_also_decides_the_author(tmp_path):
    """★★「캐시면 저자 아님」이라는 **별도 규칙을 남기지 않는다**.

    초기 선정·`_sel` 만 남고 `critique_skipped` 를 쓰기 전에 끊긴 기록이
    있다. 그 재개가 **C 를 새로 사서 채택**하면 저자는 이번 방문이다 —
    캐시 갈래라고 무조건 False 로 두면 그 생성자를 놓친다.
    """
    from app.modules.llm import opik_trace

    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    _, rec = _run(tmp_path, record=None, gen=gen, judge=judge,
                  rejudge=_judge({}), saved=saved, allow=lambda: False)
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED
    assert RR.lower() not in gen.calls

    # ★`critique_skipped` 를 **지운다** — 그 갈래를 타게 하는 기록 모양이다.
    seeded = dict(rec)
    seeded.pop("critique_skipped", None)
    seeded.pop("critique", None)

    import pytest as _pytest
    mp = _pytest.MonkeyPatch()
    mp.setattr(opik_trace, "current_shot_uid", lambda: "U9")
    try:
        gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
        rejudge2 = _judge({l: (["x"] if l in L2 else []) for l in L3},
                          ranking=[RR, "A", "B"])
        _, rec2 = _run(tmp_path, record=seeded, gen=gen2, judge=judge2,
                       rejudge=rejudge2, saved=saved, allow=lambda: True)
    finally:
        mp.undo()

    assert judge2.calls == [], "이 갈래가 초기 판정을 샀다"
    assert gen2.calls == [RR.lower()], "재롤 한 장만 사야 한다"
    assert rec2["gate"]["outcome"] == GATE_RESOLVED
    assert rec2["shot_run_produced"] is True, (
        "이번에 산 재롤이 최종인데 캐시 갈래라고 저자를 놓쳤다")


def test_a_missing_candidate_file_keeps_the_decision(tmp_path):
    """★**결정은 보존한다** — 없는 것은 파일뿐이다.

    `pending_pick` 을 지우면 파일이 돌아와도 **재판정을 다시 사게** 된다.
    「판정 미완료」와 「파일 미존재」는 다른 것이다.
    """
    import shutil as _sh

    saved: List[Dict[str, Any]] = []
    gen = _Gen()
    judge = _judge({l: ["x"] for l in L2})
    rejudge = _judge({l: (["x"] if l in L2 else []) for l in L3},
                     ranking=[RR, "A", "B"])
    _orig = _sh.copy
    n = {"i": 0}

    def _boom(src, dst):
        n["i"] += 1
        if n["i"] >= 2:
            raise OSError("디스크 가득")
        return _orig(src, dst)

    import app.modules.pipeline.multiroll_select as _ms
    _ms.shutil.copy = _boom
    try:
        with pytest.raises(OSError):
            _run(tmp_path, record=None, gen=gen, judge=judge,
                 rejudge=rejudge, saved=saved)
    finally:
        _ms.shutil.copy = _orig

    rec = dict(saved[-1])
    # 재롤 파일이 사라진 상태로 재개한다
    (tmp_path / "out" / f"s1_{RR.lower()}.png").unlink()
    gen2, judge2 = _Gen(), _judge({l: ["x"] for l in L2})
    rejudge2 = _judge({})
    _, rec2 = _run(tmp_path, record=rec, gen=gen2, judge=judge2,
                   rejudge=rejudge2, saved=saved)
    assert gen2.calls == [] and rejudge2.calls == [], "없는 파일에 돈을 썼다"
    assert rec2["gate"]["reroll_file_missing"] == RR
    assert rec2["gate_reroll"]["pending_pick"] == RR, (
        "결정을 버려서 파일이 돌아와도 재판정을 다시 사게 된다")
