"""G4.6 Wave A3 RC-E+RC-H — visible_entities_validator unit tests.

A-prime binding: authored fixture 는 모두 generic placeholder ("Adult
Character A/B", "dark jacket", "white blouse", "front-desk attendant",
"hooded adult figure", "unsupported_kind", "X01" 등) 사용 — fixture / kind /
id literal 어디에도 시나리오 derived token 0건. 본 docstring 과 module
src grep gate (test_no_scenario_descriptor_constants_in_module) 의 forbidden
pattern literal 은 의도적 carve-out — gate 가 검사할 token 자체를 알아야
동작하므로 grep 표현 자체에 forbidden token 이 등장하는 건 본질적이며,
fixture / production code 의 0-hit 정책과는 별도 layer.

Codex iter 1 B1 carry — validator + helper 가 db / project_id 인자 제거.
caller 가 main thread 에서 prebuild 한 name_by_short_id / traits_by_short_id
map 을 인자로 전달. test 도 dict 만 만듦 (mock db 불필요).

AppError 가 super().__init__() 호출 X — str(exc) 가 빈 문자열이라
pytest.raises(AppError, match=...) 동작 안 함. 대신 expect_apperror context
manager 가 exc.value.code 검사로 우회.
"""
from __future__ import annotations

import inspect
import re
from contextlib import contextmanager

import pytest

from app.core.errors import AppError
from app.core.steps.detail_steps import _build_entity_traits_block
from app.core.visible_entities_validator import (
    validate_visible_entities_contract,
)


# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────


@contextmanager
def expect_apperror(code_substr: str):
    """AppError raise 검사 + exc.code substring 매칭 (str(exc) 빈 문자열 우회)."""
    with pytest.raises(AppError) as excinfo:
        yield excinfo
    assert code_substr in excinfo.value.code, (
        f"expected AppError.code containing '{code_substr}', "
        f"got code='{excinfo.value.code}', message='{excinfo.value.message}'"
    )


def _make_name_map(canons: dict) -> dict:
    """canons: {short_id: {"name": str, ...}} → name_by_short_id map."""
    return {sid: info.get("name", "") for sid, info in canons.items()}


def _make_traits_map(canons: dict) -> dict:
    """canons: {short_id: {"stable_traits": list, ...}} → traits map."""
    return {sid: info.get("stable_traits", []) for sid, info in canons.items()}


def _rpc_empty() -> dict:
    """generic render_prompt_card with empty refs/pairs + bg-not-applicable.
    Area #1: subject_reference_policy field required (empty array = default)."""
    return {
        "asset_requirements": {"required_refs": []},
        "id_policy": {
            "allowed_outlook_pairs": [],
            "subject_reference_policy": [],
        },
        "background_binding": {"mode": "not_applicable", "bg_id": None},
    }


def _rpc_with(refs: list, *, bg_mode: str = "not_applicable",
              bg_id=None, outlook_pairs: list = None) -> dict:
    """builder helper — kind-dispatch regression tests fixture.
    Area #1: subject_reference_policy field required."""
    return {
        "asset_requirements": {"required_refs": list(refs)},
        "id_policy": {
            "allowed_outlook_pairs": list(outlook_pairs or []),
            "subject_reference_policy": [],
        },
        "background_binding": {"mode": bg_mode, "bg_id": bg_id},
    }


# ─────────────────────────────────────────────
# Source 1 — ID coverage primary (forward + reverse)
# ─────────────────────────────────────────────


