"""s40 공통 생성 파이프 이식 — multiroll_select 결정론 로직 테스트.

대상: 선정(동점=랭킹)/재개(sel+critique 소급)/롤 파일 skip/수정 프롬프트 조립/
critique 게이트. 이미지·VLM 은 fake callable — LLM 품질은 여기서 주장하지 않는다
(feedback: TDD 는 deterministic 영역만).
"""
from __future__ import annotations

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

import pytest

from app.modules.pipeline.multiroll_select import (
    build_critique_schema,
    build_fix_prompt,
    build_judge_schema,
    compute_input_fingerprint,
    gemini_select,
    roll_labels,
    run_multiroll_select,
)


# ── fakes ──────────────────────────────────────────────────────────────


class FakeGen:
    """gen_fn(tag, prompt, labeled_refs, out_path) — 호출 기록 + 바이트 기입."""

    def __init__(self, content_by_suffix: Dict[str, bytes] | None = None):
        self.calls: List[Tuple[str, str, List[Tuple[str, Path]], Path]] = []
        self._content = content_by_suffix or {}

    def __call__(self, tag, prompt, labeled_refs, out_path: Path) -> Path:
        self.calls.append((tag, prompt, list(labeled_refs), out_path))
        key = out_path.stem.rsplit("_", 1)[-1]  # a/b/c/fix
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(self._content.get(key, f"img:{key}".encode()))
        return out_path


def make_judge(winner: str, scores: Dict[str, int], ranking: List[str]):
    calls: List[Dict[str, Any]] = []

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls.append({"tag": tag, "labels": list(labels), "cands": list(cand_paths)})
        return {
            "winner": winner,
            "ranking": ranking,
            "verdicts": [
                {"label": lab, "score": sc, "verdict_ko": "ok"}
                for lab, sc in scores.items()
            ],
        }

    judge_fn.calls = calls
    return judge_fn


def make_critique(issues: List[Dict[str, str]]):
    calls: List[Dict[str, Any]] = []

    def critique_fn(tag, prompt, labeled_refs, image_path):
        calls.append({"tag": tag, "image": image_path})
        return {"issues": issues}

    critique_fn.calls = calls
    return critique_fn


FIX_HEAD = "Edit this photograph. Apply ONLY the corrections listed below:"
FIX_TAIL = "PRESERVE EVERYTHING ELSE EXACTLY."
FIX_LABEL = "PHOTOGRAPH TO EDIT"


def run(tmp_path: Path, **over):
    """공통 인자 헬퍼 — 개별 테스트가 필요한 것만 override."""
    kw: Dict[str, Any] = dict(
        tag="t1",
        prompt="PROMPT",
        labeled_refs=[("REF", tmp_path / "ref.png")],
        out_stem=tmp_path / "out" / "s1",
        gen_fn=FakeGen(),
        judge_fn=make_judge("B", {"A": 5, "B": 9, "C": 7}, ["B", "C", "A"]),
        critique_fn=None,
        fix_gen_fn=None,
        roll_count=3,
        critique_enabled=False,
        fix_head=FIX_HEAD,
        fix_tail=FIX_TAIL,
        fix_label=FIX_LABEL,
        record=None,
    )
    kw.update(over)
    (tmp_path / "ref.png").write_bytes(b"ref")
    return run_multiroll_select(**kw)


# ── 선정 로직 ──────────────────────────────────────────────────────────


def test_gemini_select_tie_uses_ranking():
    judge = {
        "winner": "A",  # winner 필드는 동점 해소에 쓰지 않음 (실험 정본)
        "ranking": ["C", "A", "B"],
        "verdicts": [
            {"label": "A", "score": 10, "verdict_ko": ""},
            {"label": "B", "score": 15, "verdict_ko": ""},
            {"label": "C", "score": 15, "verdict_ko": ""},
        ],
    }
    totals, selected = gemini_select(judge)
    assert totals == {"A": 10, "B": 15, "C": 15}
    assert selected == "C"  # 동점 B/C → ranking 앞선 C


def test_gemini_select_ranking_missing_label_falls_back():
    judge = {
        "winner": "B",
        "ranking": ["A"],  # 불완전 랭킹 — 동점 라벨이 랭킹에 없으면 뒤로
        "verdicts": [
            {"label": "A", "score": 7, "verdict_ko": ""},
            {"label": "B", "score": 7, "verdict_ko": ""},
        ],
    }
    _, selected = gemini_select(judge)
    assert selected == "A"


# ── 롤 생성·선정·record ────────────────────────────────────────────────


def test_run_generates_rolls_and_selects(tmp_path):
    gen = FakeGen()
    judge = make_judge("B", {"A": 5, "B": 9, "C": 7}, ["B", "C", "A"])
    sel_path, record = run(tmp_path, gen_fn=gen, judge_fn=judge)

    assert [c[3].name for c in gen.calls] == ["s1_a.png", "s1_b.png", "s1_c.png"]
    assert sel_path.name == "s1_sel.png"
    assert sel_path.read_bytes() == b"img:b"  # 선정본 복사
    assert record["selected"] == "B"
    assert record["totals"] == {"A": 5, "B": 9, "C": 7}
    assert record["ranking"] == ["B", "C", "A"]
    # 2026-08-24: refs 에 asset_id·role 칸이 늘었다(끊김 A). 신원을 안 넘긴
    # 호출은 두 칸이 None — path 칸은 그대로다(읽는 데가 있다).
    assert record["refs"] == [{
        "label": "REF", "path": str(tmp_path / "ref.png"),
        "asset_id": None, "role": None,
    }]
    assert record.get("critique_skipped") is True  # 게이트 off


def _fp(tmp_path, *, critique_enabled, roll_count=3, prompt="PROMPT"):
    """run() 헬퍼와 동일 인자의 입력 지문 (참조 파일 선기입 필요)."""
    ref = tmp_path / "ref.png"
    if not ref.exists():
        ref.write_bytes(b"ref")
    return compute_input_fingerprint(
        prompt=prompt,
        labeled_refs=[("REF", ref)],
        roll_count=roll_count,
        critique_enabled=critique_enabled,
    )


def test_roll_files_skip_existing_with_matching_record(tmp_path):
    """지문 일치 record + 롤 파일 존재 → 해당 롤만 skip (파일 단위 재개)."""
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_a.png").write_bytes(b"pre-existing-a")
    record = {"input_fingerprint": _fp(tmp_path, critique_enabled=False)}
    gen = FakeGen()
    sel_path, _ = run(tmp_path, gen_fn=gen, record=record)
    assert [c[3].name for c in gen.calls] == ["s1_b.png", "s1_c.png"]


def test_roll_files_without_record_are_cleared(tmp_path):
    """무기록 롤 파일 = 출처 불명 → 정리 후 전량 재생성 (BLOCKING-3)."""
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_a.png").write_bytes(b"unknown-origin")
    gen = FakeGen()
    run(tmp_path, gen_fn=gen)
    assert [c[3].name for c in gen.calls] == ["s1_a.png", "s1_b.png", "s1_c.png"]


def test_fingerprint_mismatch_clears_and_regenerates(tmp_path):
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_a.png").write_bytes(b"old-a")
    (out_dir / "s1_sel.png").write_bytes(b"old-sel")
    record = {
        "input_fingerprint": "0" * 16,  # 다른 입력의 지문
        "selected": "A",
        "critique": {"issues": []},
    }
    gen = FakeGen()
    sel_path, out_record = run(tmp_path, gen_fn=gen, record=record)
    assert len(gen.calls) == 3  # 전량 재생성
    assert sel_path.read_bytes() == b"img:b"
    assert out_record["input_fingerprint"] != "0" * 16


def test_force_clears_even_when_complete(tmp_path):
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_sel.png").write_bytes(b"final")
    record = {
        "input_fingerprint": _fp(tmp_path, critique_enabled=False),
        "selected": "B",
        "critique_skipped": True,
    }
    gen = FakeGen()
    sel_path, _ = run(tmp_path, gen_fn=gen, record=record, force=True)
    assert len(gen.calls) == 3
    assert sel_path.read_bytes() == b"img:b"


def test_crash_window_sel_without_selected_rejudges_from_rolls(tmp_path):
    """2차 리뷰 B2: 선정 persist 전 crash(지문만 있는 record + sel 존재) →
    sel 폐기 후 기존 롤로 판정만 재수행 (롤 재생성 없음, KeyError 없음)."""
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    for lab, content in (("a", b"img:a"), ("b", b"img:b"), ("c", b"img:c")):
        (out_dir / f"s1_{lab}.png").write_bytes(content)
    (out_dir / "s1_sel.png").write_bytes(b"orphan-sel")
    record = {"input_fingerprint": _fp(tmp_path, critique_enabled=False)}
    gen = FakeGen()
    judge = make_judge("B", {"A": 5, "B": 9, "C": 7}, ["B", "C", "A"])
    sel_path, out_record = run(
        tmp_path, gen_fn=gen, judge_fn=judge, record=record,
    )
    assert gen.calls == []  # 롤 재생성 없음
    assert len(judge.calls) == 1  # 판정 재수행
    assert sel_path.read_bytes() == b"img:b"
    assert out_record["selected"] == "B"


