"""검색 후보 라운드 journal — 순번 예약과 상태 전이의 단일 권위.

A4. 재실행이 같은 파일명을 덮어쓰던 것보다 큰 문제는 **계보 소급 변조**였다.
자산 row 하나의 file_path 를 계속 갱신해 왔으므로, 재검색 한 번이 과거 씨드의
입력 간선을 다른 이미지로 바꾼다. 그래서 라운드와 자산 UUID 를 불변으로 두고,
그 예약·전이를 이 journal 이 단독으로 소유한다.

★step CP 는 journal 이 될 수 없다 — force 는 `_execute` 전에 CP 를 지우고
(`step_runner.py:1194`), `save_checkpoint` 는 `_execute` 가 끝난 뒤에야 불린다.
CP 는 최종 소비 projection 일 뿐이다.

계획 = docs/superpowers/plans/2026-08-02-a4-round-immutable-candidate-store.md
"""
from __future__ import annotations

import json

import pytest

from app.core.errors import AppError
from app.modules.pipeline.form_ref_rounds import (
    ROUND_PATH_KIND,
    ROUND_STORAGE_CONTRACT_VERSION,
    RoundState,
    abandon_round,
    finalize_round,
    load_round,
    open_round,
    read_index,
    transition_round,
)

FP = "fp-abc123"

#: 새 저장 계약(v2)은 provenance header 를 **필수**로 요구한다 — 선택으로
#: 두면 header 를 지우는 것만으로 결속이 통째로 무력화된다(실측).
PROV = {"ref_pack_version": "sample-fixture-pack",
        "target_policy_version": "sample-fixture-policy"}


def test_first_round_is_r001_and_preallocates_an_asset_id(tmp_path):
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)

    assert r.round_id == "r001"
    assert r.state == RoundState.OPENED
    assert r.input_fp == FP
    assert r.contract_version == ROUND_STORAGE_CONTRACT_VERSION
    # ★자산 UUID 를 여기서 채번한다 — 없으면 crash resume 이 또 INSERT 한다
    assert r.preallocated_asset_id
    assert (tmp_path / "rounds" / "r001" / "manifest.json").exists()


def test_an_open_partial_round_still_consumes_its_number(tmp_path):
    """★열린 라운드도 이미 예약된 번호다 — 건너뛰면 번호가 겹친다."""
    first = open_round(tmp_path, input_fp=FP, provenance=PROV)
    abandon_round(tmp_path, first.round_id)
    second = open_round(tmp_path, input_fp=FP, provenance=PROV)

    assert second.round_id == "r002"
    assert second.preallocated_asset_id != first.preallocated_asset_id


def test_two_open_rounds_at_once_is_fail_closed(tmp_path):
    """열린 라운드를 닫지 않고 또 열면 어느 쪽이 권위인지 알 수 없다.

    ★동시성 전제(계획 §2.7): 이 allocator 는 **StepRunner 의 단일 claim** 이
    한 그룹에 한 실행만 들여보낸다는 전제로 직렬화된다. 락이 없으므로, 한
    그룹에 두 실행이 동시에 들어오는 구조가 생기면 두 실행이 각각 `read_index`
    를 읽어 **같은 번호를 예약**할 수 있다. 그때는 이 모듈에 파일 락이
    필요하다. 지금 이 검사는 그 전제가 지켜지는 한에서의 마지막 방어선이다.
    """
    open_round(tmp_path, input_fp=FP, provenance=PROV)

    with pytest.raises(AppError) as exc:
        open_round(tmp_path, input_fp=FP, provenance=PROV)
    assert exc.value.code == "round_already_open"