def test_validate_pass_when_all_visible_ids_in_prompt():
    """Source 1 forward + reverse — 모든 visible base 가 prompt 에 등장."""
    name_map = {"C08": "Adult Character A", "C09": "Adult Character B"}
    shot = {
        "scene_index": 1, "_shot_index": 1,
        "visible_entities": ["C08", "C09"],
        "t2i_variations": [{
            "t2i_prompt": "C08O01 standing beside C09O01, both looking at the door.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_visible_base_missing_in_prompt():
    """Source 1 forward (RC-E primary) — visible C09 base 가 prompt 에 없음."""
    name_map = {"C08": "Adult Character A", "C09": "Adult Character B"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "C09", "L03"],
        "t2i_variations": [{
            "t2i_prompt": "C08O10 in dark jacket, an adult figure, hands raised.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("subject_reference_policy.base_id_missing"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_used_id_not_in_visible():
    """Source 1 reverse — used 'C09' not in visible_entities."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "L03"],
        "t2i_variations": [{
            "t2i_prompt": "C08O10 standing. C09O01 walks in beside.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# Source 2 — dynamic entity_canon.name diagnostic
# ─────────────────────────────────────────────


def test_validate_pass_when_entity_name_anchored_with_specific_id():
    """Source 2 — entity_canon.name + specific ID 가 same sentence + ±60 char."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08O10 in dark jacket, Adult Character A, hands raised."
            ),
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {"character_id": "C08", "outlook_id": "O10"},
                ],
                "subject_reference_policy": [],
            },
        },
    }
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_entity_name_anchored_to_other_id():
    """Source 2 — 다른 visible ID 가 window 안에 있어도 specific 매칭 안 되면 reject.

    검사 중인 name (Adult Character B = C09) 의 specific ID 가 window 안에
    없음 (C09 은 멀리 있음). C08O10 이 window 안에 있어도 reject (C09 의
    candidate 가 아님).

    Source 1 PASS 가 보장되도록 C08, C09 모두 prompt 어딘가에 등장 — 단
    "Adult Character B" name 위치는 C09 와 멀리, C08O10 옆.
    """
    name_map = {"C08": "Adult Character A", "C09": "Adult Character B"}
    long_filler = " filler text " * 10
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "C09"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08O10 in dark jacket, Adult Character B in white blouse." +
                long_filler +
                "Beside, C09O01 standing afar."
            ),
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("contract_violation_entity_name_no_specific_id"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_entity_name_outside_window():
    """Source 2 RO-23 — name 위치가 specific ID 로부터 ±60 char 밖."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08O01 sits at the desk watching the monitor flicker. The room is "
                "dim, the air is heavy, the floor cold. Across the table, "
                "Adult Character A walks in."
            ),
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("contract_violation_entity_name_no_specific_id"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_entity_name_in_separate_sentence():
    """Source 2 RO-27 — sentence boundary (period) 가 anchor 차단."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08O01 stands. Adult Character A turns away.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("contract_violation_entity_name_no_specific_id"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# Source 3a — required_refs (Codex iter 1 I1 type validation 포함)
# ─────────────────────────────────────────────


def test_validate_fail_when_required_refs_id_missing_from_visible():
    """Source 3a — required_refs 의 char base 가 visible 안에 없음."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": [
                {"kind": "character_outlook", "id": "C09O11"},
            ]},
            "id_policy": {"allowed_outlook_pairs": [], "subject_reference_policy": []},
        },
    }
    with expect_apperror("contract_violation_required_ref_not_visible"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_required_refs_dict_shape_robust():
    """malformed entry → silent skip 금지, AppError fail-fast."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": ["malformed_string_entry"]},
            "id_policy": {"allowed_outlook_pairs": [], "subject_reference_policy": []},
        },
    }
    with expect_apperror("contract_violation_required_ref_type"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_required_refs_id_non_string(name_map=None):
    """Codex iter 1 I1 — required_refs 'id' non-string → AttributeError 차단."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": [
                {"kind": "character", "id": 12345},
            ]},
            "id_policy": {"allowed_outlook_pairs": [], "subject_reference_policy": []},
        },
    }
    with expect_apperror("contract_violation_required_ref_id_type"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_required_refs_id_malformed_format():
    """Codex iter 1 I1 — required_refs 'id' format 위반 → fail-fast."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": [
                {"kind": "character", "id": "INVALID_FORMAT"},
            ]},
            "id_policy": {"allowed_outlook_pairs": [], "subject_reference_policy": []},
        },
    }
    with expect_apperror("contract_violation_required_ref_id_format"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# Source 3b — allowed_outlook_pairs
# ─────────────────────────────────────────────


def test_validate_fail_when_id_policy_character_id_missing_from_visible():
    """Source 3b RO-16 — allowed_outlook_pairs 의 character_id 가 visible 밖."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {"character_id": "C09", "outlook_id": "O11"},
                ],
                "subject_reference_policy": [],
            },
        },
    }
    with expect_apperror("contract_violation_outlook_pair_not_visible"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_with_legacy_base_id_shape():
    """Source 3b legacy fallback — base_id field 사용 시도."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {"base_id": "C09", "outlook_id": "O11"},
                ],
                "subject_reference_policy": [],
            },
        },
    }
    with expect_apperror("contract_violation_outlook_pair_not_visible"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# Source 3a kind-dispatch — background ref + cross-field equality
# (Phase 4 fix iter 3 — chain_bg bg_id 합법 ID 인정)
# ─────────────────────────────────────────────


def _bg_shot_base(*, refs: list, bg_mode: str, bg_id) -> dict:
    """build a shot with given required_refs + background_binding."""
    return {
        "scene_index": 4, "_shot_index": 1,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 sits at the rooftop edge."}],
        "render_prompt_card": _rpc_with(
            refs=refs, bg_mode=bg_mode, bg_id=bg_id,
        ),
    }


def test_validate_pass_background_ref_attached_with_matching_bg_id():
    """kind=background + mode=ref_attached + id == background_binding.bg_id → PASS."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_id_mismatches_binding():
    """kind=background + ref id ≠ background_binding.bg_id → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_other", "policy": "required"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_background_id_mismatch"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_present_but_mode_skipped_close():
    """mode=skipped_close_framing 인데 background required_ref 있음 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"}],
        bg_mode="skipped_close_framing",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_background_mode_mismatch"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_present_but_mode_off():
    """mode=background_mode_off 인데 background required_ref 있음 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"}],
        bg_mode="background_mode_off",
        bg_id=None,
    )
    with expect_apperror("contract_violation_required_ref_background_mode_mismatch"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_present_but_mode_not_applicable():
    """mode=not_applicable 인데 background required_ref 있음 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    with expect_apperror("contract_violation_required_ref_background_mode_mismatch"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_present_but_binding_missing():
    """background required_ref 있는데 render_prompt_card.background_binding 없음 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 4, "_shot_index": 1,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08O01 sits at the rooftop edge."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": [
                {"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"},
            ]},
            "id_policy": {"allowed_outlook_pairs": [], "subject_reference_policy": []},
            # background_binding intentionally missing
        },
    }
    with expect_apperror("contract_violation_missing_field"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_present_but_bg_id_none():
    """mode=ref_attached 인데 background_binding.bg_id 가 None → FAIL (M3 separate)."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "required"}],
        bg_mode="background_ref_attached",
        bg_id=None,
    )
    with expect_apperror("contract_violation_background_binding_bg_id_invalid"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_required_ref_kind_unknown():
    """kind 가 알려진 5종 (character/character_outlook/prop/location/background) 외 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "unsupported_kind", "id": "X01", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    with expect_apperror("contract_violation_required_ref_unknown_kind"):
        validate_visible_entities_contract(shot, name_map)


# Codex iter 4 — bidirectional + policy + bg_id type separation 추가 케이스


def test_validate_fail_when_binding_ref_attached_but_no_required_background_ref():
    """B1 reverse — binding mode=ref_attached + bg_id 있는데 required_refs 에
    kind='background' entry 없음 → FAIL (producer drift)."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_background_missing"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_binding_ref_attached_but_bg_id_none_and_no_ref():
    """B1 + M3 reverse — binding ref_attached + bg_id None + ref 없음 → bg_id_invalid 가
    먼저 raise (reverse 진입 후 missing 보다 type 검사 우선)."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[],
        bg_mode="background_ref_attached",
        bg_id=None,
    )
    with expect_apperror("contract_violation_background_binding_bg_id_invalid"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_policy_not_required():
    """M1 — kind=background entry 의 policy 필드가 'required' 아닐 때 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal", "policy": "optional"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_background_policy"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_policy_missing():
    """M1 — kind=background entry 에 policy 필드 자체가 없을 때 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "bg_rooftop_day_normal"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_background_policy"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_id_non_string():
    """M6 (Claude) — kind=background entry 의 id 가 non-string → id_type AppError."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": 12345, "policy": "required"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_id_type"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# Source 1 forward exemption — Codex iter 6 B1
# (Rule X-2 partial_focus / body-part / reproduction 예외 dispatch)
# ─────────────────────────────────────────────


def _forward_exempt_shot(*, prompt: str, render_strategy: dict | None = None,
                         id_policy_extra: dict | None = None,
                         visible: list | None = None) -> dict:
    visible = visible if visible is not None else ["C08"]
    # Area #1 (2026-05-16) — subject_reference_policy field required.
    # Default empty array (= 모든 subject default id_and_outlook_required).
    id_policy = {
        "allowed_outlook_pairs": [],
        "subject_reference_policy": [],
    }
    if id_policy_extra:
        id_policy.update(id_policy_extra)
    rpc = {
        "asset_requirements": {"required_refs": []},
        "id_policy": id_policy,
        "background_binding": {"mode": "not_applicable", "bg_id": None},
    }
    if render_strategy is not None:
        rpc["render_strategy"] = render_strategy
    return {
        "scene_index": 5, "_shot_index": 1,
        "visible_entities": visible,
        "t2i_variations": [{"t2i_prompt": prompt}],
        "render_prompt_card": rpc,
    }


def test_validate_pass_when_render_strategy_mode_is_partial_focus():
    """B1 — render_strategy.mode == 'partial_focus' 면 forward enforcement skip
    (LLM 이 visible 일부만 그려도 OK). Area #1: C08 outlook form (default
    policy id_and_outlook_required satisfaction)."""
    name_map = {"C08": "Adult Character A", "C09": "Adult Character B"}
    shot = _forward_exempt_shot(
        prompt="C08O01 turning toward the door.",
        render_strategy={"mode": "partial_focus"},
        visible=["C08", "C09"],
    )
    validate_visible_entities_contract(shot, name_map)


# Area #1 (2026-05-16): test_validate_pass_when_body_part_trigger_phrase_in_prompt
# 폐기 — body_part_focus_rule 폐기 + per-subject policy SOT 대체. body-part
# substring 분기는 사라졌고, per-subject policy=generic_descriptor_allowed 가
# 같은 의도 표현. W4.1 신규 test 가 per-subject matrix 검증.


def test_validate_pass_when_reproduction_surface_rule_applies():
    """B1 (Area C migrated 2026-05-12) —
    id_policy.reproduction_surface_rule.applies == True 시 면제.

    Preserved intent: reproduction surface 조건 (사진/거울/포스터 안 인물)
    에서 visible base 의 ID 가 prompt 에 안 나와도 면제. 단 면제 trigger 는
    이제 producer 가 shot_staging directionality_class 에서 derive 한
    boolean (substring 매칭 제거)."""
    name_map = {"C08": "Adult Character A"}
    shot = _forward_exempt_shot(
        prompt="a printed photograph showing a figure in profile.",
        id_policy_extra={
            "reproduction_surface_rule": {
                "applies": True,
                "id_use": "forbidden",
                "rationale_summary": "...",
            },
        },
        visible=["C08"],
    )
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_no_exemption_matches():
    """B1 — render_strategy.mode != partial_focus + no body-part / reproduction
    keyword → forward enforcement 그대로 동작."""
    name_map = {"C08": "Adult Character A"}
    shot = _forward_exempt_shot(
        prompt="a generic figure leaning against the wall.",
        render_strategy={"mode": "direct"},
    )
    with expect_apperror("subject_reference_policy.base_id_missing"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_reverse_enforced_even_when_exempt():
    """B1 — 면제는 forward 만. reverse (prompt 안 ID 가 visible 밖) 는 그대로
    enforce — partial_focus 면제 받았어도 다른 ID 사용은 차단."""
    name_map = {"C08": "Adult Character A"}
    shot = _forward_exempt_shot(
        prompt="C99 enters the room.",  # C99 is not in visible
        render_strategy={"mode": "partial_focus"},
        visible=["C08"],
    )
    with expect_apperror("contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_render_strategy_missing_and_no_keyword():
    """B1 — render_strategy 자체 부재 + per-subject policy default → forward
    enforcement (default id_and_outlook_required → base_required)."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 5, "_shot_index": 1,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "a generic figure standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [],
                "subject_reference_policy": [],
            },
            "background_binding": {"mode": "not_applicable", "bg_id": None},
            # render_strategy intentionally omitted
        },
    }
    with expect_apperror("subject_reference_policy.base_id_missing"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_character_kind_id_format_invalid():
    """kind=character 인데 id 가 C## 패턴 위반 → FAIL (kind dispatch)."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "character", "id": "L05", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    with expect_apperror("contract_violation_required_ref_id_format"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_pass_prop_kind_with_valid_p_format():
    """kind=prop + id=P## → PASS (visible 검사는 character 만).
    Patch A PRO-13: required prop 의 P## 가 t2i_prompt 에 등장해야 함 (synthetic P91)."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "prop", "id": "P91", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    # PRO-13 충족 — t2i_prompt 안에 P91 등장 (fixture P91 prop 가정)
    shot["t2i_variations"] = [
        {"t2i_prompt": "C08O01 sits at the rooftop edge. P91 rests on the wall."},
    ]
    validate_visible_entities_contract(shot, name_map)


def test_validate_pass_location_kind_with_valid_l_format():
    """kind=location + id=L## → PASS."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "location", "id": "L02", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_prop_kind_id_format_invalid():
    """kind=prop 인데 id 가 P## 패턴 위반 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "prop", "id": "C09", "policy": "required"}],
        bg_mode="not_applicable",
        bg_id=None,
    )
    with expect_apperror("contract_violation_required_ref_id_format"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_background_ref_id_empty_string():
    """kind=background 인데 id 가 빈 문자열 → FAIL."""
    name_map = {"C08": "Adult Character A"}
    shot = _bg_shot_base(
        refs=[{"kind": "background", "id": "", "policy": "required"}],
        bg_mode="background_ref_attached",
        bg_id="bg_rooftop_day_normal",
    )
    with expect_apperror("contract_violation_required_ref_no_id"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# RO-15 — no bypass for failed validator_status
# ─────────────────────────────────────────────


def test_validate_no_bypass_for_failed_validator_status():
    """RO-15 — validator_status='failed_carry_original' 도 contract enforce."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "validator_status": "failed_carry_original",
        "visible_entities": [],
        "t2i_variations": [{"t2i_prompt": "C09O01 walks in"}],
        "render_prompt_card": _rpc_empty(),
    }
    with expect_apperror("contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, {})


# ─────────────────────────────────────────────
# helper — _outlook_id_candidates_for_base format strict
# ─────────────────────────────────────────────


def test_validate_pass_when_outlook_uses_composite_id_field():
    """helper 우선순위 1 — composite_id 필드 사용 (UUID outlook_id 우회)."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08O15 in white blouse, Adult Character A, smiling.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {
                        "character_id": "C08",
                        "composite_id": "C08O15",
                        "outlook_id": "abc-uuid-form",
                    },
                ],
                "subject_reference_policy": [],
            },
        },
    }
    validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_composite_id_malformed_format():
    """helper format 강제 — composite_id 가 C##O## 형식 위반."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08O01 standing, Adult Character A walks beside.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {"character_id": "C08", "composite_id": "C08-O15", "outlook_id": "O15"},
                ],
                "subject_reference_policy": [],
            },
        },
    }
    with expect_apperror("contract_violation_composite_id_format"):
        validate_visible_entities_contract(shot, name_map)


def test_validate_fail_when_outlook_id_uuid_no_composite():
    """helper format 강제 — composite_id 부재 + outlook_id 가 UUID/non-O##."""
    name_map = {"C08": "Adult Character A"}
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08O01 standing, Adult Character A walks beside.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {
                "allowed_outlook_pairs": [
                    {"character_id": "C08", "outlook_id": "abc-uuid-123"},
                ],
                "subject_reference_policy": [],
            },
        },
    }
    with expect_apperror("contract_violation_outlook_id_format"):
        validate_visible_entities_contract(shot, name_map)


