"""캐시로 끝나는 방문에도 **정책을 적용한다** — 무료 소급 (2026-09-20).

## 왜

게이트 정책을 **생성 지문에서 뗐다**(Codex ③). 접어 두었더니 정책을 켜는
것만으로 대상 샷 전부가 지문 불일치가 되어 후보를 지우고 **롤부터 다시
샀다**. 게이트가 하는 일은 「이미 산 후보 중 무엇을 쓰느냐」인데 같은
후보를 버릴 이유가 없다.

그런데 지문에서 빼고 나면 **지문이 맞는 기록은 판정을 안 거치고 캐시로
그대로 나간다** — 정책을 켜도 옛 기록에는 영영 판정이 안 붙고, verify 는
그 샷들을 계속 **미판정**으로 붙잡는다. 그래서 무호출 반환 **둘 다**
소급 적용을 지난다.

## 이 시험이 잠그는 것

    · 생성·판정 **호출 0** 으로 판정이 붙는다
    · **두 번째 방문은 아무것도 다시 쓰지 않는다**(지출 흔적 거짓 상승 방지)
    · 재선택이 나면 **선정 파일까지** 그 후보로 바뀐다
    · 수정본이 최종인 기록은 **손대지 않는다**(다른 그림의 판정문이다)
    · 비대상은 `not_applicable` 로 **명시**한다
"""
from __future__ import annotations

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

from app.modules.pipeline.multiroll_select import (
    GATE_CLEAN,
    GATE_INCOMPLETE,
    GATE_NOT_APPLICABLE,
    GATE_POLICY_VERSION,
    GATE_RESELECTED,
    GATE_UNRESOLVED,
    reapply_gate_to_cached_record,
    roll_labels,
    run_multiroll_select,
)

LABELS = roll_labels(2)


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

def _rec(*, selected=None, ranking=None, violations=None, **extra):
    v = violations or {}
    return {
        "selected": selected or LABELS[0],
        "ranking": list(ranking or LABELS),
        "readings": [{"label": k, "hard_violations": v.get(k, [])}
                     for k in LABELS],
        "critique_skipped": True,
        **extra,
    }


def test_a_cached_clean_record_gets_its_verdict_for_free():
    rec = _rec()
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) == ""
    assert rec["gate"]["outcome"] == GATE_CLEAN
    assert rec["gate"]["retroactive"] is True
    assert rec["gate"]["policy"] == GATE_POLICY_VERSION


def test_a_second_visit_rewrites_nothing():
    """★**두 번째 메타 재기록 0** (Codex).

    캐시 방문마다 메타를 새로 적으면 「지출 흔적」이 매번 움직여 재생성
    계수가 거짓으로 오른다.
    """
    rec = _rec()
    reapply_gate_to_cached_record(record=rec, labels=LABELS, applicable=True)
    snapshot = dict(rec["gate"])
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) is None
    assert rec["gate"] == snapshot


def test_a_changed_judgement_is_re_evaluated():
    """근거가 달라지면 다시 본다 — 같은 정책이어도."""
    rec = _rec()
    reapply_gate_to_cached_record(record=rec, labels=LABELS, applicable=True)
    rec["readings"] = [{"label": l, "hard_violations": ["x"]}
                       for l in LABELS]
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) == ""
    assert rec["gate"]["outcome"] == GATE_UNRESOLVED


def test_a_reselect_proposes_but_does_not_confirm():
    """★★**선택 기록은 여기서 확정하지 않는다** (2026-09-20 Codex BLOCK).

    내 첫 판은 여기서 `selected` 를 먼저 바꿨다. 그런데 호출부가 그 후보의
    **파일이 없다**는 것을 나중에 알고 되돌리려 해도 원래 값이 이미 지워진
    뒤였다 — 기록은 B 인데 `_sel` 은 A 로 남는다. 그러면 다음 방문에서
    B 의 판정으로 **실격 A 가 clean 을 달고 나간다**.
    """
    rec = _rec(violations={LABELS[0]: ["x"]})
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) == LABELS[1]
    assert rec["gate"]["outcome"] == GATE_RESELECTED
    assert rec["selected"] == LABELS[0], (
        "물질화 전에 선택을 확정했다 — 파일이 없으면 되돌릴 길이 없다")


def test_a_fixed_record_is_left_unverified():
    """★**수정본이 최종이면 저장된 판정문은 이 그림의 것이 아니다**.

    초기 롤을 본 판정문을 수정본의 판정으로 읽으면 **다른 그림의 판정**을
    이 그림에 붙이게 된다. 그런 기록은 미검증으로 남긴다.
    """
    rec = _rec(violations={LABELS[0]: ["x"]})
    rec.pop("critique_skipped")
    rec["critique"] = {"issues": [{"what": "x"}]}
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) == ""
    assert rec["gate"]["outcome"] == GATE_INCOMPLETE
    assert rec["selected"] == LABELS[0], "수정본 기록의 선택을 바꿨다"


