"""G3.2 _owned_helpers unit tests."""
from __future__ import annotations

import pytest

from app.core.steps._owned_helpers import (
    OWNED_SENTINEL_SCHEMA_VERSION,
    OWNED_VALIDATOR_FULL,
    OWNED_VALIDATOR_CLOSE_SKIP,
    normalize_owned_list,
)


def _absent_usage(owned: list[str]) -> list[dict[str, str]]:
    """C2 v1: owned token 전부 absent echo — coverage(set/cardinality) trivially 충족."""
    return [
        {"owned_token": t, "usage_kind": "absent", "source_phrase": ""}
        for t in owned
    ]


class TestNormalizeOwnedList:
    def test_strips_whitespace(self) -> None:
        assert normalize_owned_list(["  door ", " window"]) == ["door", "window"]

    def test_dedupes_case_sensitive(self) -> None:
        # 대소문자 구분 — "TV" 와 "tv" 는 다른 객체로 보존 (한글/혼합 sources 안전).
        assert normalize_owned_list(["TV", "tv", "TV"]) == ["TV", "tv"]

    def test_drops_empty_and_whitespace_only(self) -> None:
        assert normalize_owned_list(["door", "", "  ", "window"]) == ["door", "window"]

    def test_non_string_raises(self) -> None:
        # Wave 6 IMPORTANT fix (feedback_no_silent_fallback.md): 비-string fail-fast.
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", None, "window"])
        assert exc_info.value.code == "step.contract_violation"
        assert "non-string" in exc_info.value.message

    def test_non_string_int_raises(self) -> None:
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", 42, "window"])
        assert exc_info.value.code == "step.contract_violation"

    def test_sorted_ascending(self) -> None:
        assert normalize_owned_list(["window", "door", "TV"]) == ["TV", "door", "window"]

    def test_empty_input(self) -> None:
        assert normalize_owned_list([]) == []

    def test_overlength_raises(self) -> None:
        # Wave 6 IMPORTANT fix: silent truncation 폐기 — overlength fail-fast.
        from app.core.errors import AppError
        long_item = "a" * 200
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list([long_item])
        assert exc_info.value.code == "step.contract_violation"
        assert "overlength" in exc_info.value.message

    # Wave 6 IMPORTANT fix: round 5 BLOCKING 1 의 silent skip → fail-fast 로 강화.
    def test_korean_hangul_raises(self) -> None:
        # 한국어 시나리오에서도 owned 는 항상 영어 — Hangul 포함 fail-fast.
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", "문", "window"])
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_japanese_kana_raises(self) -> None:
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", "ドア", "table"])
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_cjk_ideograph_raises(self) -> None:
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", "门", "table"])
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_mixed_korean_english_raises(self) -> None:
        # "Korean도어" 같은 섞인 token 도 reject (영어 canonical 강제).
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            normalize_owned_list(["door", "Korean도어", "window"])
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_pure_ascii_passes(self) -> None:
        result = normalize_owned_list(["TV", "AC", "wardrobe"])
        assert result == ["AC", "TV", "wardrobe"]

    def test_whitespace_only_silent_drop_intended(self) -> None:
        # Wave 6: 진정한 빈/whitespace-only 만 silent drop (정규화 의도 — 손상 X).
        assert normalize_owned_list(["door", "", "  ", "\t", "window"]) == [
            "door", "window",
        ]


from app.core.steps._owned_helpers import (
    compute_owned_hash,
    compute_camera_direction_hash,
)


class TestComputeOwnedHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_owned_hash(["door", "window"])
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_deterministic_same_input(self) -> None:
        h1 = compute_owned_hash(["door", "window"])
        h2 = compute_owned_hash(["door", "window"])
        assert h1 == h2

    def test_order_independent_via_internal_sort(self) -> None:
        # Wave 6 IMPORTANT fix: 내부 sorted() 추가 → caller order 무관 동일 hash.
        # docstring 의 "sorted+joined" 약속을 구현 자체가 보장 (defense-in-depth).
        h_sorted = compute_owned_hash(["TV", "door", "window"])
        h_unsorted = compute_owned_hash(["window", "door", "TV"])
        h_other = compute_owned_hash(["door", "window", "TV"])
        assert h_sorted == h_unsorted == h_other

    def test_empty_list(self) -> None:
        h = compute_owned_hash([])
        assert isinstance(h, str)
        assert len(h) == 16


class TestComputeCameraDirectionHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_camera_direction_hash("eye-level wide shot of doorway")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_empty_string(self) -> None:
        h = compute_camera_direction_hash("")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_different_text_different_hash(self) -> None:
        h1 = compute_camera_direction_hash("eye-level wide shot")
        h2 = compute_camera_direction_hash("low angle close-up")
        assert h1 != h2


# round 5 BLOCKING 3: t2i_prompt drift detection.
from app.core.steps._owned_helpers import compute_t2i_prompt_hash


class TestComputeT2iPromptHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_t2i_prompt_hash("A figure stands by the door.")
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_empty_string(self) -> None:
        # variation["t2i_prompt"] or "" — None / 빈 문자열 모두 deterministic hash.
        h = compute_t2i_prompt_hash("")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_exact_string_match(self) -> None:
        # whitespace / case / punctuation 변화 모두 다른 hash → drift 검출 보장.
        h1 = compute_t2i_prompt_hash("A figure stands by the door.")
        h2 = compute_t2i_prompt_hash("A figure stands by the door")  # 마지막 . 차이
        h3 = compute_t2i_prompt_hash("a figure stands by the door.")  # 첫 글자 case
        assert h1 != h2
        assert h1 != h3
        assert h2 != h3

    def test_deterministic(self) -> None:
        h1 = compute_t2i_prompt_hash("identical prompt")
        h2 = compute_t2i_prompt_hash("identical prompt")
        assert h1 == h2


from app.core.steps._owned_helpers import (
    build_owned_sentinel,
    assert_owned_sentinel_shape,
)


class TestBuildOwnedSentinel:
    def test_full_sentinel_no_violations(self) -> None:
        # round 5 BLOCKING 3: t2i_prompt 인자 필수.
        s = build_owned_sentinel(
            owned=["door", "window"],
            camera_direction="eye-level wide shot",
            t2i_prompt="A figure stands by the door.",
            is_close_framing=False,
            violations=[],
            owned_object_usage=_absent_usage(["door", "window"]),
        )
        assert s["schema_version"] == OWNED_SENTINEL_SCHEMA_VERSION
        assert s["validator"] == OWNED_VALIDATOR_FULL
        assert s["violations"] == []
        assert len(s["owned_hash"]) == 16
        assert len(s["camera_direction_hash"]) == 16
        assert len(s["t2i_prompt_hash"]) == 16

    def test_full_sentinel_with_violations(self) -> None:
        viols = [
            {"owned_object": "TV", "violating_phrase": "a TV in the corner",
             "reason": "redraw without anchor"}
        ]
        s = build_owned_sentinel(
            owned=["TV"], camera_direction="medium shot",
            t2i_prompt="A TV in the corner.",
            is_close_framing=False, violations=viols,
            owned_object_usage=_absent_usage(["TV"]),
        )
        assert s["violations"] == viols

    def test_close_skip_sentinel_includes_all_hashes(self) -> None:
        # round 3 #4 + round 5 BLOCKING 3: close skip 도 모든 hash 포함.
        s = build_owned_sentinel(
            owned=["door"], camera_direction="extreme close-up",
            t2i_prompt="Focus on a hand.",
            is_close_framing=True, violations=[],
            owned_object_usage=_absent_usage(["door"]),
        )
        assert s["validator"] == OWNED_VALIDATOR_CLOSE_SKIP
        assert s["violations"] == []
        assert len(s["owned_hash"]) == 16
        assert len(s["camera_direction_hash"]) == 16
        assert len(s["t2i_prompt_hash"]) == 16

    def test_t2i_prompt_change_changes_hash(self) -> None:
        # round 5 BLOCKING 3: t2i_review 가 prompt 수정해도 caller 가
        # 새 hash 로 sentinel 재생성하면 drift 가 안 잡혀야 정상. 단 caller 가
        # sentinel 재생성을 안 하고 옛 sentinel 보존하면 verify 가 drift 잡음.
        s1 = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="Original prompt.",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(["door"]),
        )
        s2 = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="Modified prompt.",
            is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(["door"]),
        )
        assert s1["t2i_prompt_hash"] != s2["t2i_prompt_hash"]