def test_selection_persisted_before_sel_materialized(tmp_path):
    """2차 리뷰 B2: 선정 record persist 가 sel 물질화보다 선행."""
    order = []

    def persist(rec):
        sel = tmp_path / "out" / "s1_sel.png"
        if rec.get("selected"):
            order.append(("persist_selected", sel.exists()))

    run(tmp_path, gen_fn=FakeGen(), persist_record_fn=persist)
    # 첫 selected persist 시점에 sel 은 아직 없음 (durable-first)
    assert order and order[0] == ("persist_selected", False)


def test_persist_record_fn_called_per_phase(tmp_path):
    """지문 기록→선정→완결 각 phase 에서 durable persist (크래시 창 차단)."""
    snapshots = []
    gen = FakeGen()
    crit = make_critique([])
    run(
        tmp_path,
        gen_fn=gen,
        critique_fn=crit,
        fix_gen_fn=FakeGen(),
        critique_enabled=True,
        persist_record_fn=lambda rec: snapshots.append(dict(rec)),
    )
    assert len(snapshots) >= 3
    assert "input_fingerprint" in snapshots[0] and "selected" not in snapshots[0]
    # 2026-08-24: 유료 구간 진입 표식이 호출 **전에** durable persist 를 한 번
    # 더 한다(_mark_spend_attempt_once). 그래서 자리를 번호로 못박지 않고
    # 「선정이 남는 스냅숏이 있는가」로 본다 — 이 시험이 막던 것은 단계마다
    # durable 하게 남는 것이지 persist 횟수가 아니다.
    assert any(s.get("selected") == "B" for s in snapshots)
    assert "critique" in snapshots[-1]


def test_roll_count_two_labels(tmp_path):
    gen = FakeGen()
    judge = make_judge("A", {"A": 8, "B": 3}, ["A", "B"])
    _, record = run(tmp_path, gen_fn=gen, judge_fn=judge, roll_count=2)
    assert [c[3].name for c in gen.calls] == ["s1_a.png", "s1_b.png"]
    assert judge.calls[0]["labels"] == ["A", "B"]
    assert record["selected"] == "A"


# ── critique·fix ───────────────────────────────────────────────────────


def test_critique_fix_flow(tmp_path):
    gen = FakeGen()
    fix_gen = FakeGen()
    crit = make_critique(
        [
            {"issue_ko": "손 이상", "fix_en": "Fix the left hand anatomy."},
            {"issue_ko": "간판 텍스트", "fix_en": "Remove the sign text."},
        ]
    )
    sel_path, record = run(
        tmp_path,
        gen_fn=gen,
        critique_fn=crit,
        fix_gen_fn=fix_gen,
        critique_enabled=True,
    )
    # 결함 검사는 선정 원본(B) 대상
    assert crit.calls[0]["image"].name == "s1_b.png"
    # i2i 수정: 선정 원본 단독 참조 + fix_label
    (tag, fix_prompt, refs, out_path) = fix_gen.calls[0]
    assert refs == [(FIX_LABEL, tmp_path / "out" / "s1_b.png")]
    assert out_path.name == "s1_fix.png"
    assert FIX_HEAD in fix_prompt and FIX_TAIL in fix_prompt
    assert "- Fix the left hand anatomy." in fix_prompt
    assert "- Remove the sign text." in fix_prompt
    # 수정본이 최종 _sel
    assert sel_path.read_bytes() == b"img:fix"
    assert record["fix_prompt"] == fix_prompt
    assert [i["fix_en"] for i in record["critique"]["issues"]] == [
        "Fix the left hand anatomy.",
        "Remove the sign text.",
    ]


def test_critique_no_issues_copies_orig(tmp_path):
    gen = FakeGen()
    fix_gen = FakeGen()
    crit = make_critique([])
    sel_path, record = run(
        tmp_path,
        gen_fn=gen,
        critique_fn=crit,
        fix_gen_fn=fix_gen,
        critique_enabled=True,
    )
    assert sel_path.read_bytes() == b"img:b"
    assert record["fix_skipped"] is True
    assert fix_gen.calls == []


# ── 재개 ───────────────────────────────────────────────────────────────


def test_resume_skips_when_critiqued(tmp_path):
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_sel.png").write_bytes(b"final")
    gen = FakeGen()
    judge = make_judge("B", {"B": 9}, ["B"])
    crit = make_critique([])
    record = {
        "input_fingerprint": _fp(tmp_path, critique_enabled=True),
        "selected": "B",
        "critique": {"issues": []},
    }
    sel_path, out_record = run(
        tmp_path,
        gen_fn=gen,
        judge_fn=judge,
        critique_fn=crit,
        fix_gen_fn=FakeGen(),
        critique_enabled=True,
        record=record,
    )
    assert sel_path.read_bytes() == b"final"
    assert gen.calls == [] and judge.calls == [] and crit.calls == []


def test_resume_backfills_critique_only(tmp_path):
    """sel 존재 + critique 미실행(지문 일치) → 검사·수정만 소급 (s40 _resume)."""
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_b.png").write_bytes(b"orig-b")
    (out_dir / "s1_sel.png").write_bytes(b"orig-b")
    gen = FakeGen()
    judge = make_judge("B", {"B": 9}, ["B"])
    fix_gen = FakeGen()
    crit = make_critique([{"issue_ko": "x", "fix_en": "Fix x."}])
    record = {
        "input_fingerprint": _fp(tmp_path, critique_enabled=True),
        "selected": "B",
    }
    sel_path, out_record = run(
        tmp_path,
        gen_fn=gen,
        judge_fn=judge,
        critique_fn=crit,
        fix_gen_fn=fix_gen,
        critique_enabled=True,
        record=record,
    )
    assert gen.calls == [] and judge.calls == []  # 롤·판정 재실행 없음
    assert crit.calls[0]["image"].name == "s1_b.png"
    assert sel_path.read_bytes() == b"img:fix"
    assert "critique" in out_record


def test_sel_without_record_regenerates(tmp_path):
    """sel 존재 + record 부재 = 출처 불명 → 정리 후 재생성 (BLOCKING-3:
    크래시 창에서 critique 영구 생략되던 구멍 제거)."""
    out_dir = tmp_path / "out"
    out_dir.mkdir()
    (out_dir / "s1_sel.png").write_bytes(b"unknown-final")
    gen = FakeGen()
    crit = make_critique([])
    sel_path, out_record = run(
        tmp_path,
        gen_fn=gen,
        critique_fn=crit,
        fix_gen_fn=FakeGen(),
        critique_enabled=True,
        record=None,
    )
    assert len(gen.calls) == 3  # 전량 재생성
    assert sel_path.read_bytes() == b"img:b"
    assert "critique" in out_record


# ── 스키마·헬퍼 ────────────────────────────────────────────────────────


def test_roll_labels():
    assert roll_labels(3) == ["A", "B", "C"]
    assert roll_labels(5) == ["A", "B", "C", "D", "E"]
    with pytest.raises(ValueError):
        roll_labels(6)


def test_build_judge_schema_labels():
    schema = build_judge_schema(["A", "B", "C", "D"])
    assert schema["properties"]["winner"]["enum"] == ["A", "B", "C", "D"]
    assert schema["properties"]["ranking"]["items"]["enum"] == ["A", "B", "C", "D"]
    assert (
        schema["properties"]["verdicts"]["items"]["properties"]["label"]["enum"]
        == ["A", "B", "C", "D"]
    )
    # 팩 v6 (2026-08-06): 후보마다 방향·공간·엔티티 서술을 요구한다.
    assert schema["required"] == [
        "winner", "ranking", "verdicts", "readings", "all_candidates_fail"]
    assert schema["properties"]["readings"]["items"]["required"] == [
        "label", "direction", "built_space", "entities", "hard_violations"]
    # v5 이하 호환 shape 는 그대로 낼 수 있어야 한다(회귀 안전판).
    legacy = build_judge_schema(["A", "B"], with_readings=False)
    assert legacy["required"] == ["winner", "ranking", "verdicts"]


def test_fix_prompt_assembly():
    issues = [
        {"issue_ko": "a", "fix_en": "Do A."},
        {"issue_ko": "b", "fix_en": "Do B."},
    ]
    prompt = build_fix_prompt(issues, FIX_HEAD, FIX_TAIL)
    assert prompt == (
        FIX_HEAD + "\n\n" + "CORRECTIONS:\n- Do A.\n- Do B." + "\n\n" + FIX_TAIL
    )