def test_a_corrupt_index_is_fail_closed_without_directory_recovery(tmp_path):
    """★디렉터리 스캔으로 복원하지 않는다 — 부분 실패 잔재를 정답으로 만든다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    (tmp_path / "rounds" / "index.json").write_text("{ not json",
                                                    encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_index_corrupt"


def test_a_round_directory_without_a_registry_entry_is_fail_closed(tmp_path):
    """registry 에 없는 라운드 디렉터리를 주워 담지 않는다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    stray = tmp_path / "rounds" / "r007"
    stray.mkdir()
    (stray / "manifest.json").write_text("{}", encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_registry_mismatch"


def _bound_result(r, *, with_asset: bool = False) -> dict:
    """header 와 **묶인** 산출 — 새 계약이 요구하는 최소 shape.

    각 필드의 형식만 보면 header 가 산출과 다른 라운드를 말해도 통과한다
    (실측: ASSET_BOUND 에서 예약 UUID 만 바꾸자 자산을 또 INSERT 했다).
    """
    out = {
        "status": "ok",
        "form_ref_path": "sample/fixture.png",
        # ★sha 는 형식까지 본다 — 64자리 소문자 hex
        "form_ref_sha256": "0" * 64,
        "round_id": r.round_id,
        "group_fingerprint": r.input_fp,
        "round_contract_version": r.contract_version,
        **PROV,
    }
    if with_asset:
        out["form_ref_asset_id"] = r.preallocated_asset_id
    return out


def _final_result(r) -> dict:
    """완료 산출 — **CP projection 전체**를 만족해야 한다.

    journal 이 CP 의 단일 권위라 이 산출이 그대로 성공 CP 가 된다.
    """
    out = _bound_result(r, with_asset=True)
    out.update({"path_kind": ROUND_PATH_KIND})
    return out


def test_finalized_rounds_reject_further_writes(tmp_path):
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.CANDIDATES)
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    transition_round(tmp_path, r.round_id, RoundState.ASSET_BOUND,
                     result=_bound_result(r, with_asset=True))
    finalize_round(tmp_path, r.round_id, result=_final_result(r))

    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.ABANDONED)
    assert exc.value.code == "round_immutable"


def test_an_invalid_transition_does_not_touch_the_manifest(tmp_path):
    """★검증 **전에** durable 하게 쓰면 잘못된 산출이 영속된다.

    실측: bad sha 로 전이를 요청하자 오류는 났지만 `state='selected'` 와 그
    sha 가 영속됐고 이어지는 재개도 같은 오류로 막혀 **그 라운드가 영구 재개
    불능**이 됐다. "실패면 라운드를 열어 두고 이어간다"는 계약과 정반대다.
    """
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    before = mp.read_bytes()

    bad = _bound_result(r)
    bad["form_ref_sha256"] = "not-a-sha"
    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                         result=bad)
    assert exc.value.code == "round_manifest_projection_invalid"

    # (a) manifest 가 **바이트 단위로** 그대로다
    assert mp.read_bytes() == before, "실패한 전이가 manifest 를 바꿨다"
    assert load_round(tmp_path, r.round_id).state == RoundState.OPENED
    # (b) 그 뒤 올바른 전이가 성공한다 — 재개 불능이 아니다
    ok = transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                          result=_bound_result(r))
    assert ok.state == RoundState.SELECTED


@pytest.mark.parametrize("mutate,label", [
    (lambda d: d.pop("provenance"), "header 삭제"),
    (lambda d: d.__setitem__("provenance", None), "explicit null"),
    (lambda d: d.__setitem__("provenance", {}), "빈 dict"),
    (lambda d: d["provenance"].pop("ref_pack_version"), "키 하나 누락"),
    (lambda d: d["provenance"].update(extra="x"), "알 수 없는 키 추가"),
    (lambda d: d["provenance"].update(ref_pack_version="  "), "공백 값"),
    (lambda d: d["provenance"].update(ref_pack_version=1), "문자열 아님"),
])
def test_provenance_header_is_required_by_the_current_contract(
        tmp_path, mutate, label):
    """★header 를 **선택**으로 두면 지우는 것만으로 결속이 무력화된다.

    실측(Codex 6차): provenance 를 지우고 산출·CP 를 같은 거짓값으로 바꾸자
    `paid=0 · completed=1 · pack='tampered-pack'` 으로 통과했다. 부재·null·
    부분·여분 키를 전부 갈라야 한다.
    """
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    mutate(data)
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_corrupt", label


def test_opening_a_round_requires_provenance(tmp_path):
    """★빈 header 를 만드는 API 자체가 없어야 한다."""
    with pytest.raises(TypeError):
        open_round(tmp_path, input_fp=FP)          # type: ignore[call-arg]
    with pytest.raises(AppError) as exc:
        open_round(tmp_path, input_fp=FP, provenance={})
    assert exc.value.code == "round_manifest_corrupt"