# ─────────────────────────────────────────────
# A-prime grep gate — production module 시나리오 token 0
# ─────────────────────────────────────────────


def test_no_scenario_descriptor_constants_in_module():
    """A-prime grep gate — module source 안 nationality/ethnicity/작품 고유명사 0건."""
    import app.core.visible_entities_validator as vev_module
    src = inspect.getsource(vev_module)
    forbidden = re.compile(
        r"(?i)(korean|east asian|asian woman|asian man|한국인|일본인|중국인|"
        r"어린 소녀|남자 직원|여직원|여자 직원|young\s*\w{0,4}\s*(man|woman|girl|boy)|"
        r"adult\s*(male|female)|yokai|요괴|fang|vampire|흡혈)"
    )
    matches = forbidden.findall(src)
    assert not matches, (
        f"visible_entities_validator.py contains scenario-derived descriptor "
        f"tokens: {matches}. A-prime binding violation — 모든 nationality/"
        f"ethnicity/작품 고유명사 production constant 금지."
    )


# ─────────────────────────────────────────────
# _build_entity_traits_block — RC-H entity_traits block helper
# Codex iter 1 B1 carry — ctx prebuild map 사용 (db / project_id 인자 제거)
# ─────────────────────────────────────────────


def test_build_entity_traits_block_returns_empty_when_no_visible_chars():
    """visible_entities 에 character base 없으면 "" 반환."""
    block = _build_entity_traits_block(["L03", "P01"], {}, {})
    assert block == ""