# ── seed 품질 2R — roll_prompts 변형 롤 (2026-07-16) ──────────────────


GOLDEN_FP_EXTRA = "93e7b63b0689e723"
GOLDEN_FP_NOEXTRA = "dba19ce085191beb"


def test_fingerprint_legacy_golden_unchanged():
    """roll_prompts 미제공 호출의 지문 byte-identical 잠금 (기존 CP 보호)."""
    assert compute_input_fingerprint(
        prompt="PROMPT", labeled_refs=[("REF", b"refbytes")],
        roll_count=3, critique_enabled=False, extra={"k": "v"},
    ) == GOLDEN_FP_EXTRA
    assert compute_input_fingerprint(
        prompt="PROMPT", labeled_refs=[("REF", b"refbytes")],
        roll_count=3, critique_enabled=False,
    ) == GOLDEN_FP_NOEXTRA


ROLL_PROMPTS = {"A": "VARIANT A BODY", "B": "VARIANT B BODY",
                "C": "VARIANT C BODY"}


def test_roll_prompts_gen_per_label_judge_gets_brief(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    gen = FakeGen()
    judge = make_judge("B", {"A": 5, "B": 9, "C": 7}, ["B", "C", "A"])
    sel, record = run(tmp_path, gen_fn=gen, judge_fn=judge,
                      roll_prompts=ROLL_PROMPTS)
    assert [c[1] for c in gen.calls] == [
        "VARIANT A BODY", "VARIANT B BODY", "VARIANT C BODY"]
    # judge/record 의 prompt=공유 브리프(기존 인자)
    assert record["prompt"] == "PROMPT"
    assert record["roll_prompts"] == ROLL_PROMPTS
    assert sel.exists()


def test_roll_prompts_changes_fingerprint(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    _, r1 = run(tmp_path)
    _, r2 = run(tmp_path, out_stem=tmp_path / "out" / "s2",
                roll_prompts=ROLL_PROMPTS)
    assert r1["input_fingerprint"] != r2["input_fingerprint"]
    other = dict(ROLL_PROMPTS, C="DIFFERENT C")
    _, r3 = run(tmp_path, out_stem=tmp_path / "out" / "s3",
                roll_prompts=other)
    assert r2["input_fingerprint"] != r3["input_fingerprint"]


def test_roll_prompts_label_mismatch_raises(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    with pytest.raises(ValueError):
        run(tmp_path, roll_prompts={"A": "x", "B": "y"})


def test_roll_prompts_critique_gets_selected_variant_plus_brief(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    crit = make_critique([])
    calls: List[str] = []

    def critique_fn(tag, prompt, labeled_refs, image_path):
        calls.append(prompt)
        return crit(tag, prompt, labeled_refs, image_path)

    sel, record = run(
        tmp_path, critique_enabled=True, critique_fn=critique_fn,
        fix_gen_fn=FakeGen(), roll_prompts=ROLL_PROMPTS,
    )
    assert calls == ["VARIANT B BODY\n\nPROMPT"]  # 선정=B + 공유 브리프
    assert record["fix_skipped"] is True


def test_roll_prompts_resume_backfill_critique_composed(tmp_path):
    """sel 존재+critique 미실행 소급 경로도 변형 프롬프트 합성 사용."""
    (tmp_path / "ref.png").write_bytes(b"ref")
    _, record = run(tmp_path, roll_prompts=ROLL_PROMPTS)
    calls: List[str] = []

    def critique_fn(tag, prompt, labeled_refs, image_path):
        calls.append(prompt)
        return {"issues": []}

    sel, record2 = run(
        tmp_path, critique_enabled=True, critique_fn=critique_fn,
        fix_gen_fn=FakeGen(), roll_prompts=ROLL_PROMPTS, record=record,
    )
    assert calls == ["VARIANT B BODY\n\nPROMPT"]
    assert record2.get("fix_skipped") is True


# ── still-variants 확장 (2026-07-17): roll_refs·flip·critique 모드·병렬 ──


ROLL_PROMPTS_2 = {"A": "VARIANT A BODY", "B": "VARIANT B BODY"}


class FlipJudge:
    """호출 순서별 사전 정의 judge 결과 — flip 계약 검증용."""

    def __init__(self, results):
        self.calls: List[Dict[str, Any]] = []
        self._results = list(results)

    def __call__(self, tag, prompt, labeled_refs, cand_paths, labels):
        self.calls.append({
            "cands": [Path(p).name for p in cand_paths],
            "labels": list(labels),
            "refs": list(labeled_refs),
        })
        return self._results[len(self.calls) - 1]


def _verdicts(scores: Dict[str, int]) -> List[Dict[str, Any]]:
    return [
        {"label": lab, "score": sc, "verdict_ko": ""}
        for lab, sc in scores.items()
    ]


# ── roll_refs — 라벨별 참조 ────────────────────────────────────────────


def test_roll_refs_label_set_mismatch_raises(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    with pytest.raises(ValueError, match="roll_refs"):
        run(
            tmp_path, roll_count=2,
            judge_fn=make_judge("A", {"A": 9, "B": 5}, ["A", "B"]),
            roll_refs={"A": [("R", tmp_path / "ref.png")]},  # B 누락
        )


def test_roll_refs_gen_per_label_judge_gets_shared(tmp_path):
    """gen=라벨별 refs / judge=공유 labeled_refs(블라인드) 관할 분리."""
    conti = tmp_path / "conti.png"
    conti.write_bytes(b"conti")
    shared = [("REF", tmp_path / "ref.png")]
    rr = {
        "A": [("REF", tmp_path / "ref.png"), ("SKETCH", conti)],
        "B": [("REF", tmp_path / "ref.png")],
    }
    gen = FakeGen()
    judge = make_judge("A", {"A": 9, "B": 5}, ["A", "B"])
    run(
        tmp_path, roll_count=2, gen_fn=gen, judge_fn=judge,
        labeled_refs=shared, roll_refs=rr,
    )
    by_label = {c[3].stem.rsplit("_", 1)[-1]: c[2] for c in gen.calls}
    assert [lab for lab, _ in by_label["a"]] == ["REF", "SKETCH"]
    assert [lab for lab, _ in by_label["b"]] == ["REF"]
    assert judge.calls[0]["cands"]  # judge 호출 성립
    # judge 는 공유 refs 만 — FakeGen 기록과 달리 make_judge 는 refs 를
    # 기록하지 않으므로 여기서는 gen 관할만 검증(공유 refs 검증은 아래
    # critique 테스트에서 함께).


def test_roll_refs_critique_uses_selected_label_refs(tmp_path):
    conti = tmp_path / "conti.png"
    conti.write_bytes(b"conti")
    rr = {
        "A": [("REF", tmp_path / "ref.png"), ("SKETCH", conti)],
        "B": [("REF", tmp_path / "ref.png")],
    }
    seen_refs: List[List[Tuple[str, Any]]] = []

    def critique_fn(tag, prompt, labeled_refs, image_path):
        seen_refs.append(list(labeled_refs))
        return {"issues": []}

    run(
        tmp_path, roll_count=2,
        judge_fn=make_judge("A", {"A": 9, "B": 5}, ["A", "B"]),
        roll_refs=rr, critique_enabled=True,
        critique_fn=critique_fn, fix_gen_fn=FakeGen(),
    )
    assert [lab for lab, _ in seen_refs[0]] == ["REF", "SKETCH"]  # 승자 A refs


def test_roll_refs_fingerprint_drift_regenerates(tmp_path):
    conti = tmp_path / "conti.png"
    conti.write_bytes(b"conti-v1")
    rr = {
        "A": [("SKETCH", conti)],
        "B": [("REF", tmp_path / "ref.png")],
    }
    judge = make_judge("A", {"A": 9, "B": 5}, ["A", "B"])
    _, record = run(tmp_path, roll_count=2, judge_fn=judge, roll_refs=rr)

    gen2 = FakeGen()
    _, record2 = run(
        tmp_path, roll_count=2, judge_fn=judge, roll_refs=rr,
        gen_fn=gen2, record=record,
    )
    assert gen2.calls == []  # 동일 입력 = 전부 재사용

    conti.write_bytes(b"conti-v2")  # 한 라벨 참조 내용만 변경
    gen3 = FakeGen()
    _, record3 = run(
        tmp_path, roll_count=2, judge_fn=judge, roll_refs=rr,
        gen_fn=gen3, record=record2,
    )
    assert len(gen3.calls) == 2  # 지문 드리프트 → 전체 재생성
    assert record3["input_fingerprint"] != record2["input_fingerprint"]


def test_default_run_has_no_new_record_keys_and_legacy_fingerprint(tmp_path):
    """신규 kwargs 미사용 = record shape·지문 byte-identical (golden)."""
    _, record = run(tmp_path)
    assert "roll_refs" not in record
    assert "judge_flip" not in record
    expected = compute_input_fingerprint(
        prompt="PROMPT", labeled_refs=[("REF", tmp_path / "ref.png")],
        roll_count=3, critique_enabled=False, extra=None,
    )
    assert record["input_fingerprint"] == expected


# ── critique_selected_prompt_only ─────────────────────────────────────


def test_critique_selected_prompt_only_uses_variant_prompt_once(tmp_path):
    """BLOCKING-1: roll_prompts 가 base 전문 포함 시 브리프 재병합 금지."""
    calls: List[str] = []

    def critique_fn(tag, prompt, labeled_refs, image_path):
        calls.append(prompt)
        return {"issues": []}

    run(
        tmp_path, critique_enabled=True, critique_fn=critique_fn,
        fix_gen_fn=FakeGen(), roll_prompts=ROLL_PROMPTS,
        critique_selected_prompt_only=True,
    )
    assert calls == ["VARIANT B BODY"]  # 선정 변형 전문 단독 — 중복 0


def test_critique_selected_prompt_only_requires_roll_prompts(tmp_path):
    (tmp_path / "ref.png").write_bytes(b"ref")
    with pytest.raises(ValueError, match="critique_selected_prompt_only"):
        run(
            tmp_path, critique_enabled=True, critique_fn=make_critique([]),
            fix_gen_fn=FakeGen(), critique_selected_prompt_only=True,
        )


# ── judge flip — canonical remap·결합 정책 ────────────────────────────


def test_normalize_flip_verdict_remaps_all_fields():
    from app.modules.pipeline.multiroll_select import normalize_flip_verdict

    raw = {
        "winner": "A",
        "ranking": ["A", "B"],
        "verdicts": _verdicts({"A": 7, "B": 3}),
    }
    out = normalize_flip_verdict(raw, {"A": "B", "B": "A"}, ["A", "B"])
    assert out["winner"] == "B"
    assert out["ranking"] == ["B", "A"]
    assert {v["label"]: v["score"] for v in out["verdicts"]} == {
        "B": 7, "A": 3,
    }


@pytest.mark.parametrize("raw", [
    {"winner": "A", "ranking": ["A", "B"],
     "verdicts": _verdicts({"A": 7})},                      # verdict 누락
    {"winner": "A", "ranking": ["A", "B"],
     "verdicts": _verdicts({"A": 7, "B": 3}) + _verdicts({"A": 1})},  # 중복
    {"winner": "A", "ranking": ["A"],
     "verdicts": _verdicts({"A": 7, "B": 3})},              # 비 permutation
    {"winner": "Z", "ranking": ["A", "B"],
     "verdicts": _verdicts({"A": 7, "B": 3})},              # winner 무효
])
def test_normalize_flip_verdict_malformed_fail_closed(raw):
    from app.modules.pipeline.multiroll_select import normalize_flip_verdict

    with pytest.raises(ValueError):
        normalize_flip_verdict(raw, {"A": "B", "B": "A"}, ["A", "B"])


def test_combine_flip_verdicts_agreement():
    from app.modules.pipeline.multiroll_select import combine_flip_verdicts

    fwd = {"winner": "A", "ranking": ["A", "B"],
           "verdicts": _verdicts({"A": 9, "B": 5})}
    rev = {"winner": "A", "ranking": ["A", "B"],
           "verdicts": _verdicts({"A": 8, "B": 6})}
    selected, combined = combine_flip_verdicts(fwd, rev, ["A", "B"], ["A", "B"])
    assert selected == "A"
    assert combined["agreement"] is True
    assert combined["totals"] == {"A": 17, "B": 11}


def test_combine_flip_verdicts_disagreement_sums_scores():
    from app.modules.pipeline.multiroll_select import combine_flip_verdicts

    fwd = {"winner": "A", "ranking": ["A", "B"],
           "verdicts": _verdicts({"A": 9, "B": 5})}
    rev = {"winner": "B", "ranking": ["B", "A"],
           "verdicts": _verdicts({"A": 2, "B": 10})}
    selected, combined = combine_flip_verdicts(fwd, rev, ["A", "B"], ["A", "B"])
    assert combined["agreement"] is False
    assert combined["totals"] == {"A": 11, "B": 15}
    assert selected == "B"


def test_combine_flip_verdicts_tie_uses_priority():
    from app.modules.pipeline.multiroll_select import combine_flip_verdicts

    fwd = {"winner": "A", "ranking": ["A", "B"],
           "verdicts": _verdicts({"A": 9, "B": 5})}
    rev = {"winner": "B", "ranking": ["B", "A"],
           "verdicts": _verdicts({"A": 3, "B": 7})}
    # 합산 A=12, B=12 동점
    sel_ab, _ = combine_flip_verdicts(fwd, rev, ["A", "B"], ["A", "B"])
    assert sel_ab == "A"
    sel_ba, _ = combine_flip_verdicts(fwd, rev, ["A", "B"], ["B", "A"])
    assert sel_ba == "B"


def test_judge_flip_two_calls_reversed_and_record(tmp_path):
    """정순+역순 2회 — 역순은 후보 반전 제시, record 에 raw/normalized
    병록, 정순 직후 중간 persist (NARROW-2)."""
    (tmp_path / "ref.png").write_bytes(b"ref")
    judge = FlipJudge([
        {"winner": "A", "ranking": ["A", "B"],
         "verdicts": _verdicts({"A": 9, "B": 5})},
        # 역순 display: A=canonical B, B=canonical A → display A 승
        # = canonical B 승 (불일치 → 합산 A=9+6=15, B=5+7=12 → A)
        {"winner": "A", "ranking": ["A", "B"],
         "verdicts": _verdicts({"A": 7, "B": 6})},
    ])
    persisted: List[Dict[str, Any]] = []

    def persist(rec):
        import copy
        persisted.append(copy.deepcopy(rec))

    sel, record = run(
        tmp_path, roll_count=2, judge_fn=judge, judge_flip=True,
        persist_record_fn=persist,
    )
    assert len(judge.calls) == 2
    assert judge.calls[0]["cands"] == ["s1_a.png", "s1_b.png"]
    assert judge.calls[1]["cands"] == ["s1_b.png", "s1_a.png"]  # 반전 제시
    jf = record["judge_flip"]
    assert jf["forward_raw"]["winner"] == "A"
    assert jf["reverse_raw"]["winner"] == "A"
    assert jf["reverse_normalized"]["winner"] == "B"
    assert jf["combined"]["totals"] == {"A": 15, "B": 12}
    assert record["selected"] == "A"
    assert sel.read_bytes() == b"img:a"
    # 정순 완료 직후 중간 persist — 역순 판정 전에 forward_raw 가 durable
    assert any(
        "judge_flip" in p and "reverse_raw" not in p["judge_flip"]
        for p in persisted
    )


# ── parallel_rolls — 병렬 생성·결정론 오류·전파 ───────────────────────


def test_parallel_rolls_overlap_and_all_generated(tmp_path):
    """2롤이 실제 동시 실행됨을 Barrier 로 증명 (순차면 timeout)."""
    import threading

    barrier = threading.Barrier(2)
    (tmp_path / "ref.png").write_bytes(b"ref")

    def gen_fn(tag, prompt, labeled_refs, out_path: Path):
        barrier.wait(timeout=10)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(f"img:{out_path.stem[-1]}".encode())
        return out_path

    sel, record = run(
        tmp_path, roll_count=2, gen_fn=gen_fn,
        judge_fn=make_judge("A", {"A": 9, "B": 5}, ["A", "B"]),
        parallel_rolls=True,
    )
    assert sel.read_bytes() == b"img:a"


def test_parallel_rolls_error_is_canonical_first(tmp_path):
    """b·c 둘 다 실패 시 completion timing 무관 canonical 순(b) 예외."""
    (tmp_path / "ref.png").write_bytes(b"ref")

    class Boom(RuntimeError):
        pass

    def gen_fn(tag, prompt, labeled_refs, out_path: Path):
        lab = out_path.stem.rsplit("_", 1)[-1]
        if lab in ("b", "c"):
            raise Boom(f"fail:{lab}")
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"img:a")
        return out_path

    with pytest.raises(Boom, match="fail:b"):
        run(tmp_path, gen_fn=gen_fn, parallel_rolls=True)
    # 성공 롤 파일은 보존 — resume 재사용
    assert (tmp_path / "out" / "s1_a.png").exists()


def test_parallel_rolls_propagates_budget_and_capture_context(tmp_path):
    """BLOCKING-3: worker 에서 budget 과 generation_context 둘 다 관측."""
    from app.core.image_call_budget import (
        ImageCallBudget,
        get_current_budget,
        install_budget,
        uninstall_budget,
    )
    from app.services.image_capture.context import (
        current_context,
        generation_context,
    )

    (tmp_path / "ref.png").write_bytes(b"ref")
    seen: List[Tuple[Any, Any]] = []

    def gen_fn(tag, prompt, labeled_refs, out_path: Path):
        seen.append((get_current_budget(), current_context()))
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(f"img:{out_path.stem[-1]}".encode())
        return out_path

    budget = ImageCallBudget(cap=10)
    install_budget(budget)
    try:
        with generation_context("p1", "e1", "still_recipe"):
            run(
                tmp_path, roll_count=2, gen_fn=gen_fn,
                judge_fn=make_judge("A", {"A": 9, "B": 5}, ["A", "B"]),
                parallel_rolls=True,
            )
    finally:
        uninstall_budget()
    assert len(seen) == 2
    for b, ctx in seen:
        assert b is budget
        assert ctx is not None and ctx.stage == "still_recipe"


def test_judge_flip_resume_reuses_forward_judgement(tmp_path):
    """리뷰 BLOCKING-1: 정순 판정 직후 crash → resume 은 durable
    forward record 재사용(정순 재호출 0) + reverse 만 호출."""
    (tmp_path / "ref.png").write_bytes(b"ref")
    fwd_result = {"winner": "A", "ranking": ["A", "B"],
                  "verdicts": _verdicts({"A": 9, "B": 5})}

    class CrashAfterForward:
        def __init__(self):
            self.calls = 0

        def __call__(self, tag, prompt, labeled_refs, cand_paths, labels):
            self.calls += 1
            if self.calls == 1:
                return fwd_result
            raise RuntimeError("crash before reverse")

    persisted: List[Dict[str, Any]] = []

    def persist(rec):
        import copy
        persisted.append(copy.deepcopy(rec))

    crash_judge = CrashAfterForward()
    with pytest.raises(RuntimeError, match="crash before reverse"):
        run(
            tmp_path, roll_count=2, judge_fn=crash_judge, judge_flip=True,
            persist_record_fn=persist,
        )
    durable = persisted[-1]
    assert durable["judge_flip"]["forward_raw"] == fwd_result
    # 양회 raw+normalized 병록 계약 — forward 도 normalized 저장
    assert durable["judge_flip"]["forward_normalized"]["winner"] == "A"

    # resume: 정순 재호출 0 — reverse 1콜만, gen 0콜
    class ReverseOnly:
        def __init__(self):
            self.calls = []

        def __call__(self, tag, prompt, labeled_refs, cand_paths, labels):
            self.calls.append([Path(p).name for p in cand_paths])
            return {"winner": "A", "ranking": ["A", "B"],
                    "verdicts": _verdicts({"A": 7, "B": 6})}

    rev_judge = ReverseOnly()
    gen2 = FakeGen()
    sel, record = run(
        tmp_path, roll_count=2, judge_fn=rev_judge, judge_flip=True,
        gen_fn=gen2, record=durable, persist_record_fn=persist,
    )
    assert gen2.calls == []                     # 롤 재생성 0
    assert rev_judge.calls == [["s1_b.png", "s1_a.png"]]  # reverse 1콜만
    jf = record["judge_flip"]
    assert jf["forward_raw"] == fwd_result      # 재사용 — 덮어쓰기 아님
    assert jf["reverse_normalized"]["winner"] == "B"
    assert jf["combined"]["totals"] == {"A": 15, "B": 12}
    assert record["selected"] == "A"


def test_judge_flip_malformed_durable_forward_reauthors(tmp_path):
    """지문 일치라도 durable forward 가 malformed 면 정순 재판정
    (영구 차단 금지 — 안전측 재저작)."""
    (tmp_path / "ref.png").write_bytes(b"ref")
    good = {"winner": "A", "ranking": ["A", "B"],
            "verdicts": _verdicts({"A": 9, "B": 5})}
    judge = FlipJudge([good, good])  # 정순+역순 재호출 기대
    # 선행 run 으로 지문 일치 record 확보
    _, base_record = run(tmp_path, roll_count=2,
                         judge_fn=FlipJudge([good, good]), judge_flip=True)
    (tmp_path / "out" / "s1_sel.png").unlink()  # 판정 재수행 유도
    corrupted = dict(base_record)
    corrupted["judge_flip"] = {"forward_raw": {"winner": "Z"}}
    corrupted.pop("selected", None)
    sel, record = run(
        tmp_path, roll_count=2, judge_fn=judge, judge_flip=True,
        record=corrupted,
    )
    assert len(judge.calls) == 2  # malformed → 정순부터 재판정
    assert record["judge_flip"]["forward_raw"] == good


@pytest.mark.parametrize("raw", [
    "bad",                                                  # 비 dict 판정
    {"winner": "A", "ranking": ["A", "B"],
     "verdicts": [None, {"label": "B", "score": 3,
                         "verdict_ko": ""}]},               # verdict 비 dict
    {"winner": "A", "ranking": ["A", 1],
     "verdicts": _verdicts({"A": 7, "B": 3})},              # ranking mixed
    {"winner": "A", "ranking": "AB",
     "verdicts": _verdicts({"A": 7, "B": 3})},              # ranking 비 list
    {"winner": "A", "ranking": ["A", "B"],
     "verdicts": {"A": 7}},                                 # verdicts 비 list
    {"winner": "A", "ranking": ["A", "B"],
     "verdicts": [{"label": "A", "score": "9", "verdict_ko": ""},
                  {"label": "B", "score": 3, "verdict_ko": ""}]},  # score 비 int
])
def test_normalize_flip_verdict_type_malformed_is_value_error(raw):
    """재리뷰 NARROW-1: 타입 손상도 전부 ValueError 로 귀결 — 재사용
    경로(except ValueError)가 회복 가능해야 durable 영구 차단이 없다."""
    from app.modules.pipeline.multiroll_select import normalize_flip_verdict

    with pytest.raises(ValueError):
        normalize_flip_verdict(raw, {"A": "B", "B": "A"}, ["A", "B"])


@pytest.mark.parametrize("bad_jf", [
    "corrupted",                                            # truthy 비 dict 컨테이너
    {"forward_raw": "bad"},                                 # 비 dict 판정
    {"forward_raw": {"winner": "A", "ranking": ["A", 1],
                     "verdicts": _verdicts({"A": 9, "B": 5})}},  # mixed ranking
    {"forward_raw": {"winner": "A", "ranking": ["A", "B"],
                     "verdicts": [None]}},                  # verdict 비 dict
])
def test_judge_flip_corrupted_durable_recovers_with_full_rejudge(
        tmp_path, bad_jf):
    """손상 durable(비 dict 컨테이너/타입 손상 판정)에서도 resume 이
    정순 1콜+역순 1콜로 회복 — 예외 영구 차단 금지."""
    (tmp_path / "ref.png").write_bytes(b"ref")
    good = {"winner": "A", "ranking": ["A", "B"],
            "verdicts": _verdicts({"A": 9, "B": 5})}
    _, base_record = run(tmp_path, roll_count=2,
                         judge_fn=FlipJudge([good, good]), judge_flip=True)
    (tmp_path / "out" / "s1_sel.png").unlink()
    corrupted = dict(base_record)
    corrupted["judge_flip"] = bad_jf
    corrupted.pop("selected", None)
    judge = FlipJudge([good, good])
    _, record = run(
        tmp_path, roll_count=2, judge_fn=judge, judge_flip=True,
        record=corrupted,
    )
    assert len(judge.calls) == 2  # 정순+역순 회복
    assert record["judge_flip"]["forward_raw"] == good
    assert record["selected"] == "A"


# ── fix-rejudge (E2E10 fix②): 수정본 포함 재판정 ──────────────────────


def make_rejudge(prefer_bytes: bytes):
    """2후보 재판정 fake — 지정 바이트를 가진 후보를 항상 승자로.

    정순·역순 표시 순서와 무관하게 내용 기반 판정 → flip 합의 성립.
    """
    calls: List[Dict[str, Any]] = []

    def rejudge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls.append({"tag": tag, "cands": [str(p) for p in cand_paths]})
        idx = next(
            i for i, p in enumerate(cand_paths)
            if Path(p).read_bytes() == prefer_bytes
        )
        win = labels[idx]
        return {
            "winner": win,
            "ranking": sorted(labels, key=lambda l: l != win),
            "verdicts": [
                {"label": l, "score": 9 if l == win else 4,
                 "verdict_ko": ""}
                for l in labels
            ],
        }

    rejudge_fn.calls = calls
    return rejudge_fn


def test_fix_rejudge_keeps_original_when_fix_worse(tmp_path):
    rejudge = make_rejudge(b"img:b")  # 선정 원본(B롤) 선호
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=rejudge,
    )
    assert record["fix_rejudge"]["fix_won"] is False
    assert record["fix_rejudge"]["winner"] == "A"  # canonical A=원본
    assert sel.read_bytes() == b"img:b"
    assert record["fix_prompt"]  # 수정 시도 자체는 기록
    assert len(rejudge.calls) == 2  # 정순+역순
    # 수정본 파일은 감사용으로 보존
    assert (tmp_path / "out" / "s1_fix.png").exists()


def test_fix_rejudge_places_fix_when_fix_better(tmp_path):
    rejudge = make_rejudge(b"img:fix")
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=rejudge,
    )
    assert record["fix_rejudge"]["fix_won"] is True
    assert record["fix_rejudge"]["winner"] == "B"  # canonical B=수정본
    assert sel.read_bytes() == b"img:fix"


def test_fix_rejudge_absent_keeps_legacy_behavior(tmp_path):
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=FakeGen(),
    )
    assert "fix_rejudge" not in record
    assert sel.read_bytes() == b"img:fix"  # 기존: 수정본 무판정 확정


def test_fix_rejudge_no_issues_skips_rejudge(tmp_path):
    rejudge = make_rejudge(b"img:b")
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=rejudge,
    )
    assert record.get("fix_skipped") is True
    assert "fix_rejudge" not in record
    assert rejudge.calls == []


