"""구조 결함은 i2i 편집이 아니라 재생성으로 — 분기 계약 고정.

2026-07-31 실측: `bg_multifamily_residence` 에서 critique 가 "3층 지침인데
2층으로 묘사됨"을 정확히 잡고 `Add a third full storey` 를 지시했는데, i2i
편집은 매스를 바꾸지 못해 그대로 2층이 나왔다. 게다가 재판정이 미배선이라
그 실패본이 무판정 확정됐다. 층수는 국소 편집의 사정거리 밖이다.

그래서 critique 가 `needs_regeneration` 으로 표시한 결함은 편집 대신 **원
브리프로 다시 생성**하고, 나온 후보는 기존 fix 재판정 경로를 그대로 탄다.
재생성 수단이 없는 호출자의 동작은 바뀌지 않아야 한다(기존 경로 보존).
"""
from __future__ import annotations

from pathlib import Path

import pytest

from app.core.errors import AppError
from app.modules.pipeline.multiroll_select import (
    REGEN_ISSUE_POLICY_DEFER,
    REGEN_ISSUE_POLICY_VERSION,
    _critique_and_fix,
    build_critique_schema,
    build_regen_prompt,
    compute_input_fingerprint,
    run_multiroll_select,
)

BRIEF = "A four-storey villa with a parking storey and three residential floors."


def _png(p: Path, payload: bytes = b"\x89PNG\r\n\x1a\nORIG") -> Path:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(payload)
    return p


def _crit(issues):
    def fn(tag, prompt, refs, image):  # noqa: ANN001
        return {"issues": issues}
    return fn


class _Recorder:
    """gen_fn 대역 — 호출 인자를 기록하고 산출 파일을 만든다."""

    def __init__(self, payload: bytes):
        self.calls = []
        self.payload = payload

    def __call__(self, tag, prompt, refs, out):  # noqa: ANN001
        self.calls.append({"tag": tag, "prompt": prompt,
                           "refs": list(refs or [])})
        _png(Path(out), self.payload)
        return Path(out)


STRUCTURAL = [{"issue_ko": "층수 미달", "fix_en": "Make it four storeys.",
               "needs_regeneration": True}]
LOCAL = [{"issue_ko": "간판 색 오류", "fix_en": "Repaint the sign green.",
          "needs_regeneration": False}]


def _first_shown_wins(tag, prompt, refs, images, labels):  # noqa: ANN001
    """재판정 대역 — 늘 첫 번째 표시 후보를 이기게 한다."""
    labs = list(labels)
    return {
        "verdicts": [{"label": labs[0], "score": 9, "reason_ko": "-"},
                     {"label": labs[1], "score": 3, "reason_ko": "-"}],
        "ranking": [labs[0], labs[1]],
        "winner": labs[0],
    }


def test_schema_carries_needs_regeneration():
    props = build_critique_schema()["properties"]["issues"]["items"][
        "properties"]
    assert "needs_regeneration" in props
    assert props["needs_regeneration"]["type"] == "boolean"


def test_regen_prompt_keeps_brief_and_names_faults():
    out = build_regen_prompt(STRUCTURAL, BRIEF)
    # 원 브리프를 그대로 다시 준다 — 프롬프트를 바꾸면 준수 판정이 무의미
    assert BRIEF in out
    assert "Make it four storeys." in out
    # 편집이 아니라 새 촬영이라는 것이 명시돼야 한다
    assert "not an edit" in out
    # 주차층도 층수에 든다는 계수 지침
    assert "still counts" in out


def test_structural_fault_regenerates_without_the_rejected_image(tmp_path):
    """구조 결함 → 재생성. 거부된 산출은 참조에서 빠진다."""
    stem = tmp_path / "seed_x"
    _png(stem.parent / "seed_x_a.png")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    sel = _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(STRUCTURAL), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regen_gen_fn=regen, regen_prompt=BRIEF,
        fix_rejudge_fn=_first_shown_wins,
    )

    assert rec["repair_mode"] == "regenerate"
    assert rec["regen_issue_count"] == 1
    assert edit.calls == [], "구조 결함인데 i2i 편집이 호출됐다"
    assert len(regen.calls) == 1
    # 거부된 산출을 참조로 물리면 같은 매스가 재현된다 — 들어가면 안 된다
    assert regen.calls[0]["refs"] == []
    assert BRIEF in regen.calls[0]["prompt"]
    # 재생성본도 판정을 거친다 — 어느 쪽이 이기든 무판정 확정은 아니다
    assert "fix_rejudge" in rec
    assert sel.read_bytes().endswith(b"REGEN") or sel.read_bytes().endswith(
        b"ORIG")