def _plant_v1_round(tmp_path, *, state=RoundState.OPENED) -> bytes:
    """**실제 v1 저장소 모양** — index 와 manifest 가 둘 다 계약 1이다.

    앞선 회귀는 manifest 만 v1 로 두고 index 에는 현재 계약을 썼는데, 그건
    실제 구 저장소가 아니라 registry 만 이식된 합성 상태였다(Codex 7차 지적).
    """
    import uuid as _uuid

    d = tmp_path / "rounds" / "r001"
    d.mkdir(parents=True)
    mp = d / "manifest.json"
    mp.write_text(json.dumps({
        "round_id": "r001", "state": state.value, "input_fp": FP,
        "contract_version": 1,
        "preallocated_asset_id": str(_uuid.uuid4()),
    }), encoding="utf-8")
    (tmp_path / "rounds" / "index.json").write_text(json.dumps({
        "contract_version": 1,
        "rounds": [{"round_id": "r001",
                    "manifest_path": "rounds/r001/manifest.json"}]}),
        encoding="utf-8")
    return mp.read_bytes()


def test_a_real_v1_store_is_rejected_not_silently_migrated(tmp_path):
    """★구 계약은 **지원하지 않는다** — 읽기도 이행도 하지 않고 선다.

    A4 는 유료 실행 전이고 실제 라운드 저장소가 0개다(전수 확인). 그래서
    이행 경로를 지어내는 대신 거부를 계약으로 확정했다. "read-only 로 읽을
    수 있다"고 써 두면 그 경로가 실제로는 index 검사에 막혀 실행되지 않는데도
    실행되는 것처럼 읽힌다.
    """
    from app.modules.pipeline.form_ref_rounds import list_rounds

    _plant_v1_round(tmp_path)

    with pytest.raises(AppError) as exc:
        list_rounds(tmp_path)
    assert exc.value.code == "round_index_corrupt"


@pytest.mark.parametrize("force", [False, True])
def test_a_real_v1_store_is_untouched_by_resolve(tmp_path, force):
    """★거부는 **바이트를 건드리지 않는다** — resume·force 둘 다.

    앞선 구현은 계약 불일치를 `is_resumable=False` 로만 보고 `abandon_round`
    를 불러 구 manifest 를 ABANDONED 로 **원자 교체**했다(Codex 7차 재현).
    그 회귀가 helper 단언만 해서 이 쓰기를 놓쳤다.
    """
    from app.modules.pipeline.form_ref_rounds import resolve_round

    before = _plant_v1_round(tmp_path)
    mp = tmp_path / "rounds" / "r001" / "manifest.json"

    with pytest.raises(AppError):
        resolve_round(tmp_path, input_fp=FP, force=force, provenance=PROV)

    assert mp.read_bytes() == before, "거부하면서 구 manifest 를 고쳤다"


def test_a_manifest_under_an_unsupported_contract_is_rejected(tmp_path):
    """registry 가 현재 계약이어도 manifest 가 구 계약이면 읽지 않는다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["contract_version"] = 1
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_unsupported_contract"


def test_a_bound_state_must_agree_with_its_header(tmp_path):
    """★header 와 산출이 서로 다른 라운드를 말하면 읽을 때 선다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["input_fp"] = "sample-fixture-other"     # header 만 바꾼다
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_header_mismatch"


def test_asset_bound_binds_the_preallocated_uuid_to_its_result(tmp_path):
    """★ASSET_BOUND 이후 예약 UUID 는 산출과 **한 몸**이다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    # 자산 id 없이 ASSET_BOUND 로 갈 수 없다
    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.ASSET_BOUND)
    assert exc.value.code == "round_manifest_header_mismatch"


def test_bound_states_require_a_stored_result(tmp_path):
    """★SELECTED/ASSET_BOUND/FINALIZED 는 **저장된 산출**을 요구한다.

    산출 없이 그 상태로 갈 수 있으면 재개가 "선택은 끝났다"는 라운드에서
    산출을 못 찾아 **유료 재검색으로 하강**한다(실측).
    """
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)

    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.SELECTED)
    assert exc.value.code == "round_result_missing"

    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    transition_round(tmp_path, r.round_id, RoundState.ASSET_BOUND,
                     result=_bound_result(r, with_asset=True))
    # 명시적으로 비우는 것도 막는다
    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.FINALIZED,
                         result={})
    assert exc.value.code == "round_result_missing"


def test_a_bound_state_with_an_empty_result_is_corrupt_on_read(tmp_path):
    """디스크에서 그 상태를 만나도 정상으로 받아들이지 않는다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["result"] = {}
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_corrupt"