def test_fix_rejudge_changes_fingerprint_only_when_enabled(tmp_path):
    for d in ("off", "on", "off2"):
        (tmp_path / d).mkdir()
    _, rec_off = run(tmp_path / "off", critique_enabled=False)
    _, rec_on = run(
        tmp_path / "on",
        critique_enabled=False,
        fix_rejudge_fn=make_rejudge(b"img:b"),
    )
    assert rec_off["input_fingerprint"] != rec_on["input_fingerprint"]
    _, rec_off2 = run(tmp_path / "off2", critique_enabled=False)
    assert rec_off["input_fingerprint"] == rec_off2["input_fingerprint"]


# ── GPT 구도 critique 합류 (E2E11 fix③) ──────────────────────────────


def make_comp_critique(issues: List[Dict[str, str]]):
    calls: List[Dict[str, Any]] = []

    def comp_fn(tag, prompt, labeled_refs, image_path):
        calls.append({"tag": tag, "image": image_path})
        return {"issues": issues}

    comp_fn.calls = calls
    return comp_fn


def test_composition_issues_merged_into_single_fix_prompt(tmp_path):
    comp = make_comp_critique([{"fix_en": "move the camera lower"}])
    gen = FakeGen()
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=gen,
        composition_critique_fn=comp,
    )
    fp = record["fix_prompt"]
    assert "remove artifact" in fp and "move the camera lower" in fp
    assert fp.index("remove artifact") < fp.index("move the camera lower")
    assert record["composition_critique"]["issues"]
    assert len(comp.calls) == 1 and comp.calls[0]["tag"].endswith("_comp")