def test_regen_without_rejudge_fails_before_any_image_call(tmp_path):
    """★재생성을 켰는데 재판정 계약이 없으면 첫 이미지 호출 전에 막는다.

    재생성이 개선을 보장하지 않는다는 것은 실측이다(같은 개정절로 다시
    그렸더니 한 그룹이 더 나빠졌다). 판정 없이 재생성본을 확정하면 그
    악화가 그대로 굳는다 — v13 이 없애려던 "무판정 확정"이 방향만 바꿔
    되살아나는 셈이다. 그래서 하강이 아니라 fail-closed 다.

    ★검증 지점은 롤 생성 **전**이어야 한다 — critique 단계에서 막으면
    이미 3롤이 그려진 뒤다. 그래서 공개 진입점에서 확인한다.
    """
    gen = _Recorder(b"\x89PNG\r\n\x1a\nROLL")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")

    def _judge(tag, prompt, refs, images, labels):  # noqa: ANN001
        raise AssertionError("판정까지 도달하면 안 된다")

    with pytest.raises(AppError) as exc:
        run_multiroll_select(
            tag="t", prompt=BRIEF, labeled_refs=[],
            out_stem=tmp_path / "seed_f", gen_fn=gen, judge_fn=_judge,
            critique_fn=_crit(STRUCTURAL), fix_gen_fn=edit,
            regen_gen_fn=regen,
        )

    assert exc.value.status_code == 422
    assert exc.value.code == "regen_without_rejudge"
    # ★유료 호출이 하나도 나가기 전에 막혀야 한다 — 호출 횟수로 확인
    assert gen.calls == [] and regen.calls == [] and edit.calls == []


def test_empty_brief_is_blocked_before_a_single_roll_is_drawn(tmp_path):
    """★브리프 원천이 비면 **롤을 그리기 전에** 막는다.

    앞선 판본은 critique 뒤에야 막았다 — 그때는 롤 생성·판정·검사가 이미
    유료로 나간 뒤여서(실측 gen 2·judge 1·critique 1) "첫 유료 호출 전"이라는
    계약을 지키지 못했다. 그 판본의 테스트는 `_critique_and_fix` 를 직접
    불러 앞단 호출을 세지 않았기 때문에 이 사실을 드러내지 못했다.
    그래서 여기서는 **공개 진입점**을 쓰고 **생성 호출 횟수**로 확인한다.
    """
    gen = _Recorder(b"\x89PNG\r\n\x1a\nROLL")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")

    def _judge(tag, prompt, refs, images, labels):  # noqa: ANN001
        raise AssertionError("판정까지 도달하면 안 된다")

    with pytest.raises(AppError) as exc:
        run_multiroll_select(
            tag="t", prompt="   ", labeled_refs=[],
            out_stem=tmp_path / "seed_eb", gen_fn=gen, judge_fn=_judge,
            critique_fn=_crit(STRUCTURAL), fix_gen_fn=gen,
            regen_gen_fn=regen, fix_rejudge_fn=_first_shown_wins,
        )

    assert exc.value.code == "regen_brief_missing"
    assert gen.calls == [], "롤이 이미 그려진 뒤에 막혔다"
    assert regen.calls == []


def test_regen_with_empty_brief_does_not_fall_back_to_edit(tmp_path):
    """재생성이 켜졌는데 브리프가 비면 편집으로 **내려가지 않는다**.

    위 진입 검증이 우회되더라도 조용한 하강은 없어야 한다 — private 계약의
    마지막 문. 여기서 내려가면 편집으로 못 고치는 결함이 다시 편집 지시에
    실린다.
    """
    stem = tmp_path / "seed_e"
    _png(stem.parent / "seed_e_a.png")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    with pytest.raises(AppError) as exc:
        _critique_and_fix(
            tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem,
            selected="A", critique_fn=_crit(STRUCTURAL), fix_gen_fn=edit,
            fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
            regen_gen_fn=regen, regen_prompt="   ",
            fix_rejudge_fn=_first_shown_wins,
        )

    assert exc.value.code == "regen_brief_missing"
    assert regen.calls == [] and edit.calls == []