def test_illegal_transitions_are_rejected(tmp_path):
    """★합법 전이 그래프가 없으면 ASSET_BOUND 를 건너뛴 완료도 통과한다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)

    # OPENED 에서 곧장 완료로 갈 수 없다
    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.FINALIZED,
                         result=_final_result(r))
    assert exc.value.code == "round_illegal_transition"

    # 역전이도 막힌다
    transition_round(tmp_path, r.round_id, RoundState.SELECTED,
                     result=_bound_result(r))
    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.CANDIDATES)
    assert exc.value.code == "round_illegal_transition"


def test_a_non_canonical_uuid_is_rejected(tmp_path):
    """★canonical 변환만 하고 원문을 안 보면 하이픈 없는 표현이 통과한다."""
    import uuid as _uuid

    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["preallocated_asset_id"] = str(_uuid.uuid4()).upper().replace("-", "")
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_corrupt"


def test_a_boolean_contract_version_is_rejected(tmp_path):
    """★`bool` 은 `int` 의 서브클래스다 — `int()` 로 받으면 true 가 1 이 된다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["contract_version"] = True
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_corrupt"


def test_an_unsupported_index_contract_is_rejected(tmp_path):
    """★타입만 보고 값을 안 보면 999 짜리 계약도 통과한다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    idx = tmp_path / "rounds" / "index.json"
    data = json.loads(idx.read_text(encoding="utf-8"))
    data["contract_version"] = 999
    idx.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_index_corrupt"


def test_a_boolean_index_contract_is_reported_as_index_corruption(tmp_path):
    """★helper 가 error code 를 고정하면 index 손상이 manifest 손상이 된다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    idx = tmp_path / "rounds" / "index.json"
    data = json.loads(idx.read_text(encoding="utf-8"))
    data["contract_version"] = True
    idx.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_index_corrupt"