def test_composition_only_issues_still_trigger_fix(tmp_path):
    comp = make_comp_critique([{"fix_en": "shift subject to the left"}])
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([]),  # Gemini 결함 0
        fix_gen_fn=FakeGen(),
        composition_critique_fn=comp,
    )
    assert "fix_skipped" not in record
    assert "shift subject to the left" in record["fix_prompt"]
    assert sel.read_bytes() == b"img:fix"


def test_composition_none_both_empty_skips_fix(tmp_path):
    comp = make_comp_critique([])
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([]),
        fix_gen_fn=FakeGen(),
        composition_critique_fn=comp,
    )
    assert record.get("fix_skipped") is True
    assert sel.read_bytes() == b"img:b"


def test_composition_changes_fingerprint_only_when_enabled(tmp_path):
    for d in ("off", "on"):
        (tmp_path / d).mkdir()
    _, rec_off = run(tmp_path / "off", critique_enabled=False)
    _, rec_on = run(
        tmp_path / "on", critique_enabled=False,
        composition_critique_fn=make_comp_critique([]),
    )
    assert rec_off["input_fingerprint"] != rec_on["input_fingerprint"]


# ── E2E13 fix⑤: unfixable(시점 이동류) 이슈는 i2i fix 에서 제외 ────────


def test_critique_schema_has_optional_unfixable():
    sch = build_critique_schema()
    item = sch["properties"]["issues"]["items"]
    assert "unfixable" in item["properties"]
    assert item["properties"]["unfixable"] == {"type": "boolean"}
    assert "unfixable" not in item["required"]