def test_unknown_regeneration_policy_is_rejected(tmp_path):
    """오타 난 정책 이름이 조용히 legacy 로 해석되면 안 된다."""
    gen = _Recorder(b"\x89PNG\r\n\x1a\nROLL")

    with pytest.raises(AppError) as exc:
        run_multiroll_select(
            tag="t", prompt=BRIEF, labeled_refs=[],
            out_stem=tmp_path / "seed_p", gen_fn=gen,
            judge_fn=_first_shown_wins, critique_fn=_crit([]),
            fix_gen_fn=gen, regeneration_issue_policy="defered",
        )

    assert exc.value.code == "regen_issue_policy_unknown"
    assert gen.calls == []


def test_defer_policy_contributes_to_the_fingerprint_but_legacy_does_not():
    """★defer 는 지문에 실리고 legacy 는 기여 0 이다.

    실리지 않으면 완료 CP 가 옛 편집본을 그대로 재사용해 새 계약이 실행에
    도달하지 못한다. 반대로 legacy 까지 실으면 이 파이프를 공유하는 다른
    소비자의 산출이 통째로 무효화된다 — 그래서 한쪽만 기여한다.
    """
    common = dict(prompt=BRIEF, labeled_refs=[], roll_count=3,
                  critique_enabled=True)
    base = compute_input_fingerprint(**common, extra=None)
    defer_like = compute_input_fingerprint(
        **common,
        extra={"regen_issue_policy": {
            "policy": REGEN_ISSUE_POLICY_DEFER,
            "version": REGEN_ISSUE_POLICY_VERSION}})
    assert base != defer_like, "정책이 지문 축에 실리지 않는다"


def _run_pipe(tmp_path, stem_name, **kw):
    """run_multiroll_select 1회 — 실제 경로에서 record 를 받는다."""
    _sel, rec = run_multiroll_select(
        tag="t", prompt=BRIEF, labeled_refs=[],
        out_stem=tmp_path / stem_name,
        gen_fn=_Recorder(b"\x89PNG\r\n\x1a\nROLL"),
        judge_fn=_first_shown_wins,
        critique_fn=_crit([]), fix_gen_fn=_Recorder(b"\x89PNG\r\n\x1a\nEDIT"),
        roll_count=2, **kw)
    return rec


def test_defer_changes_the_fingerprint_on_the_real_path(tmp_path):
    """★helper 가 아니라 **실제 경로**에서 지문이 갈리는지 본다.

    조립 코드가 정책을 접지 않으면 helper 단위 테스트는 통과해도 완료 CP
    가 옛 산출을 그대로 재사용한다 — 지난 세션에 같은 함정을 겪었다.
    """
    legacy = _run_pipe(tmp_path, "fp_legacy")["input_fingerprint"]
    defer = _run_pipe(
        tmp_path, "fp_defer",
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER,
    )["input_fingerprint"]
    same = _run_pipe(tmp_path, "fp_legacy2")["input_fingerprint"]

    assert legacy == same, "같은 정책인데 지문이 흔들린다"
    assert legacy != defer, "defer 가 실제 경로의 지문을 바꾸지 않았다"


def test_regen_keeps_the_original_references(tmp_path):
    """★참조 기반 파이프에서는 원래 참조가 재생성에도 살아 있어야 한다.

    구조 스케치를 형태 권위로 주는 경로에서 참조를 통째로 비우면 재생성이
    형태 근거를 잃는다 — 재생성은 "이 브리프와 이 참조로 다시 찍기"이지
    "맨손으로 다시 그리기"가 아니다.
    """
    stem = tmp_path / "seed_r"
    _png(stem.parent / "seed_r_a.png")
    sketch = _png(tmp_path / "sketch.png", b"\x89PNG\r\n\x1a\nSKETCH")
    look = _png(tmp_path / "look.png", b"\x89PNG\r\n\x1a\nLOOK")
    refs = [("STRUCTURE SKETCH", sketch), ("LOOK REFERENCE", look)]
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    rec: dict = {}

    _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=refs, out_stem=stem,
        selected="A", critique_fn=_crit(STRUCTURAL), fix_gen_fn=_Recorder(b"E"),
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regen_gen_fn=regen, regen_prompt=BRIEF,
        fix_rejudge_fn=_first_shown_wins,
    )

    assert rec["repair_mode"] == "regenerate"
    passed = regen.calls[0]["refs"]
    assert [lab for lab, _ in passed] == [
        "STRUCTURE SKETCH", "LOOK REFERENCE"]
    # 거부된 산출(_a.png)은 여전히 참조에 없다
    assert all(Path(p).name != "seed_r_a.png" for _, p in passed)