def test_a_malformed_index_entry_is_a_typed_failure(tmp_path):
    """★dict 가 아닌 원소가 `.get` 으로 새면 계약 위반이 500 으로 둔갑한다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    idx = tmp_path / "rounds" / "index.json"
    data = json.loads(idx.read_text(encoding="utf-8"))
    data["rounds"].append("not-a-dict")
    idx.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_index_corrupt"


def test_an_unregistered_round_is_rejected(tmp_path):
    """★`load_round` 를 직접 부르면 registry 를 우회한다."""
    from app.modules.pipeline.form_ref_rounds import load_registered_round

    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    idx = tmp_path / "rounds" / "index.json"
    idx.write_text(json.dumps({
        "contract_version": ROUND_STORAGE_CONTRACT_VERSION,
        "rounds": []}), encoding="utf-8")

    # 직접 로더는 통과한다 — 그래서 등록 확인이 따로 필요하다
    assert load_round(tmp_path, r.round_id).round_id == r.round_id
    with pytest.raises(AppError) as exc:
        load_registered_round(tmp_path, r.round_id)
    assert exc.value.code in ("round_not_registered",
                             "round_registry_mismatch")


def test_manifest_identity_is_verified(tmp_path):
    """★manifest 의 round_id 만 바꿔도 그 값이 권위가 되던 경로.

    실측: r001 의 manifest 에 r999 를 적자 `resolve_round` 가 디스크에 없는
    r999 를 정상 반환했고, 그 상태는 **유료 호출을 탄 뒤에야** 죽었다.
    """
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    mp = tmp_path / "rounds" / r.round_id / "manifest.json"
    data = json.loads(mp.read_text(encoding="utf-8"))
    data["round_id"] = "r999"
    mp.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        load_round(tmp_path, r.round_id)
    assert exc.value.code == "round_manifest_identity_mismatch"


def test_registry_path_and_duplicates_are_verified(tmp_path):
    """registry 가 다른 라운드의 manifest 를 가리키거나 번호가 겹치면 선다."""
    open_round(tmp_path, input_fp=FP, provenance=PROV)
    idx = tmp_path / "rounds" / "index.json"
    data = json.loads(idx.read_text(encoding="utf-8"))
    data["rounds"][0]["manifest_path"] = "rounds/r002/manifest.json"
    idx.write_text(json.dumps(data), encoding="utf-8")

    with pytest.raises(AppError) as exc:
        read_index(tmp_path)
    assert exc.value.code == "round_index_corrupt"


def _plant_finalized(tmp_path, round_id: str) -> dict:
    """완료 라운드를 디스크에 직접 심는다 (번호를 임의로 두기 위해)."""
    import uuid as _uuid

    d = tmp_path / "rounds" / round_id
    d.mkdir(parents=True, exist_ok=True)
    asset_id = str(_uuid.uuid4())
    (d / "manifest.json").write_text(json.dumps({
        "round_id": round_id,
        "state": RoundState.FINALIZED.value,
        "input_fp": FP,
        "contract_version": ROUND_STORAGE_CONTRACT_VERSION,
        "preallocated_asset_id": asset_id,
        "provenance": dict(PROV),
        # ★header 와 묶인 산출이어야 읽힌다
        "result": {
            "status": "ok", "round_id": round_id, "group_fingerprint": FP,
            "round_contract_version": ROUND_STORAGE_CONTRACT_VERSION,
            "form_ref_asset_id": asset_id, "path_kind": ROUND_PATH_KIND,
            "form_ref_path": f"sample/{round_id}.png",
            "form_ref_sha256": "0" * 64,
            **PROV,
        },
    }), encoding="utf-8")
    return {"round_id": round_id,
            "manifest_path": f"rounds/{round_id}/manifest.json"}


def test_resolve_picks_the_numerically_latest_round(tmp_path):
    """★r1000 이후 문자열 비교는 뒤집힌다 ("r999" > "r1000").

    `_round_seq` 만 부르는 테스트로는 이걸 못 잡는다 — 실제로 고르는 곳은
    `resolve_round` 다(위반 주입에서 확인).
    """
    from app.modules.pipeline.form_ref_rounds import resolve_round

    entries = [_plant_finalized(tmp_path, "r999"),
               _plant_finalized(tmp_path, "r1000")]
    (tmp_path / "rounds" / "index.json").write_text(json.dumps({
        "contract_version": ROUND_STORAGE_CONTRACT_VERSION,
        "rounds": entries}), encoding="utf-8")

    res = resolve_round(tmp_path, input_fp=FP, force=False,
                        provenance=PROV)

    assert res.reused_finalized is True
    assert res.round.round_id == "r1000", "문자열로 비교해 r999 를 골랐다"


def test_abandoned_rounds_reject_further_writes(tmp_path):
    """★ABANDONED 도 FINALIZED 와 같은 불변 경계다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    abandon_round(tmp_path, r.round_id)

    with pytest.raises(AppError) as exc:
        transition_round(tmp_path, r.round_id, RoundState.CANDIDATES)
    assert exc.value.code == "round_immutable"


def test_the_round_manifest_owns_the_state_not_the_index(tmp_path):
    """★같은 상태를 둘이 중복 소유하면 불일치가 생긴다.

    index 는 순번 예약과 manifest 경로만 갖고, 상태는 manifest 가 단독 소유한다.
    """
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.CANDIDATES)

    index = json.loads(
        (tmp_path / "rounds" / "index.json").read_text(encoding="utf-8"))
    entry = index["rounds"][0]
    assert entry["round_id"] == r.round_id
    assert "manifest_path" in entry
    assert "state" not in entry, "index 가 상태를 중복 소유한다"
    assert load_round(tmp_path, r.round_id).state == RoundState.CANDIDATES


def test_resume_requires_an_exact_input_fingerprint(tmp_path):
    """지문이 어긋난 열린 라운드를 이어가면 다른 입력의 산출이 섞인다."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    loaded = load_round(tmp_path, r.round_id)

    assert loaded.is_resumable(input_fp=FP) is True
    assert loaded.is_resumable(input_fp="fp-different") is False


def test_writes_are_atomic_leaving_no_partial_file(tmp_path):
    """중간에 끊겨도 반쪽 파일이 남지 않아야 한다 — temp→replace."""
    r = open_round(tmp_path, input_fp=FP, provenance=PROV)
    transition_round(tmp_path, r.round_id, RoundState.CANDIDATES)

    leftovers = list((tmp_path / "rounds").rglob("*.tmp"))
    assert leftovers == [], f"임시 파일이 남았다: {leftovers}"
    for path in (tmp_path / "rounds").rglob("*.json"):
        json.loads(path.read_text(encoding="utf-8"))  # 전부 온전한 JSON


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