def test_unfixable_issues_excluded_from_fix_prompt(tmp_path):
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([
            {"fix_en": "remove artifact"},
            {"fix_en": "move the camera up", "unfixable": True},
        ]),
        fix_gen_fn=FakeGen(),
    )
    assert "remove artifact" in record["fix_prompt"]
    assert "move the camera up" not in record["fix_prompt"]
    assert sel.read_bytes() == b"img:fix"


def test_all_unfixable_skips_fix_keeps_original(tmp_path):
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([
            {"fix_en": "move the camera up", "unfixable": True},
        ]),
        fix_gen_fn=FakeGen(),
    )
    assert record.get("fix_skipped") is True
    assert record.get("fix_skip_reason") == "all_issues_unfixable"
    assert "fix_prompt" not in record
    assert sel.read_bytes() == b"img:b"


def test_composition_unfixable_issue_excluded_from_fix_prompt(tmp_path):
    """Codex BLOCKING-2 (E2E13): GPT 구도 critique 경로도 unfixable 방어를
    통과해야 한다 — 시점 이동류 구도 결함이 fix 프롬프트로 새지 않게."""
    comp = make_comp_critique([
        {"fix_en": "shift subject to the left"},
        {"fix_en": "move the camera to the upper landing",
         "unfixable": True},
    ])
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=FakeGen(),
        composition_critique_fn=comp,
    )
    fp = record["fix_prompt"]
    assert "remove artifact" in fp and "shift subject to the left" in fp
    assert "move the camera to the upper landing" not in fp
    assert sel.read_bytes() == b"img:fix"


def test_composition_all_unfixable_skips_fix(tmp_path):
    comp = make_comp_critique([
        {"fix_en": "move the camera lower", "unfixable": True},
    ])
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([]),
        fix_gen_fn=FakeGen(),
        composition_critique_fn=comp,
    )
    assert record.get("fix_skipped") is True
    assert record.get("fix_skip_reason") == "all_issues_unfixable"
    assert "fix_prompt" not in record
    assert sel.read_bytes() == b"img:b"


# ── critique 공유 문안의 totality (2026-08-01 Codex 2차 재리뷰 NARROW-3) ──
# `shared_prompt or prompt` 는 빈 문자열을 falsy 로 흘려 원 prompt 를 쓴다.
# 그런데 지문 쪽은 `is not None` 으로 갈라 **빈 문자열도 별개 계약**으로
# 기록한다. 즉 같은 입력이 지문에서는 다르고 실행에서는 같아진다 — 기록이
# 실행을 대변하지 못하는 공용 API 결함이다.

def test_explicit_empty_shared_prompt_does_not_fall_back_to_prompt():
    from app.modules.pipeline.multiroll_select import _compose_critique_prompt

    got = _compose_critique_prompt(
        "BASE PROMPT", {"a": "ROLL A"}, "a", shared_prompt="")
    assert "BASE PROMPT" not in got
    assert got.strip() == "ROLL A"


def test_default_shared_prompt_still_uses_base_prompt():
    """None(기존 동작)은 byte 동일하게 유지한다."""
    from app.modules.pipeline.multiroll_select import _compose_critique_prompt

    assert _compose_critique_prompt(
        "BASE PROMPT", {"a": "ROLL A"}, "a") == "ROLL A\n\nBASE PROMPT"


def test_explicit_shared_prompt_replaces_base_prompt():
    from app.modules.pipeline.multiroll_select import _compose_critique_prompt

    assert _compose_critique_prompt(
        "BASE PROMPT", {"a": "ROLL A"}, "a",
        shared_prompt="SHARED") == "ROLL A\n\nSHARED"


# ── 관할절이 각 경로에 정확히 1회 (Codex 1차 HIGH-5 / 2차 NARROW-3 회귀) ──
# 이 계약은 지금까지 **스텝 레벨에서 multiroll 을 mock 한 채로만** 검증됐다.
# 그러면 조립을 실제로 수행하는 `run_multiroll_select` 본문은 아무도 지키지
# 않는다. 여기서는 fake gen/judge/critique 로 **진짜 본문을 실행**해 잠근다.
#
# seed 스텝의 호출 모양: 절이 base prompt 와 전 롤 프롬프트 양쪽에 실리고,
# critique 의 공유 문안만 **절 없는 원 브리프**를 받는다. 공유 문안을 안 주면
# critique 입력에서만 절이 2회가 되어 판정 무게가 왜곡된다.