def test_a_record_without_readings_is_left_unverified():
    """근거가 없으면 **미검증** — 소급을 명분으로 새 판정을 사지 않는다."""
    rec = _rec()
    rec.pop("readings")
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) == ""
    assert rec["gate"]["outcome"] == GATE_INCOMPLETE


def test_the_non_target_branch_is_marked_explicitly():
    """★비대상도 **적는다** — 안 적으면 verify 가 계속 미판정으로 붙잡는다."""
    rec = _rec()
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=False) == ""
    assert rec["gate"]["outcome"] == GATE_NOT_APPLICABLE
    assert rec["gate"]["applicable"] is False
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=False) is None


def test_a_dependency_hold_is_left_alone():
    """★의존 대기는 **걷기가 소유**한다 — 여기서 풀지 않는다."""
    rec = _rec()
    rec["gate"] = {"outcome": "blocked_dependency", "blocked_by": "S1sh1",
                   "prior": {"outcome": GATE_UNRESOLVED}}
    assert reapply_gate_to_cached_record(
        record=rec, labels=LABELS, applicable=True) is None
    assert rec["gate"]["outcome"] == "blocked_dependency"


# ── 실제 루프: 두 방문 ──────────────────────────────────────────────

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

    def __call__(self, tag, prompt, labeled_refs, out_path: Path):
        self.calls.append(out_path)
        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]]):
    calls: List[Any] = []

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

    # ★cross-model `owns_order` 경로에서만 게이트를 켠다 — 그 계약을
    #  여기서도 지킨다(plain flip 은 정순 관찰만 싣는다).
    judge_fn.owns_order = True
    judge_fn.calls = calls
    return judge_fn


def _run(tmp_path, *, record, gate_on, gen, judge, persisted):
    (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=gate_on,
        persist_record_fn=persisted.append)


def test_turning_the_gate_on_costs_no_generation(tmp_path):
    """★★정책을 켜는 것만으로 **다시 그리지 않는다**.

    지문에 접어 두었을 때는 켜는 순간 후보가 지워지고 롤부터 다시 샀다.
    이제 켜도 **생성 0 · 판정 0** 이고 판정만 붙는다.
    """
    gen, judge, saved = _Gen(), _judge({roll_labels(2)[0]: ["x"]}), []
    sel1, rec1 = _run(tmp_path, record=None, gate_on=False,
                      gen=gen, judge=judge, persisted=saved)
    assert len(gen.calls) == 2 and len(judge.calls) == 1
    assert rec1["selected"] == LABELS[0]
    assert rec1["gate"]["outcome"] == GATE_NOT_APPLICABLE

    # ── 둘째 방문: 게이트만 켠다. 프롬프트·참조·롤 수는 그대로.
    gen2, judge2 = _Gen(), _judge({roll_labels(2)[0]: ["x"]})
    sel2, rec2 = _run(tmp_path, record=dict(rec1), gate_on=True,
                      gen=gen2, judge=judge2, persisted=saved)
    assert gen2.calls == [], "정책을 켰다고 **다시 그렸다**"
    assert judge2.calls == [], "정책을 켰다고 **다시 판정했다**"
    assert rec2["gate"]["outcome"] == GATE_RESELECTED
    assert rec2["gate"]["retroactive"] is True
    # ★기록만 바꾸지 않는다 — 선정 파일도 그 후보다
    assert sel2.read_bytes() == (
        tmp_path / "out" / f"s1_{LABELS[1]}.png").read_bytes()


def test_a_third_visit_writes_nothing_more(tmp_path):
    """★**두 번째 메타 재기록 0** — 실제 루프에서도."""
    gen, judge, saved = _Gen(), _judge({}), []
    _, rec1 = _run(tmp_path, record=None, gate_on=True,
                   gen=gen, judge=judge, persisted=saved)
    assert rec1["gate"]["outcome"] == GATE_CLEAN
    n_before = len(saved)

    gen2, judge2 = _Gen(), _judge({})
    _, rec2 = _run(tmp_path, record=dict(rec1), gate_on=True,
                   gen=gen2, judge=judge2, persisted=saved)
    assert gen2.calls == [] and judge2.calls == []
    assert len(saved) == n_before, "같은 정책·근거인데 메타를 다시 썼다"