def test_local_fault_still_edits_in_place(tmp_path):
    """국소 결함은 기존 i2i 경로 — 원본을 참조로 물린다."""
    stem = tmp_path / "seed_y"
    _png(stem.parent / "seed_y_a.png")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(LOCAL), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regen_gen_fn=regen, regen_prompt=BRIEF,
    )

    assert rec["repair_mode"] == "edit"
    assert regen.calls == []
    assert len(edit.calls) == 1
    assert edit.calls[0]["refs"] and edit.calls[0]["refs"][0][0] == "ORIGINAL"


def test_defer_policy_keeps_structural_faults_out_of_the_edit(tmp_path):
    """defer 정책에서 구조 결함은 편집 지시에 실리지 않는다.

    ★2026-08-01 교체. 앞선 판본은 "구조 결함도 기존 i2i 경로로 내려간다"를
    정상 계약으로 잠갔는데, 그것이 바로 v13 이 없애려던 실패 경로다 —
    실측(bg_multifamily_residence)에서 `Add a third full storey` 를 편집에
    실었더니 매스가 그대로였고, 씨드 경로는 재판정이 미배선이라 그 실패본이
    원본을 밀어냈다. 편집의 사정거리 밖인 결함을 편집에 실으면 유료 호출
    한 번을 버리면서 산출을 더 나쁘게 만든다. 재생성이 꺼져 있다는 것은
    "편집으로 대신한다"가 아니라 "그 결함은 손대지 않는다"여야 한다.
    """
    stem = tmp_path / "seed_z"
    _png(stem.parent / "seed_z_a.png")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(STRUCTURAL + LOCAL), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER,
    )

    assert rec["repair_mode"] == "edit"
    assert len(edit.calls) == 1
    sent = edit.calls[0]["prompt"]
    assert "Repaint the sign green." in sent, "국소 결함은 그대로 편집한다"
    assert "Make it four storeys." not in sent, (
        "재생성이 꺼졌는데 구조 결함을 편집 지시에 실었다")
    # 조용히 버리지 않는다 — 무엇이 미처리로 남았는지 기록으로 남긴다
    assert rec["regen_deferred_issue_count"] == 1
    assert rec["regen_deferred_issues"] == STRUCTURAL


def test_defer_policy_with_only_structural_makes_no_paid_call(tmp_path):
    """전부 구조 결함이면 편집을 아예 부르지 않고 원본을 유지한다."""
    stem = tmp_path / "seed_q"
    _png(stem.parent / "seed_q_a.png")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    sel = _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(STRUCTURAL), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER,
    )

    assert edit.calls == [], "고칠 수 없는 결함에 유료 편집 호출이 나갔다"
    assert rec["fix_skipped"] is True
    assert rec["fix_skip_reason"] == "all_issues_need_regeneration"
    assert "fix_prompt" not in rec
    assert rec["regen_deferred_issue_count"] == 1
    # critique 원문은 그대로 보존된다 — 감사 근거
    assert rec["critique"]["issues"] == STRUCTURAL
    assert sel.read_bytes().endswith(b"ORIG"), "원본이 유지돼야 한다"


def test_legacy_policy_is_the_default_and_unchanged(tmp_path):
    """★기본값(legacy)은 기존 동작 그대로 — 비대상 소비자 보호.

    defer 는 씨드가 명시로 켠다. 공용 기본값을 조용히 바꾸면 plate 등
    다른 소비자의 계약이 함께 뒤집힌다(Codex 지적, 수용).
    """
    stem = tmp_path / "seed_l"
    _png(stem.parent / "seed_l_a.png")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}

    _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(STRUCTURAL + LOCAL), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
    )

    assert rec["repair_mode"] == "edit"
    assert len(edit.calls) == 1
    sent = edit.calls[0]["prompt"]
    assert "Make it four storeys." in sent
    assert "Repaint the sign green." in sent
    assert "regen_deferred_issue_count" not in rec


def test_mixed_unfixable_and_deferred_gets_a_truthful_reason(tmp_path):
    """★둘이 섞였는데 한쪽 이름을 붙이면 감사 기록이 거짓이 된다."""
    stem = tmp_path / "seed_m"
    _png(stem.parent / "seed_m_a.png")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}
    mixed = [
        {"issue_ko": "시점 이동", "fix_en": "Move the camera.",
         "unfixable": True},
        {"issue_ko": "층수 미달", "fix_en": "Make it four storeys.",
         "needs_regeneration": True},
    ]

    _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(mixed), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER,
    )

    assert edit.calls == []
    assert rec["fix_skip_reason"] == "all_issues_non_editable"
    assert rec["regen_deferred_issue_count"] == 1