def test_build_entity_traits_block_returns_empty_when_no_traits():
    """모든 entity 의 stable_traits 가 빈 list 면 "" 반환 (block inject 안 함)."""
    block = _build_entity_traits_block(
        ["C08"],
        {"C08": "Adult Character A"},
        {"C08": []},
    )
    assert block == ""


def test_build_entity_traits_block_includes_all_visible_with_traits():
    """visible_entities 의 모든 character base 의 stable_traits 가 block 에 포함."""
    name_map = {"C08": "Adult Character A", "C09": "Adult Character B"}
    traits_map = {
        "C08": ["trait_token_alpha", "trait_token_beta"],
        "C09": ["trait_token_gamma"],
    }
    block = _build_entity_traits_block(["C08", "C09"], name_map, traits_map)
    assert "[Entity stable_traits for this shot]" in block
    assert "C08 (Adult Character A): trait_token_alpha, trait_token_beta" in block
    assert "C09 (Adult Character B): trait_token_gamma" in block


def test_build_entity_traits_block_fail_fast_on_missing_short_id():
    """visible_entities 의 character base ID 가 prebuild map 에 없음 → AppError."""
    name_map = {"C08": "Adult Character A"}
    traits_map = {"C08": ["t1"]}
    with expect_apperror("unknown_short_id"):
        _build_entity_traits_block(["C08", "C09"], name_map, traits_map)