def test_the_fingerprint_survives_turning_the_gate_on(tmp_path):
    """★지문이 그대로라야 후보가 안 지워진다 — 뿌리를 직접 잰다."""
    gen, judge, saved = _Gen(), _judge({}), []
    _, rec_off = _run(tmp_path, record=None, gate_on=False,
                      gen=gen, judge=judge, persisted=saved)
    gen2, judge2 = _Gen(), _judge({})
    _, rec_on = _run(tmp_path, record=dict(rec_off), gate_on=True,
                     gen=gen2, judge=judge2, persisted=saved)
    assert rec_on["input_fingerprint"] == rec_off["input_fingerprint"], (
        "정책을 켰더니 생성 지문이 움직였다 — 후보를 지우고 다시 산다")


# ── 재선택 후보 파일이 **없을 때** (Codex BLOCK 반례) ──────────────

def test_a_missing_candidate_file_leaves_everything_alone(tmp_path):
    """★★두 방문 반례 — 후보 파일이 없으면 **선택도 `_sel` 도 그대로**.

    Codex 가 준 반례 그대로:
      · 기존 selected=A · `_sel`=A · A 실격 / B 허용 · **B 롤 파일만 없음**
      · 첫 소급 → 종전에는 selected=B 인데 `_sel` 은 A 로 남았다
      · 둘째 소급 → 그 B 로 다시 보니 **허용**이라 `clean` 이 되고,
        라벨이 더 안 바뀌어 파일 확인도 건너뛰고 **A bytes 가 clean 을
        달고 나갔다**
    """
    gen, judge, saved = _Gen(), _judge({LABELS[0]: ["x"]}), []
    _, rec1 = _run(tmp_path, record=None, gate_on=False,
                   gen=gen, judge=judge, persisted=saved)
    assert rec1["selected"] == LABELS[0]
    sel_bytes = (tmp_path / "out" / "s1_sel.png").read_bytes()

    # B 롤 파일만 지운다 (정리·부분 손실 모사)
    (tmp_path / "out" / f"s1_{LABELS[1]}.png").unlink()

    for visit in (1, 2):
        gen_n, judge_n = _Gen(), _judge({LABELS[0]: ["x"]})
        sel_n, rec_n = _run(tmp_path, record=dict(rec1), gate_on=True,
                            gen=gen_n, judge=judge_n, persisted=saved)
        assert gen_n.calls == [] and judge_n.calls == [], (
            f"{visit}번째 방문이 그림·판정을 샀다")
        assert rec_n["gate"]["outcome"] == GATE_INCOMPLETE, (
            f"{visit}번째 방문이 실격본을 통과시켰다")
        assert rec_n["selected"] == LABELS[0], (
            f"{visit}번째 방문이 없는 후보로 선택을 바꿨다")
        assert sel_n.read_bytes() == sel_bytes, "선정본이 바뀌었다"
        rec1 = dict(rec_n)


def test_the_candidate_coming_back_lets_the_free_reselect_happen(tmp_path):
    """★파일이 **복구되면** 무료 재선택이 된다 — 영원히 막히지 않는다.

    미검증에 `evidence` 를 채워 두면 다음 방문이 통째로 건너뛰어 복구할
    길이 막힌다. 그래서 그 자리에는 **일부러 안 채운다**.
    """
    gen, judge, saved = _Gen(), _judge({LABELS[0]: ["x"]}), []
    _, rec = _run(tmp_path, record=None, gate_on=False,
                  gen=gen, judge=judge, persisted=saved)
    roll_b = tmp_path / "out" / f"s1_{LABELS[1]}.png"
    kept = roll_b.read_bytes()
    roll_b.unlink()

    gen2, judge2 = _Gen(), _judge({LABELS[0]: ["x"]})
    _, rec = _run(tmp_path, record=dict(rec), gate_on=True,
                  gen=gen2, judge=judge2, persisted=saved)
    assert rec["gate"]["outcome"] == GATE_INCOMPLETE
    assert "evidence" not in rec["gate"], (
        "미검증에 근거를 채워 두면 복구돼도 영영 건너뛴다")

    roll_b.write_bytes(kept)                 # 복구
    gen3, judge3 = _Gen(), _judge({LABELS[0]: ["x"]})
    sel3, rec = _run(tmp_path, record=dict(rec), gate_on=True,
                     gen=gen3, judge=judge3, persisted=saved)
    assert gen3.calls == [] and judge3.calls == []
    assert rec["gate"]["outcome"] == GATE_RESELECTED
    assert rec["selected"] == LABELS[1]
    assert sel3.read_bytes() == kept, "기록만 바꾸고 선정본을 안 맞췄다"

    n = len(saved)
    gen4, judge4 = _Gen(), _judge({LABELS[0]: ["x"]})
    _, rec4 = _run(tmp_path, record=dict(rec), gate_on=True,
                   gen=gen4, judge=judge4, persisted=saved)
    assert len(saved) == n, "확정한 뒤에도 방문마다 다시 쓴다"