class TestAssertOwnedSentinelShape:
    def test_valid_full(self) -> None:
        s = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="prompt", is_close_framing=False, violations=[],
            owned_object_usage=_absent_usage(["door"]),
        )
        # noop 통과
        assert_owned_sentinel_shape(s)

    def test_valid_close_skip(self) -> None:
        s = build_owned_sentinel(
            owned=[], camera_direction="ECU",
            t2i_prompt="prompt", is_close_framing=True, violations=[],
            owned_object_usage=[],
        )
        assert_owned_sentinel_shape(s)

    def test_missing_field_raises(self) -> None:
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape({"schema_version": 2})
        assert exc_info.value.code == "step.contract_violation"

    def test_invalid_validator_raises(self) -> None:
        # round 7 MINOR 4: bad fixture 에 t2i_prompt_hash 포함 — required field
        # 모두 갖춘 상태에서 validator branch 만 트리거. shadow 차단.
        # Wave 6: hash 도 16-char hex 필수 (strict shape).
        from app.core.errors import AppError
        bad = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": "unknown.v0",
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "validator" in exc_info.value.message  # branch 정확히 타는지

    def test_violations_must_be_list(self) -> None:
        # round 7 MINOR 4: bad fixture 에 t2i_prompt_hash 포함.
        # Wave 6: hash 16-char hex 필수.
        from app.core.errors import AppError
        bad = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": "not a list",
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "violations" in exc_info.value.message

    # Wave 6 BLOCKING fix: 새 strict shape 검증 항목 (Codex partB).
    def test_invalid_schema_version_raises(self) -> None:
        from app.core.errors import AppError
        bad = {
            "schema_version": 99,  # 현재 2 와 불일치
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message

    def test_non_int_schema_version_raises(self) -> None:
        from app.core.errors import AppError
        bad = {
            "schema_version": "2",  # str 이 아니라 int 필요
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message

    def test_bool_schema_version_raises(self) -> None:
        # Wave 6 iter2 fix (Codex): Python bool 은 int 서브클래스라
        # isinstance(True, int)==True. schema_version=True 가
        # OWNED_SENTINEL_SCHEMA_VERSION==2 와 silent pass 차단.
        from app.core.errors import AppError
        bad = {
            "schema_version": True,  # bool 거부
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message

    @pytest.mark.parametrize("hash_field", [
        "t2i_prompt_hash", "owned_hash", "camera_direction_hash",
        "owned_usage_hash",
    ])
    def test_short_hash_raises(self, hash_field: str) -> None:
        # 16 자보다 짧은 hex.
        from app.core.errors import AppError
        good = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        good[hash_field] = "abc"  # 짧음
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(good)
        assert exc_info.value.code == "step.contract_violation"
        assert hash_field in exc_info.value.message

    @pytest.mark.parametrize("hash_field", [
        "t2i_prompt_hash", "owned_hash", "camera_direction_hash",
        "owned_usage_hash",
    ])
    def test_uppercase_hash_raises(self, hash_field: str) -> None:
        # sha256 hexdigest 는 lowercase. uppercase 는 reject.
        from app.core.errors import AppError
        good = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        good[hash_field] = "0123456789ABCDEF"  # 대문자
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(good)
        assert exc_info.value.code == "step.contract_violation"
        assert hash_field in exc_info.value.message

    def test_non_hex_hash_raises(self) -> None:
        # hex 가 아닌 문자 포함.
        from app.core.errors import AppError
        bad = {
            "schema_version": 2,
            "t2i_prompt_hash": "zzzzzzzzzzzzzzzz",  # 비-hex
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "t2i_prompt_hash" in exc_info.value.message

    def test_violation_item_not_dict_raises(self) -> None:
        from app.core.errors import AppError
        bad = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": ["not a dict"],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "violations[0]" in exc_info.value.message

    @pytest.mark.parametrize("missing_key", [
        "owned_object", "violating_phrase", "reason",
    ])
    def test_violation_item_missing_str_field_raises(self, missing_key: str) -> None:
        from app.core.errors import AppError
        viol = {
            "owned_object": "TV",
            "violating_phrase": "a TV in the corner",
            "reason": "redraw without anchor",
        }
        viol.pop(missing_key)
        bad = {
            "schema_version": 2,
            "t2i_prompt_hash": "0123456789abcdef",
            "owned_hash": "0123456789abcdef",
            "camera_direction_hash": "fedcba9876543210",
            "owned_usage_hash": "0123456789abcdef",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": [viol],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert missing_key in exc_info.value.message


from app.core.steps._owned_helpers import assert_background_prompt_owned_contract
from app.core.errors import AppError


class TestAssertBgPromptOwnedContract:
    def test_bg_mode_off_allows_none(self) -> None:
        # bg off 면 cp 부재 정상.
        assert_background_prompt_owned_contract(
            None, background_mode_on=False, where="t",
        )

    def test_bg_mode_on_none_cp_blocks(self) -> None:
        # round 4 BLOCKING 1: bg-on AND cp 부재 = silent fallback 차단.
        # step_runner gate 가 cp 존재 안 보므로 helper 책임.
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                None, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "missing" in exc_info.value.message.lower() or "None" in exc_info.value.message

    def test_bg_mode_on_old_v4_cp_blocks(self) -> None:
        old_cp = {
            "schema_version": 1,
            "data": {"backgrounds": {"bg1": {"status": "ok"}}},
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                old_cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message

    def test_bg_mode_on_v5_cp_with_owned_passes(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door", "window"],
                    },
                },
            },
        }
        assert_background_prompt_owned_contract(
            cp, background_mode_on=True, where="loader",
        )

    def test_bg_mode_on_v5_cp_partial_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door"],
                    },
                    "bg2": {"status": "ok"},  # owned 누락
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "bg2" in exc_info.value.message

    def test_bg_mode_on_v5_cp_failed_status_skipped(self) -> None:
        # status != ok 인 entry 는 owned 없어도 OK.
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door"],
                    },
                    "bg_failed": {"status": "failed"},  # status 가 ok 아님 → 통과
                },
            },
        }
        assert_background_prompt_owned_contract(
            cp, background_mode_on=True, where="loader",
        )

    def test_empty_owned_list_blocks(self) -> None:
        # background_prompt schema 가 minItems=1 강제. ok 인데 owned 빈 list 는
        # 강제 위반 (LLM 이 schema 우회한 케이스).
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {"status": "ok", "objects_owned_by_background": []},
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"

    # round 7 BLOCKING 1: 수동 편집 / 부분 산출 cp 가 한국어 owned 들고 있을 때
    # loader 경계에서 silent drop 되는 path 차단.
    def test_korean_only_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["문"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_mixed_korean_english_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door", "문"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_owned_all_whitespace_blocks_via_normalize(self) -> None:
        # normalize 후 빈 list → block (silent {} 차단).
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["  ", "\t"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "empty after normalize" in exc_info.value.message

# ── owned-judge prompt v2 cascade fix (2026-05-05): has_redraw_violation + verdict ──


from app.core.steps._owned_helpers import (
    has_redraw_violation,
    assert_owned_sentinel_shape,
    build_owned_sentinel,
)


class TestHasRedrawViolation:
    """Schema 자동 감지 — v1 legacy (verdict 없음) vs v2 (verdict 분류)."""

    def test_empty_array_returns_false(self) -> None:
        assert has_redraw_violation([]) is False

    def test_v1_legacy_any_violation_returns_true(self) -> None:
        # verdict 없음 → legacy 모드 → 비어있지 않으면 violation
        legacy = [{"owned_object": "door", "violating_phrase": "x", "reason": "y"}]
        assert has_redraw_violation(legacy) is True

    def test_v2_all_anchor_reference_returns_false(self) -> None:
        # 모두 anchor → contract_violation 아님 (false positive 차단)
        v2 = [
            {"owned_object": "door", "violating_phrase": "x", "reason": "y", "verdict": "anchor_reference"},
            {"owned_object": "window", "violating_phrase": "x", "reason": "y", "verdict": "anchor_reference"},
        ]
        assert has_redraw_violation(v2) is False

    def test_v2_at_least_one_redraw_returns_true(self) -> None:
        v2 = [
            {"owned_object": "door", "violating_phrase": "x", "reason": "y", "verdict": "anchor_reference"},
            {"owned_object": "TV", "violating_phrase": "x", "reason": "y", "verdict": "redraw_violation"},
        ]
        assert has_redraw_violation(v2) is True

    def test_v2_all_redraw_returns_true(self) -> None:
        v2 = [
            {"owned_object": "TV", "violating_phrase": "x", "reason": "y", "verdict": "redraw_violation"},
        ]
        assert has_redraw_violation(v2) is True

    def test_mixed_schema_raises_strict(self) -> None:
        # review I1: 일부만 verdict 있는 mixed schema — production 도달 불가
        # (LiteLLM schema validator 1차 차단), 도달 시 LLM bug / cp 손상
        # → silent fallback 차단 + fail-fast (debug 명확성).
        from app.core.errors import AppError

        mixed = [
            {"owned_object": "door", "violating_phrase": "x", "reason": "y"},
            {"owned_object": "TV", "violating_phrase": "x", "reason": "y", "verdict": "anchor_reference"},
        ]
        with pytest.raises(AppError) as exc_info:
            has_redraw_violation(mixed)
        assert exc_info.value.code == "step.contract_violation"
        assert "mixed verdict schema" in exc_info.value.message


class TestSentinelShapeVerdictOptional:
    def test_v1_legacy_no_verdict_passes(self) -> None:
        sentinel = build_owned_sentinel(
            owned=["door"], camera_direction="medium",
            t2i_prompt="x door", is_close_framing=False,
            violations=[{"owned_object": "door", "violating_phrase": "x door", "reason": "anchor"}],
            owned_object_usage=_absent_usage(["door"]),
        )
        # v1 legacy entry — verdict 없음 → assertion 통과 (backward-compat)
        assert_owned_sentinel_shape(sentinel, where="test_v1")

    def test_v2_valid_verdict_passes(self) -> None:
        sentinel = build_owned_sentinel(
            owned=["door"], camera_direction="medium",
            t2i_prompt="x door", is_close_framing=False,
            violations=[{
                "owned_object": "door", "violating_phrase": "x door",
                "reason": "redraw", "verdict": "redraw_violation",
            }],
            owned_object_usage=_absent_usage(["door"]),
        )
        assert_owned_sentinel_shape(sentinel, where="test_v2")

    def test_v2_invalid_verdict_raises(self) -> None:
        from app.core.errors import AppError

        sentinel = build_owned_sentinel(
            owned=["door"], camera_direction="medium",
            t2i_prompt="x door", is_close_framing=False,
            violations=[{
                "owned_object": "door", "violating_phrase": "x door",
                "reason": "redraw", "verdict": "maybe_violation",
            }],
            owned_object_usage=_absent_usage(["door"]),
        )
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(sentinel, where="test_invalid_verdict")
        assert exc_info.value.code == "step.contract_violation"
        assert "verdict" in exc_info.value.message


# ── C2 v1 §4.1: close-framing absent echo invariant ──


from app.core.steps._owned_helpers import assert_close_framing_absent_echo


class TestAssertCloseFramingAbsentEcho:
    """close-framing path: 모든 owned token usage_kind='absent' + empty source_phrase."""

    def test_all_absent_empty_passes(self) -> None:
        usage = [
            {"owned_token": "door", "usage_kind": "absent", "source_phrase": ""},
            {"owned_token": "window", "usage_kind": "absent", "source_phrase": ""},
        ]
        assert_close_framing_absent_echo(usage, where="test")  # no raise

    def test_empty_list_passes(self) -> None:
        assert_close_framing_absent_echo([], where="test")  # no raise

    def test_non_absent_usage_kind_raises(self) -> None:
        from app.core.errors import AppError
        usage = [
            {"owned_token": "door", "usage_kind": "anchor",
             "source_phrase": "near the door"},
        ]
        with pytest.raises(AppError) as exc_info:
            assert_close_framing_absent_echo(usage, where="test")
        assert exc_info.value.code == "step.contract_violation"
        assert "door" in exc_info.value.message

    def test_absent_with_nonempty_source_phrase_raises(self) -> None:
        # Codex N-1: usage_kind='absent' 이어도 source_phrase 가 비어있지 않으면
        # close-framing invariant 위반.
        from app.core.errors import AppError
        usage = [
            {"owned_token": "door", "usage_kind": "absent",
             "source_phrase": "near the door"},
        ]
        with pytest.raises(AppError) as exc_info:
            assert_close_framing_absent_echo(usage, where="test")
        assert exc_info.value.code == "step.contract_violation"
        assert "source_phrase" in exc_info.value.message
        assert "door" in exc_info.value.message