def test_build_entity_traits_block_handles_outlook_composite_id():
    """visible_entities 안 C##O## composite 도 base C## 로 추출되어 처리."""
    block = _build_entity_traits_block(
        ["C08O10"],
        {"C08": "Adult Character A"},
        {"C08": ["trait_token_alpha"]},
    )
    assert "C08 (Adult Character A): trait_token_alpha" in block


# ─────────────────────────────────────────────
# AppError signature (RO-22)
# ─────────────────────────────────────────────


def test_apperror_constructor_signature_module_source():
    """RO-22 — visible_entities_validator.py 안 모든 raise 가 keyword arg 사용."""
    import app.core.visible_entities_validator as vev_module
    src = inspect.getsource(vev_module)
    positional_pattern = re.compile(r'AppError\(\s*["f]')
    matches = positional_pattern.findall(src)
    assert not matches, (
        f"visible_entities_validator.py contains positional AppError calls: "
        f"{matches}. Use keyword args (code=, message=, status_code=)."
    )


# =========================================================================
# Patch A Task 5: PRO-13 — required prop P## must appear in at least one
# t2i_variation prompt (Tier 1 LLM output contract).
# synthetic fixtures only — P91~P99
# =========================================================================
class TestPro13PropPidInPrompt:
    def _make_shot(
        self,
        required_prop_ids: list,
        variation_prompts: list,
        visible_short_ids=None,
    ) -> dict:
        """fixture builder — required_refs prop 과 t2i_variations 만 좁게 구성."""
        required_refs = [
            {"kind": "prop", "id": pid, "policy": "required"}
            for pid in required_prop_ids
        ]
        visible = visible_short_ids or list(required_prop_ids)
        return {
            "scene_index": 1, "_shot_index": 1,
            "visible_entities": visible,
            "t2i_variations": [
                {"t2i_prompt": p} for p in variation_prompts
            ],
            "render_prompt_card": {
                "id_policy": {
                    "allowed_base_entity_ids": list(visible),
                    "allowed_outlook_pairs": [],
                    "common_noun_required_when": [],
                    "id_use_required_when": [],
                    "subject_reference_policy": [],  # Area #1 field required
                },
                "asset_requirements": {
                    "required_refs": required_refs,
                    "forbidden_refs": [],
                    "readiness_policy": "block_if_missing" if required_refs else "n/a",
                },
                "background_binding": {"mode": "not_applicable"},
            },
        }

    def test_pro13_prop_pid_in_variation_passes(self):
        """PRO-13: required prop P91 가 적어도 한 variation 의 t2i_prompt 에 등장 → pass."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. P91 lies flat on the console.",
            ],
        )
        validate_visible_entities_contract(shot, name_by_short_id={})

    def test_pro13_prop_pid_missing_from_all_variations_fails(self):
        """PRO-13: required prop P91 가 어떤 variation 에도 없으면 fail."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. an old photograph lies flat.",
            ],
        )
        with pytest.raises(AppError) as exc:
            validate_visible_entities_contract(shot, name_by_short_id={})
        assert exc.value.code == (
            "step.scene_detail.contract_violation_prop_p_id_missing_in_prompt"
        )
        assert "P91" in str(exc.value.message)

    def test_pro13_word_boundary_blocks_substring_false_positive(self):
        """PRO-13: 'P91' word-boundary 만. 'P910' 안 substring 매치 X."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. P910 lies on the table.",
            ],
        )
        with pytest.raises(AppError):
            validate_visible_entities_contract(shot, name_by_short_id={})

    def test_pro13_multi_required_props_all_must_appear(self):
        """PRO-13: required 가 다수 prop 이면 각 P## 가 적어도 한 variation 에 등장.
        하나라도 누락 시 fail."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        # P91 만 있고 P93 누락
        shot = self._make_shot(
            required_prop_ids=["P91", "P93"],
            variation_prompts=[
                "Photorealistic still. P91 lies flat.",
                "Wide shot — P91 on the console.",
            ],
        )
        with pytest.raises(AppError) as exc:
            validate_visible_entities_contract(shot, name_by_short_id={})
        assert "P93" in str(exc.value.message)

    def test_pro13_no_required_prop_skips_check(self):
        """PRO-13: required_refs 에 prop 없으면 PRO-13 검사 자체 skip (기존 동작 회귀 0)."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        shot = self._make_shot(
            required_prop_ids=[],
            variation_prompts=["Photorealistic still. a wide landscape."],
        )
        # 통과 (이 PRO-13 룰 무관)
        validate_visible_entities_contract(shot, name_by_short_id={})


# Area C — consumer reproduction_surface_rule.applies migration tests
# (do not duplicate imports if already present at top of file)


def _area_c_make_rpc(applies):
    """Test fixture — minimal RPC with reproduction_surface_rule."""
    return {
        "render_strategy": {"mode": "default"},
        "id_policy": {
            "reproduction_surface_rule": {
                "applies": applies,
                "id_use": "forbidden",
                "rationale_summary": "...",
            },
            "subject_reference_policy": [],
        },
    }


def test_area_c_consumer_applies_true_returns_exempt():
    """applies=True → (True, 'reproduction_surface_rule.applies == True')."""
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    exempt, reason = _is_forward_exempt_by_reproduction_surface_rule(
        rpc=_area_c_make_rpc(True),
    )
    assert exempt is True
    assert "reproduction_surface_rule.applies == True" in reason


def test_area_c_consumer_applies_false_falls_through():
    """applies=False → fall-through (다른 분기 검사 진행, 본 케이스 = False)."""
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    exempt, reason = _is_forward_exempt_by_reproduction_surface_rule(
        rpc=_area_c_make_rpc(False),
    )
    assert exempt is False
    assert reason == ""


def test_area_c_consumer_field_absent_falls_through():
    """reproduction_surface_rule field 부재 (legacy/stale card) → fall-through."""
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    rpc = {"render_strategy": {"mode": "default"}, "id_policy": {}}
    exempt, reason = _is_forward_exempt_by_reproduction_surface_rule(rpc=rpc)
    assert exempt is False


def test_area_c_consumer_applies_none_raises():
    """applies=None → AppError(malformed)."""
    import pytest
    from app.core.errors import AppError
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    rpc = _area_c_make_rpc(None)
    with pytest.raises(AppError) as exc_info:
        _is_forward_exempt_by_reproduction_surface_rule(rpc=rpc)
    assert exc_info.value.code == "visible_entities_validator.reproduction_surface_rule_malformed"


def test_area_c_consumer_applies_string_raises():
    """applies='true' (string) → AppError(malformed)."""
    import pytest
    from app.core.errors import AppError
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    rpc = _area_c_make_rpc("true")
    with pytest.raises(AppError) as exc_info:
        _is_forward_exempt_by_reproduction_surface_rule(rpc=rpc)
    assert exc_info.value.code == "visible_entities_validator.reproduction_surface_rule_malformed"


def test_area_c_consumer_no_substring_match_when_applies_false():
    """Area C 핵심 — applies=False 면 exempt X. substring 매칭 분기 폐기 verify."""
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    rpc = _area_c_make_rpc(False)
    exempt, reason = _is_forward_exempt_by_reproduction_surface_rule(rpc=rpc)
    assert exempt is False, (
        "substring 매칭 분기 잔존 — Area C migration 미완"
    )


def test_area_c_consumer_reproduction_surface_rule_non_dict_raises():
    """Critical regression pin — non-dict reproduction_surface_rule raises
    instead of silent fall-through (Gate 4 정합)."""
    from app.core.visible_entities_validator import _is_forward_exempt_by_reproduction_surface_rule
    for bad_value in ["bad-string", [], 42, True]:
        rpc = {
            "render_strategy": {"mode": "default"},
            "subject_reference_policy": [],
            "id_policy": {"reproduction_surface_rule": bad_value},
        }
        with pytest.raises(AppError) as exc_info:
            _is_forward_exempt_by_reproduction_surface_rule(rpc=rpc)
        assert exc_info.value.code == "visible_entities_validator.reproduction_surface_rule_malformed", (
            f"Non-dict reproduction_surface_rule={bad_value!r} "
            f"({type(bad_value).__name__}) must raise malformed. "
            f"Got: {exc_info.value.code}"
        )


# ─────────────────────────────────────────────
# Area #1 — per-subject policy validator matrix + structured exemption 보존
# ─────────────────────────────────────────────


def _make_shot_for_validator(
    visible,
    srp_array,
    prompt,
    render_strategy_mode="direct",
    reproduction_applies=False,
):
    """Test fixture — validate_visible_entities_contract minimal shot dict."""
    outlook_pairs = [
        {"character_id": sid, "outlook_id": "O01"} for sid in visible
        if sid.startswith("C")
    ]
    return {
        "scene_index": 1,
        "_shot_index": 1,
        "shot_index": 1,
        "visible_entities": visible,
        "t2i_variations": [
            {
                "t2i_prompt": prompt,
                "applied_frame_spatial_constraint_ids": [],
            },
        ],
        "render_prompt_card": {
            "schema_version": 1,
            "shot_key": {"scene_index": 1, "shot_index": 1},
            "render_strategy": {"mode": render_strategy_mode},
            "id_policy": {
                "allowed_base_entity_ids": visible,
                "allowed_outlook_pairs": outlook_pairs,
                "subject_reference_policy": srp_array,
                "reproduction_surface_rule": {
                    "applies": reproduction_applies,
                    "id_use": "see reproduction_surface_rule",
                    "rationale_summary": "test fixture",
                },
            },
            "asset_requirements": {
                "required_refs": [],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            },
            "background_binding": {
                "mode": "background_mode_off",
                "bg_id": None,
                "owned_objects": [],
            },
        },
    }


def _srp(subject_id, policy, reason="test"):
    return {
        "subject_id": subject_id,
        "policy_type": "identity_reference",
        "policy": policy,
        "reason": reason,
    }


def test_validator_id_and_outlook_required_base_missing_fail():
    """policy=id_and_outlook_required, base C## 누락 → ".base_id_missing"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="a man speaks at length",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".base_id_missing" in exc.value.code