_CLAUSE = "SAMPLE-FIXTURE-JURISDICTION-CLAUSE"


def _recording_fakes():
    prompts: Dict[str, List[str]] = {"gen": [], "judge": [], "critique": []}
    gen = FakeGen()
    inner_gen = gen.__call__

    def gen_fn(tag, prompt, labeled_refs, out_path):
        prompts["gen"].append(prompt)
        return inner_gen(tag, prompt, labeled_refs, out_path)

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        prompts["judge"].append(prompt)
        return {"winner": "B", "ranking": ["B", "A", "C"],
                "verdicts": [{"label": lab, "score": sc, "verdict_ko": "ok"}
                             for lab, sc in (("A", 5), ("B", 9), ("C", 7))]}

    def critique_fn(tag, prompt, labeled_refs, image_path):
        prompts["critique"].append(prompt)
        return {"issues": []}

    return prompts, gen_fn, judge_fn, critique_fn


def test_clause_reaches_generation_judge_and_critique_exactly_once(tmp_path):
    brief = "SEED BRIEF BODY"
    prompts, gen_fn, judge_fn, critique_fn = _recording_fakes()
    labels = roll_labels(3)
    run(
        tmp_path,
        prompt=f"{brief}\n\n{_CLAUSE}",
        roll_prompts={lab: f"ROLL {lab}\n\n{_CLAUSE}" for lab in labels},
        critique_shared_prompt=brief,
        gen_fn=gen_fn,
        judge_fn=judge_fn,
        critique_fn=critique_fn,
        critique_enabled=True,
        fix_gen_fn=FakeGen(),
    )
    assert [p.count(_CLAUSE) for p in prompts["gen"]] == [1, 1, 1]
    assert [p.count(_CLAUSE) for p in prompts["judge"]] == [1]
    assert [p.count(_CLAUSE) for p in prompts["critique"]] == [1]


def test_without_shared_prompt_the_clause_doubles_in_critique(tmp_path):
    """공유 문안을 안 주면 왜 2회가 되는지 — 계약의 근거를 남긴다."""
    prompts, gen_fn, judge_fn, critique_fn = _recording_fakes()
    labels = roll_labels(3)
    run(
        tmp_path,
        prompt=f"SEED BRIEF BODY\n\n{_CLAUSE}",
        roll_prompts={lab: f"ROLL {lab}\n\n{_CLAUSE}" for lab in labels},
        gen_fn=gen_fn,
        judge_fn=judge_fn,
        critique_fn=critique_fn,
        critique_enabled=True,
        fix_gen_fn=FakeGen(),
    )
    assert prompts["critique"][0].count(_CLAUSE) == 2


def test_resume_replays_the_record_without_recomposing_any_prompt(tmp_path):
    """재개는 **아무 프롬프트도 다시 조립하지 않는다** — 그래서 회차 간
    절 개수가 흔들릴 수 없다.

    ★처음에 "재개도 절이 1회"라고 단언했는데 그건 공허하게 통과했다 —
    재개 경로는 critique 를 아예 호출하지 않기 때문이다(실측: gen 0 ·
    judge 0 · critique 0). 재현되지 않은 경로에 대한 단언은 계약을 지키는
    것이 아니라 지키는 척한다. 실제 계약을 잠근다.
    """
    brief = "SEED BRIEF BODY"
    labels = roll_labels(3)
    common = dict(
        prompt=f"{brief}\n\n{_CLAUSE}",
        roll_prompts={lab: f"ROLL {lab}\n\n{_CLAUSE}" for lab in labels},
        critique_shared_prompt=brief,
        critique_enabled=True,
    )
    prompts, gen_fn, judge_fn, critique_fn = _recording_fakes()
    _sel, record = run(tmp_path, gen_fn=gen_fn, judge_fn=judge_fn,
                       critique_fn=critique_fn, fix_gen_fn=FakeGen(),
                       **common)
    assert prompts["critique"][0].count(_CLAUSE) == 1

    prompts2, gen_fn2, judge_fn2, critique_fn2 = _recording_fakes()
    _sel2, record2 = run(tmp_path, gen_fn=gen_fn2, judge_fn=judge_fn2,
                         critique_fn=critique_fn2, fix_gen_fn=FakeGen(),
                         record=dict(record), **common)
    assert (prompts2["gen"], prompts2["judge"], prompts2["critique"]) == (
        [], [], [])
    assert record2["selected"] == record["selected"]


# ── 편집에 참조 동봉 (2026-08-07, 팩 v7) ───────────────────────────────
#
# 결함의 형태: critique 는 `labeled_refs` 전체를 보고 지적하는데 편집은 선정
# 원본 한 장만 받아, 지적이 글자로만 전달됐다. 실측 4건이 전부 그 자리에서
# 났다(비니·의상·머리 모양·비석 글자). 여기서 잠그는 것은 **무엇이 편집
# 호출에 실리는가**뿐이다 — 그림이 나아지는지는 육안이 판정한다.


def _fix_call(gen: FakeGen):
    """FakeGen 호출 중 편집(_fix) 건."""
    return next(c for c in gen.calls if c[0].endswith("_fix"))


def test_fix_carries_critique_refs_when_label_given(tmp_path):
    fix_gen = FakeGen()
    refs = [("CHARACTER REFERENCE — 김", tmp_path / "c.png"),
            ("PREVIOUS SHOT STILL", tmp_path / "p.png")]
    for _, p in refs:
        p.write_bytes(b"x")
    run(tmp_path,
        labeled_refs=refs,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "match the beanie"}]),
        fix_gen_fn=fix_gen,
        fix_ref_label="CONTEXT REFERENCE — DO NOT EDIT. ORIGINAL LABEL —")
    _, _, labeled, _ = _fix_call(fix_gen)
    # 편집 대상이 첫 장 — i2i 는 첫 이미지를 편집한다
    assert labeled[0][0] == FIX_LABEL
    assert labeled[0][1].name == "s1_b.png"      # 선정 원본(B롤)
    # 참조는 뒤에, 편집 대상이 아님을 라벨이 못 박은 채로
    assert len(labeled) == 3
    assert all(lab.startswith("CONTEXT REFERENCE") for lab, _ in labeled[1:])
    assert "CHARACTER REFERENCE — 김" in labeled[1][0]
    assert [p.name for _, p in labeled[1:]] == ["c.png", "p.png"]


def test_fix_without_label_keeps_original_only(tmp_path):
    """빈 라벨 = 기존 동작. 팩을 내렸을 때 조용히 참조가 새지 않는다."""
    fix_gen = FakeGen()
    refs = [("CHARACTER REFERENCE — 김", tmp_path / "c.png")]
    refs[0][1].write_bytes(b"x")
    run(tmp_path,
        labeled_refs=refs,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "match the beanie"}]),
        fix_gen_fn=fix_gen)
    _, _, labeled, _ = _fix_call(fix_gen)
    assert [lab for lab, _ in labeled] == [FIX_LABEL]


def test_fix_ref_count_recorded(tmp_path):
    """감사 기록 — 몇 장이 편집에 실렸는지 record 로 되짚을 수 있어야 한다."""
    refs = [("R1", tmp_path / "c.png"), ("R2", tmp_path / "p.png")]
    for _, p in refs:
        p.write_bytes(b"x")
    _, record = run(
        tmp_path, labeled_refs=refs, critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "x"}]),
        fix_gen_fn=FakeGen(), fix_ref_label="CTX")
    assert record["fix_ref_count"] == 3


# ── 재판정 동점은 원본 유지 (2026-08-07 정책 반전) ─────────────────────


def make_tie_rejudge():
    """모든 후보에 같은 점수 — 정순·역순 승자가 갈려 동점 규칙이 결정한다."""
    calls: List[Dict[str, Any]] = []

    def rejudge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls.append({"tag": tag})
        return {
            "winner": labels[0],
            "ranking": list(labels),
            "verdicts": [{"label": l, "score": 7, "verdict_ko": ""}
                         for l in labels],
        }

    rejudge_fn.calls = calls
    return rejudge_fn


def test_fix_rejudge_tie_keeps_original(tmp_path):
    """대등 = 지적이 해소됐다는 증거가 아니다 — 덜 바꾼 쪽(원본)을 남긴다."""
    sel, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "remove artifact"}]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=make_tie_rejudge(),
    )
    assert record["fix_rejudge"]["winner"] == "A"   # canonical A=원본
    assert record["fix_rejudge"]["fix_won"] is False
    assert sel.read_bytes() == b"img:b"             # 선정 원본(B롤) 그대로


# ── 판정 스키마 PHYSICS 축 (팩 v7) ────────────────────────────────────