def test_resume_with_the_same_policy_makes_no_call(tmp_path):
    """★같은 정책으로 재개하면 유료 호출이 하나도 없다.

    지문이 정책을 접으므로 정책이 그대로면 산출이 재사용돼야 한다 — 매번
    다시 그리면 defer 를 넣은 것이 오히려 비용을 만든다.
    """
    first = _run_pipe(
        tmp_path, "resume_x",
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER)

    gen = _Recorder(b"\x89PNG\r\n\x1a\nROLL2")
    crit_calls = []

    def _crit_counting(tag, prompt, refs, image):  # noqa: ANN001
        crit_calls.append(tag)
        return {"issues": []}

    def _judge_boom(tag, prompt, refs, images, labels):  # noqa: ANN001
        raise AssertionError("재개인데 판정이 다시 돌았다")

    _sel, again = run_multiroll_select(
        tag="t", prompt=BRIEF, labeled_refs=[],
        out_stem=tmp_path / "resume_x", gen_fn=gen, judge_fn=_judge_boom,
        critique_fn=_crit_counting, fix_gen_fn=gen, roll_count=2,
        record=first, regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER)

    assert gen.calls == [], "재개인데 롤을 다시 그렸다"
    assert crit_calls == [], "재개인데 결함 검사를 다시 돌렸다"
    assert again["input_fingerprint"] == first["input_fingerprint"]


def test_unfixable_wins_over_needs_regeneration(tmp_path):
    """두 bool 이 겹치면 unfixable 이 이긴다 — 중복 처리 금지.

    partition 은 한 번만 하고 우선순위를 고정한다(Codex 지적, 수용).
    unfixable 로 이미 제외된 이슈가 deferred 로 두 번 세어지면 감사 수치가
    틀어진다.
    """
    stem = tmp_path / "seed_u"
    _png(stem.parent / "seed_u_a.png")
    edit = _Recorder(b"\x89PNG\r\n\x1a\nEDIT")
    rec: dict = {}
    both = [{"issue_ko": "시점 이동", "fix_en": "Move the camera.",
             "unfixable": True, "needs_regeneration": True}]

    sel = _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(both), fix_gen_fn=edit,
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        regeneration_issue_policy=REGEN_ISSUE_POLICY_DEFER,
    )

    assert edit.calls == []
    assert rec["fix_skip_reason"] == "all_issues_unfixable"
    assert rec.get("regen_deferred_issue_count", 0) == 0, (
        "unfixable 이슈가 deferred 로 두 번 세어졌다")
    assert sel.read_bytes().endswith(b"ORIG")


def test_regen_candidate_goes_through_fix_rejudge(tmp_path):
    """재생성본도 원본과 블라인드 재판정을 거친다 — 무판정 확정 금지."""
    stem = tmp_path / "seed_w"
    _png(stem.parent / "seed_w_a.png")
    regen = _Recorder(b"\x89PNG\r\n\x1a\nREGEN")
    seen = []

    def rejudge(tag, prompt, refs, images, labels):  # noqa: ANN001
        # 표시 순서와 무관하게 늘 첫 번째 표시 후보를 이기게 한다 →
        # 정순·역순 결합에서 canonical A(원본)가 승자가 된다.
        labs = list(labels)
        seen.append(labs)
        return {
            "verdicts": [{"label": labs[0], "score": 9, "reason_ko": "-"},
                         {"label": labs[1], "score": 3, "reason_ko": "-"}],
            "ranking": [labs[0], labs[1]],
            "winner": labs[0],
        }

    rec: dict = {}
    sel = _critique_and_fix(
        tag="t", prompt=BRIEF, labeled_refs=[], out_stem=stem, selected="A",
        critique_fn=_crit(STRUCTURAL), fix_gen_fn=_Recorder(b"E"),
        fix_head="H", fix_tail="T", fix_label="ORIGINAL", record=rec,
        fix_rejudge_fn=rejudge, regen_gen_fn=regen, regen_prompt=BRIEF,
    )
    assert rec["repair_mode"] == "regenerate"
    assert len(seen) == 2, "정순·역순 두 번 판정해야 한다"
    assert "fix_rejudge" in rec
    assert rec["fix_rejudge"]["winner"] in ("A", "B")
    # 무판정 확정이 아니라 두 후보 중 판정된 쪽이 확정돼야 한다
    assert sel.read_bytes().endswith(b"ORIG") or sel.read_bytes().endswith(
        b"REGEN")


if __name__ == "__main__":
    raise SystemExit(pytest.main([__file__, "-q"]))