def test_validator_id_and_outlook_required_outlook_missing_fail():
    """policy=id_and_outlook_required, base 있지만 outlook 없음 → ".outlook_id_missing"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="C01 speaks at length",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_id_missing" in exc.value.code


def test_validator_id_and_outlook_required_both_present_pass():
    """policy=id_and_outlook_required, base + outlook 모두 → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="C01O01 speaks at length",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_base_id_required_outlook_forbidden_fail():
    """policy=base_id_required + C##O## present → ".outlook_forbidden"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "base_id_required")],
        prompt="C01O01 speaks",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_forbidden" in exc.value.code


def test_validator_base_id_required_base_present_pass():
    """policy=base_id_required + C## present (outlook 없음) → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "base_id_required")],
        prompt="C01 speaks",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_generic_descriptor_allowed_no_id_pass():
    """policy=generic_descriptor_allowed + no ID → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "generic_descriptor_allowed")],
        prompt="a hooded figure speaks in shadow",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_generic_descriptor_allowed_outlook_present_fail():
    """policy=generic_descriptor_allowed + C##O## present → ".outlook_forbidden"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "generic_descriptor_allowed")],
        prompt="C01O01 speaks",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_forbidden" in exc.value.code


# Phase 3 W1 — new tests for base_forbidden enforcement
def test_validator_generic_descriptor_allowed_bare_base_fail():
    """policy=generic_descriptor_allowed + bare C## (no outlook) → ".base_id_forbidden"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "generic_descriptor_allowed")],
        prompt="C01 speaks softly in shadow",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".base_id_forbidden" in exc.value.code


def test_validator_base_id_required_bare_base_pass():
    """policy=base_id_required + bare C## (no outlook) → PASS (regression guard)."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "base_id_required")],
        prompt="C01 stands in the room",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_per_subject_exception_does_not_leak():
    """한 subject 의 예외가 다른 subject 의 누락을 풀지 못함 (Gate 4)."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01", "C02"],
        srp_array=[
            _srp("C01", "generic_descriptor_allowed"),  # C01: ID 없어도 OK
            _srp("C02", "id_and_outlook_required"),     # C02: ID + outlook 의무
        ],
        prompt="a hooded figure stands while another silhouette watches",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(
            shot, name_by_short_id={"C01": "Alice", "C02": "Bob"}
        )
    assert ".base_id_missing" in exc.value.code


def test_partial_focus_render_strategy_still_exempts_forward():
    """Area B/G4 보존 — render_strategy.mode == 'partial_focus' → exempt 유지."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],
        prompt="a hand reaches into frame",
        render_strategy_mode="partial_focus",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_reproduction_surface_rule_applies_still_exempts_forward():
    """Area C 보존 — reproduction_surface_rule.applies == True → exempt 유지."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],
        prompt="a photograph propped on the desk",
        reproduction_applies=True,
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_card_field_missing_fail_fast():
    """id_policy.subject_reference_policy field 부재 → ".id_policy.field_missing"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],
        prompt="C01O01 speaks",
    )
    del shot["render_prompt_card"]["id_policy"]["subject_reference_policy"]
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".id_policy.field_missing" in exc.value.code