def test_judge_schema_requires_physics_reading():
    s = build_judge_schema(["A", "B"], with_physics=True)
    item = s["properties"]["readings"]["items"]
    assert "physics" in item["properties"]
    assert "physics" in item["required"]
    # 기존 세 축은 그대로
    for axis in ("direction", "built_space", "entities"):
        assert axis in item["required"]


def test_judge_schema_physics_defaults_off_for_non_still():
    """기본 False — v6 프롬프트를 쓰는 씨드·플레이트에 없는 축을 요구하지
    않는다. selector 를 갈랐으면 스키마도 갈라야 한다(Codex 2차 리뷰)."""
    on = build_judge_schema(["A", "B"], with_physics=True)
    off = build_judge_schema(["A", "B"])
    item = off["properties"]["readings"]["items"]
    assert "physics" not in item["properties"]
    assert "physics" not in item["required"]
    assert on != off


# ── 심판의 "전 후보 실패" 선언을 버리지 않는다 (2026-08-07 Codex 리뷰) ──
#
# 판정 팩은 least-bad 를 좋다고 하지 말고 그렇게 선언하라고 하고, 스키마도
# 필수로 요구한다. 그런데 소비처가 0이라 선언이 그대로 사라졌다 — 자동차
# 기하 붕괴처럼 후보 전체가 실패한 샷이 정상 선정본과 구분되지 않았다.


def make_judge_fail_all(winner: str, scores: Dict[str, int],
                        ranking: List[str], fail: bool = True):
    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        return {
            "winner": winner, "ranking": ranking,
            "verdicts": [{"label": l, "score": s, "verdict_ko": ""}
                         for l, s in scores.items()],
            "all_candidates_fail": fail,
            "readings": [{"label": l, "direction": "", "built_space": "",
                          "entities": "", "physics": "떠 있고 받치는 것이 없다",
                          "hard_violations": []} for l in labels],
        }
    return judge_fn


def test_all_candidates_fail_is_recorded(tmp_path):
    _, record = run(
        tmp_path,
        judge_fn=make_judge_fail_all("B", {"A": 3, "B": 4, "C": 2},
                                     ["B", "A", "C"]))
    # 단계 이름이 붙는다 — 이 값은 **초기 롤** 판정이지 최종 산출이 아니다
    assert record["initial_roll_all_fail"] is True
    assert "all_candidates_fail" not in record
    # 수정을 안 돌린 경로라 최종 = 초기 선정본 → 재촬영 대상
    assert record["needs_reshoot"] is True
    # 서술도 함께 남아야 재촬영 선별이 근거를 가진다
    assert record["readings"][0]["physics"]


def test_no_flag_when_judge_does_not_declare(tmp_path):
    _, record = run(
        tmp_path,
        judge_fn=make_judge_fail_all("B", {"A": 3, "B": 9, "C": 2},
                                     ["B", "A", "C"], fail=False))
    assert "initial_roll_all_fail" not in record
    assert "needs_reshoot" not in record


def test_normalize_flip_keeps_fail_and_readings():
    from app.modules.pipeline.multiroll_select import normalize_flip_verdict

    raw = {
        "winner": "A", "ranking": ["A", "B"],
        "verdicts": [{"label": "A", "score": 5, "verdict_ko": ""},
                     {"label": "B", "score": 4, "verdict_ko": ""}],
        "all_candidates_fail": True,
        "readings": [{"label": "A", "physics": "x"},
                     {"label": "B", "physics": "y"}],
    }
    out = normalize_flip_verdict(raw, {"A": "B", "B": "A"}, ["A", "B"])
    assert out["all_candidates_fail"] is True
    # 라벨 역매핑이 readings 에도 적용돼야 한다
    assert [r["label"] for r in out["readings"]] == ["B", "A"]


def test_flip_requires_both_directions_to_declare(tmp_path):
    """한쪽만 선언하면 참으로 보지 않는다 — 한쪽 기준은 신호를 무의미하게 한다."""
    calls = {"n": 0}

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls["n"] += 1
        return {
            "winner": "A", "ranking": ["A", "B", "C"],
            "verdicts": [{"label": l, "score": 5, "verdict_ko": ""}
                         for l in labels],
            "all_candidates_fail": calls["n"] == 1,   # 정순만 선언
        }

    _, record = run(tmp_path, judge_fn=judge_fn, judge_flip=True,
                    flip_priority=["A", "B", "C"])
    assert "initial_roll_all_fail" not in record


# ── needs_reshoot 는 최종 산출 기준 (2026-08-07 Codex 2차 리뷰) ─────────
#
# 단계별 선언을 그대로 두면 두 방향으로 오독된다. 두 경우를 다 잠근다.


def test_reshoot_cleared_when_fix_rescues_a_failed_roll(tmp_path):
    """초기 롤 전부 실패 → 수정본이 정상 승리 = 재촬영 불필요."""
    rejudge = make_rejudge(b"img:fix")     # 수정본 승
    _, record = run(
        tmp_path,
        judge_fn=make_judge_fail_all("B", {"A": 3, "B": 4, "C": 2},
                                     ["B", "A", "C"]),
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "x"}]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=rejudge,
    )
    assert record["initial_roll_all_fail"] is True   # 초기 사실은 남는다
    assert record["fix_rejudge"]["fix_won"] is True
    assert "needs_reshoot" not in record             # 최종은 멀쩡하다


def test_reshoot_set_when_both_original_and_fix_fail(tmp_path):
    """초기는 통과했는데 원본·수정본이 재판정에서 둘 다 실패 = 재촬영."""
    def rejudge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        return {
            "winner": labels[0], "ranking": list(labels),
            "verdicts": [{"label": l, "score": 3, "verdict_ko": ""}
                         for l in labels],
            "all_candidates_fail": True,
        }

    _, record = run(
        tmp_path,
        critique_enabled=True,
        critique_fn=make_critique([{"fix_en": "x"}]),
        fix_gen_fn=FakeGen(),
        fix_rejudge_fn=rejudge_fn,
    )
    assert "initial_roll_all_fail" not in record     # 초기엔 선언 없었다
    assert record["fix_rejudge"]["all_candidates_fail"] is True
    assert record["needs_reshoot"] is True           # 최종이 실패했다


def test_reshoot_computed_on_resume_paths(tmp_path):
    """재개 경로에서도 최종 판정이 남아야 한다 (2026-08-07 Codex 3차).

    빠지면 **크래시 여부에 따라 같은 이미지가** 재촬영 대상/비대상으로
    다르게 읽힌다.
    """
    # 1회차: 선정까지만 하고 critique 없이 종료(sel 존재 + critique 미실행)
    _, rec1 = run(
        tmp_path,
        judge_fn=make_judge_fail_all("B", {"A": 3, "B": 4, "C": 2},
                                     ["B", "A", "C"]),
        critique_enabled=False)
    assert rec1["initial_roll_all_fail"] is True
    assert rec1["needs_reshoot"] is True

    # 2회차: 같은 record 로 재개 — critique 켜고 지적 0건
    rec2 = dict(rec1)
    rec2.pop("critique_skipped", None)
    rec2.pop("needs_reshoot", None)
    _, out = run(
        tmp_path,
        judge_fn=make_judge_fail_all("B", {"A": 3, "B": 4, "C": 2},
                                     ["B", "A", "C"]),
        critique_enabled=True,
        critique_fn=make_critique([]),
        fix_gen_fn=FakeGen(),
        record=rec2)
    assert out["needs_reshoot"] is True   # 재개해도 판정이 남는다


# ── _clear_outputs allowlist (2026-08-14 S18sh5/S79sh1/S84sh2 실측) ───

def test_clear_outputs_spares_non_multiroll_siblings(tmp_path):
    """지문 mismatch 정리는 **자기 산출만** 지운다 — 광역 `{stem}_*` glob
    이 confined 도면(`_confinedfp`, 밑줄 하나라 `__` 가드 밖)을 지워 롤
    생성이 입력 결손으로 죽고 resume 마다 결정론 재발했다(1차 E2E
    212/215 정지 원인). 롤(_a~_e)·_sel·_fix·_cine 만 지우고 타 단계
    입력·하위 네임스페이스는 남긴다."""
    from app.modules.pipeline.multiroll_select import _clear_outputs

    own = ["S18sh5_a.png", "S18sh5_b.png", "S18sh5_e.png",
           "S18sh5_sel.png", "S18sh5_fix.png", "S18sh5_cine.png"]
    spare = ["S18sh5_confinedfp.png", "S18sh5__bgfirst_bg.png"]
    for name in own + spare:
        (tmp_path / name).write_bytes(b"x")
    _clear_outputs(tmp_path / "S18sh5")
    for name in own:
        assert not (tmp_path / name).exists(), name
    for name in spare:
        assert (tmp_path / name).exists(), name
