"""G4.1 RenderPromptCard builder + shape + entry-point unit tests.

Tasks covered (per docs/superpowers/plans/2026-05-04-g4-render-prompt-card-implementation.md):
  - Task 2: build_empty_card
  - Task 3: build_render_strategy (R1-I1 / R2-B2)
  - Task 4: build_id_policy (R2-B4)
  - Task 5: build_background_binding (R5-B4 close skip carry)
  - Task 6: build_continuity_elements_used (R1-I2 / R1-I7 / R2-B4)
  - Task 7: build_asset_requirements (R2-B4)
  - Task 9: assert_card_shape (R2-B2 / R2-I1)
  - Task 9.5: build_render_prompt_card (entry point)
"""
from __future__ import annotations

import pytest

from app.core.errors import AppError
from app.core.steps.render_prompt_card import (
    BG_MODE_NOT_APPLICABLE,
    BG_MODE_OFF,
    BG_MODE_REF_ATTACHED,
    BG_MODE_SKIPPED_CLOSE,
    CARD_SCHEMA_VERSION,
    FRAMING_CLOSE,
    FRAMING_INSERT,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
    READINESS_BLOCK,
    READINESS_NA,
    READINESS_SKIPPED,
    RENDER_MODE_DIRECT,
    RENDER_MODE_NOT_APPLICABLE,
    _VALID_RENDER_MODES,
    assert_card_shape,
    build_asset_requirements,
    build_background_binding,
    build_continuity_elements_used,
    build_empty_card,
    build_id_policy,
    build_render_prompt_card,
    build_render_strategy,
)


# =========================================================================
# Task 2: build_empty_card
# =========================================================================
class TestBuildEmptyCard:
    def test_envelope_has_schema_version(self) -> None:
        c = build_empty_card(scene_index=12, shot_index=4)
        assert c["schema_version"] == CARD_SCHEMA_VERSION

    def test_envelope_has_shot_key(self) -> None:
        c = build_empty_card(scene_index=12, shot_index=4)
        assert c["shot_key"] == {"scene_index": 12, "shot_index": 4}

    def test_envelope_has_5_field_placeholders(self) -> None:
        c = build_empty_card(scene_index=1, shot_index=1)
        for f in (
            "render_strategy", "id_policy", "background_binding",
            "continuity_elements_used", "asset_requirements",
        ):
            assert f in c
            assert isinstance(c[f], dict)

    def test_no_extra_top_level_fields(self) -> None:
        # spec §4: 정확히 envelope (schema_version, shot_key) + 5 fields = 7 keys.
        c = build_empty_card(scene_index=1, shot_index=1)
        assert set(c.keys()) == {
            "schema_version", "shot_key", "render_strategy", "id_policy",
            "background_binding", "continuity_elements_used", "asset_requirements",
        }


# =========================================================================
# Task 3: build_render_strategy
# =========================================================================
class TestBuildRenderStrategy:
    def test_default_mode_direct(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide shot of room"},
            staging={"camera_direction": "wide shot of room",
                     "framing_scale": "wide",
                     "lighting_mood": "warm dim",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["mode"] == RENDER_MODE_DIRECT
        assert s["framing_scale"] == FRAMING_WIDE

    def test_close_framing_detected(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "extreme close-up of hand"},
            staging={"camera_direction": "extreme close-up of hand",
                     "framing_scale": "close",
                     "lighting_mood": "harsh",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_CLOSE

    def test_korean_close_framing_detected(self) -> None:
        # framing_scale enum SOT: camera_direction text no longer classifies.
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "손가락이 가득 찬 클로즈업"},
            staging={"camera_direction": "손가락이 가득 찬 클로즈업",
                     "framing_scale": "close",
                     "lighting_mood": "어두운",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_CLOSE

    def test_insert_keyword_detected(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "insert shot of clock face"},
            staging={"camera_direction": "insert shot of clock face",
                     "framing_scale": "insert",
                     "lighting_mood": "neutral",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_INSERT

    def test_staging_required_but_missing_raises(self) -> None:
        # R1-I1: silent fallback 금지. shot 자체에 staging 필수인데
        # 누락 → fail-fast (AppError step.contract_violation).
        # camera_direction 도 비어 있으면 staging 필수 case 로 간주.
        with pytest.raises(AppError) as ei:
            build_render_strategy(
                seg={}, shot_info={"camera_direction": ""},
                staging=None, perception_mode=None,
            )
        assert ei.value.code == "step.contract_violation"

    def test_staging_explicit_not_applicable_returns_mode_na(self) -> None:
        # R1-I1: staging 이 의도적 not applicable → mode="not_applicable".
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "",
                               "staging_not_applicable": True},
            staging=None, perception_mode=None,
        )
        assert s["mode"] == "not_applicable"
        assert s["camera_direction"] == ""
        assert s["lighting_mood"] == ""
        # source="fallback" 표현 제거 (R1-I1).
        assert "source" not in s or s.get("source") != "fallback"

    def test_perception_mode_propagated(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "medium shot"},
            staging={"camera_direction": "medium shot",
                     "framing_scale": "medium",
                     "lighting_mood": "warm",
                     "subject_reference_policy": []},
            perception_mode="memory",
        )
        assert s["perception_mode"] == "memory"

    def test_perception_mode_default_direct_when_none(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "framing_scale": "wide",
                     "lighting_mood": "warm",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["perception_mode"] == "direct"

    def test_constraints_present_non_empty(self) -> None:
        # spec §4.1: constraints 항상 존재 + 1+ entries.
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "framing_scale": "wide",
                     "lighting_mood": "warm",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert isinstance(s["constraints"], list)
        assert len(s["constraints"]) >= 1

    def test_moment_lock_present(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "framing_scale": "wide",
                     "lighting_mood": "warm",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert "moment_lock" in s
        assert (
            "single still" in s["moment_lock"].lower()
            or len(s["moment_lock"]) > 0
        )

    def test_primary_subject_from_shot_info(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide",
                               "primary_subject": "the observer at the doorway"},
            staging={"camera_direction": "wide", "framing_scale": "wide",
                     "lighting_mood": "warm",
                     "subject_reference_policy": []},
            perception_mode=None,
        )
        assert s["primary_subject"] == "the observer at the doorway"

    def test_not_applicable_mode_is_valid_enum_member(self) -> None:
        # R2-B2: `not_applicable` 이 _VALID_RENDER_MODES 에 들어가 있어야
        # validator (Task 9) 가 receive 가능.
        assert RENDER_MODE_NOT_APPLICABLE == "not_applicable"
        assert RENDER_MODE_NOT_APPLICABLE in _VALID_RENDER_MODES


# =========================================================================
# Task 4: build_id_policy
# =========================================================================
class TestBuildIdPolicy:
    def test_allowed_entities_propagated(self) -> None:
        p = build_id_policy(
            visible_entities=["C01", "C02", "L01", "P03"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert p["allowed_base_entity_ids"] == ["C01", "C02", "L01", "P03"]

    def test_allowed_outlook_pairs_propagated(self) -> None:
        pairs = [
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ]
        p = build_id_policy(
            visible_entities=["C01", "C02"],
            outlook_pairs=pairs, perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert p["allowed_outlook_pairs"] == pairs

    def test_composite_id_always_true(self) -> None:
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert p["must_use_composite_character_ids"] is True

    def test_common_noun_policy_static(self) -> None:
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        items = p["common_noun_required_when"]
        assert isinstance(items, list)
        # Area #1 (2026-05-16): body-part entry 폐기 — per-subject policy SOT
        # 가 대체. abstract reproduction-surface + extra entries 만 보존.
        assert not any("body-part" in it.lower() for it in items), (
            f"body-part residue in common_noun_required_when: {items!r}"
        )
        # Area C migration (2026-05-12 review fix-up) — noun enumeration
        # 제거. abstract 'declared reproduction surface' 항목 존재 검사.
        assert any(
            "declared reproduction surface" in it.lower() for it in items
        )

    def test_demographic_fallback_true(self) -> None:
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert p["demographic_fallback_required"] is True

    def test_constraints_present(self) -> None:
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert isinstance(p["constraints"], list)
        assert len(p["constraints"]) >= 3

    def test_perception_mode_reflection_adds_constraint(self) -> None:
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[],
            perception_mode="reflection",
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        joined = " ".join(p["constraints"])
        # Area C migration (2026-05-12 review fix-up) — perception_mode 분기
        # constraint 는 'reflective/projected surface' 표현 사용. 옛 noun
        # enumeration (mirror / window reflection 등) 가 사라졌으므로 새
        # 표현 검사.
        assert (
            "reflective/projected surface" in joined.lower()
            or "perception_mode" in joined.lower()
        )

    def test_empty_inputs_still_valid(self) -> None:
        # R2-B4: 명시적 빈 list 만 valid (upstream 의 의도적 empty).
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert p["allowed_base_entity_ids"] == []
        assert p["allowed_outlook_pairs"] == []

    def test_none_visible_entities_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        with pytest.raises(AppError) as ei:
            build_id_policy(
                visible_entities=None,  # type: ignore[arg-type]
                outlook_pairs=[],
                perception_mode=None,
                key_bg_elements=[],
                subject_reference_policies=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "visible_entities" in ei.value.message

    def test_none_outlook_pairs_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        with pytest.raises(AppError) as ei:
            build_id_policy(
                visible_entities=[],
                outlook_pairs=None,  # type: ignore[arg-type]
                perception_mode=None,
                key_bg_elements=[],
                subject_reference_policies=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "outlook_pairs" in ei.value.message


# =========================================================================
# Task 5: build_background_binding
# =========================================================================
class TestBuildBackgroundBinding:
    def test_bg_mode_off_short_circuits(self) -> None:
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "x"}, bg_guide="g",
            is_close_framing=False, background_mode_on=False,
        )
        assert b["mode"] == BG_MODE_OFF
        assert b.get("bg_id") is None
        assert b["owned_objects"] == []
        assert b.get("camera_reference") is None
        assert b["close_framing_skips_background_ref"] is False

    def test_close_framing_skips_owned_and_camera(self) -> None:
        # G3.2 R5-B4: close 는 owned / camera / guide 3 종 skip.
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door", "window"],
            bg_camera_meta={"camera_position": "wide"}, bg_guide="g",
            is_close_framing=True, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_SKIPPED_CLOSE
        assert b["bg_id"] == "cb_main_room"  # 참조용 보존
        assert b["owned_objects"] == []  # G3.2 close skip
        assert b.get("camera_reference") is None
        assert b["close_framing_skips_background_ref"] is True
        # constraints 에 close 모드 reminder 포함.
        joined = " ".join(b["constraints"])
        assert "skipped_close_framing" in joined or "close" in joined.lower()

    def test_non_close_with_bg_includes_owned(self) -> None:
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door", "window", "TV"],
            bg_camera_meta={"camera_position": "southeast doorway",
                            "camera_height": "eye-level standing",
                            "lens_hint": "35mm", "framing_notes": "TV at edge"},
            bg_guide="The room from the southeast doorway angle.",
            is_close_framing=False, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_REF_ATTACHED
        assert b["bg_id"] == "cb_main_room"
        assert b["owned_objects"] == ["door", "window", "TV"]
        assert b["camera_reference"]["camera_position"] == "southeast doorway"
        assert b["close_framing_skips_background_ref"] is False
        assert b["reference_usage"] == "exact_background"

    def test_no_bg_id_returns_not_applicable(self) -> None:
        b = build_background_binding(
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_NOT_APPLICABLE
        assert b.get("bg_id") is None
        assert b["owned_objects"] == []

    def test_owned_list_not_mutated(self) -> None:
        # builder 가 input list 를 in-place 수정하지 않음 (defensive copy).
        owned_in = ["door", "window"]
        build_background_binding(
            bg_id="bg1", bg_owned=owned_in, bg_camera_meta={},
            bg_guide=None, is_close_framing=False, background_mode_on=True,
        )
        assert owned_in == ["door", "window"]  # 변경 0

    def test_constraints_always_present(self) -> None:
        for kwargs in [
            dict(bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
                 is_close_framing=False, background_mode_on=False),
            dict(bg_id="bg1", bg_owned=["x"],
                 bg_camera_meta={"camera_position": "p"},
                 bg_guide="g", is_close_framing=False, background_mode_on=True),
            dict(bg_id="bg1", bg_owned=["x"],
                 bg_camera_meta={"camera_position": "p"},
                 bg_guide="g", is_close_framing=True, background_mode_on=True),
        ]:
            b = build_background_binding(**kwargs)
            assert isinstance(b["constraints"], list)
            assert len(b["constraints"]) >= 1

    def test_owned_objects_no_mention_constraint_when_owned_present(self) -> None:
        # G3.2 핵심 reminder: owned 가 있으면 "do not create new" constraint.
        b = build_background_binding(
            bg_id="bg1", bg_owned=["door", "TV"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="g", is_close_framing=False, background_mode_on=True,
        )
        joined = " ".join(b["constraints"])
        assert "owned" in joined.lower() or "do not create" in joined.lower()

    def test_none_bg_owned_raises(self) -> None:
        # Wave 4 R4 I2 (R1-I12): bg_owned=None 은 producer missing →
        # AppError fail-fast. 명시적 [] (no owned objects) 만 valid.
        with pytest.raises(AppError) as ei:
            build_background_binding(
                bg_id="bg1", bg_owned=None,  # type: ignore[arg-type]
                bg_camera_meta={"camera_position": "wide"},
                bg_guide="g", is_close_framing=False, background_mode_on=True,
            )
        assert ei.value.code == "step.contract_violation"
        assert "bg_owned" in ei.value.message

    def test_none_bg_owned_raises_even_when_bg_off(self) -> None:
        # bg-off path 도 contract 일관 — None 은 producer missing 신호.
        with pytest.raises(AppError) as ei:
            build_background_binding(
                bg_id=None, bg_owned=None,  # type: ignore[arg-type]
                bg_camera_meta=None, bg_guide=None,
                is_close_framing=False, background_mode_on=False,
            )
        assert ei.value.code == "step.contract_violation"
        assert "bg_owned" in ei.value.message


# =========================================================================
# Task 6: build_continuity_elements_used
# =========================================================================
class TestBuildContinuityElementsUsed:
    def test_full_continuity(self) -> None:
        fe = [{
            "element_id": "body_full_pose", "element_type": "character_state",
            "bound_entity_hint": "C02",
            "description": "an East Asian woman lying motionless on her left side",
            "source_facts": [], "visual_inferences": [], "confidence": "high",
        }]
        psr = [{"scene_index": 12, "shot_index": 3,
                "ref_usage": "zoom_in_detail", "keep_elements": []}]
        fzt = [{"scene_index": 12, "shot_index": 5,
                "description": "follow-up close-up target must be visible",
                "keep_elements": []}]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=psr,
            forward_zoom_targets=fzt,
        )
        assert c["fixed_elements"] == fe
        assert c["previous_shot_refs"] == psr
        assert c["forward_zoom_targets"] == fzt

    def test_all_empty_is_valid(self) -> None:
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["fixed_elements"] == []
        assert c["previous_shot_refs"] == []
        assert c["forward_zoom_targets"] == []

    def test_constraints_present(self) -> None:
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert isinstance(c["constraints"], list)
        assert len(c["constraints"]) >= 2

    def test_input_lists_not_mutated(self) -> None:
        fe_in = [{"element_id": "x"}]
        psr_in = [{"scene_index": 1, "shot_index": 1}]
        fzt_in = [{"scene_index": 1, "shot_index": 2}]
        build_continuity_elements_used(
            fixed_elements=fe_in, previous_shot_refs=psr_in,
            forward_zoom_targets=fzt_in,
        )
        assert fe_in == [{"element_id": "x"}]
        assert psr_in == [{"scene_index": 1, "shot_index": 1}]
        assert fzt_in == [{"scene_index": 1, "shot_index": 2}]

    def test_full_text_preserved_no_truncation(self) -> None:
        # CLAUDE.md 절대 규칙: LLM-bound data 무절단.
        long_desc = "A " * 300 + "long description"
        fe = [{
            "element_id": "x", "element_type": "scene_state",
            "character_name": None,
            "description": long_desc,
            "applies_to_shots": [],
            "source_facts": [], "visual_inferences": [],
            "creative_decisions": [], "confidence": "high",
        }]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["fixed_elements"][0]["description"] == long_desc

    def test_lossless_producer_fields_preserved(self) -> None:
        # R1-I2 + Area #4: scene_consistency producer 의 10 required field 모두 보존.
        fe = [{
            "element_id": "body_full_pose",
            "element_type": "character_state",
            "character_name": "C02",
            "description": "an East Asian woman lying motionless on her left side",
            "applies_to_shots": [{"scene_index": 12, "shot_index": 3},
                                 {"scene_index": 12, "shot_index": 4}],
            "element_scope": "full",
            "source_facts": ["scene narration: 'she lay still'"],
            "visual_inferences": ["lighting falls from window onto left shoulder"],
            "creative_decisions": ["camera respects 180-degree line"],
            "confidence": "high",
        }]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=[], forward_zoom_targets=[],
        )
        out = c["fixed_elements"][0]
        for k in ("element_id", "element_type", "character_name",
                  "description", "applies_to_shots", "element_scope",
                  "source_facts", "visual_inferences", "creative_decisions", "confidence"):
            assert out[k] == fe[0][k], f"field {k!r} dropped/transformed"

    def test_forward_zoom_full_list_no_cap(self) -> None:
        # R1-I7: forward_zoom_targets count > 6 + keep_elements count > 5
        # → card payload 모든 entry 보존 (cap 0).
        fzt = [
            {
                "scene_index": 12, "shot_index": 5 + i,
                "description": f"forward zoom target #{i}",
                "keep_elements": [{"label": f"element_{j}", "kind": "environment"} for j in range(7)],  # 7 > 5
            }
            for i in range(8)  # 8 > 6
        ]
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=fzt,
        )
        # 모든 entry 보존.
        assert len(c["forward_zoom_targets"]) == 8
        for entry in c["forward_zoom_targets"]:
            assert len(entry["keep_elements"]) == 7

    def test_none_fixed_elements_raises(self) -> None:
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=None,  # type: ignore[arg-type]
                previous_shot_refs=[], forward_zoom_targets=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "fixed_elements" in ei.value.message

    def test_none_previous_shot_refs_raises(self) -> None:
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=[],
                previous_shot_refs=None,  # type: ignore[arg-type]
                forward_zoom_targets=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "previous_shot_refs" in ei.value.message

    def test_none_forward_zoom_targets_raises(self) -> None:
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=[], previous_shot_refs=[],
                forward_zoom_targets=None,  # type: ignore[arg-type]
            )
        assert ei.value.code == "step.contract_violation"
        assert "forward_zoom_targets" in ei.value.message


# =========================================================================
# Task 7: build_asset_requirements
# =========================================================================
class TestBuildAssetRequirements:
    def test_full_assets(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01", "C02", "L01", "P03"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O02"},
                {"character_id": "C02", "outlook_id": "O01"},
            ],
            bg_id="cb_main_room", is_close_framing=False,
            background_mode_on=True,
            render_contracts=[],
            policy_map={},
        )
        # 2 outlook + 1 bg = 3 required.
        kinds = [r["kind"] for r in a["required_refs"]]
        assert kinds.count("character_outlook") == 2
        assert kinds.count("background") == 1
        # forbidden 없음.
        assert a["forbidden_refs"] == []
        assert a["readiness_policy"] == READINESS_BLOCK

    def test_close_framing_forbids_background(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id="cb_main_room", is_close_framing=True,
            background_mode_on=True,
            render_contracts=[],
            policy_map={},
        )
        # close → bg 는 forbidden, character_outlook 는 required.
        kinds_req = [r["kind"] for r in a["required_refs"]]
        assert "background" not in kinds_req
        assert "character_outlook" in kinds_req
        assert any(f["kind"] == "background" for f in a["forbidden_refs"])
        assert a["readiness_policy"] == READINESS_SKIPPED

    def test_bg_mode_off(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            render_contracts=[],
            policy_map={},
        )
        kinds_req = [r["kind"] for r in a["required_refs"]]
        assert "background" not in kinds_req
        assert a["readiness_policy"] == READINESS_NA

    def test_composite_id_format(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            render_contracts=[],
            policy_map={},
        )
        # required_refs[0].id = "C01O02".
        outlook_refs = [r for r in a["required_refs"]
                        if r["kind"] == "character_outlook"]
        assert outlook_refs[0]["id"] == "C01O02"

    def test_no_outlook_pair_no_outlook_ref(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"], outlook_pairs=[], bg_id=None,
            is_close_framing=False, background_mode_on=False,
            render_contracts=[],
            policy_map={},
        )
        assert a["required_refs"] == []
        assert a["readiness_policy"] == READINESS_NA

    def test_constraints_present(self) -> None:
        a = build_asset_requirements(
            visible_entities=[], outlook_pairs=[], bg_id=None,
            is_close_framing=False, background_mode_on=False,
            render_contracts=[],
            policy_map={},
        )
        assert isinstance(a["constraints"], list)
        assert len(a["constraints"]) >= 1

    def test_none_visible_entities_raises(self) -> None:
        with pytest.raises(AppError) as ei:
            build_asset_requirements(
                visible_entities=None,  # type: ignore[arg-type]
                outlook_pairs=[], bg_id=None,
                is_close_framing=False, background_mode_on=False,
                render_contracts=[],
                policy_map={},
            )
        assert ei.value.code == "step.contract_violation"
        assert "visible_entities" in ei.value.message

    def test_none_outlook_pairs_raises(self) -> None:
        with pytest.raises(AppError) as ei:
            build_asset_requirements(
                visible_entities=[],
                outlook_pairs=None,  # type: ignore[arg-type]
                bg_id=None,
                is_close_framing=False, background_mode_on=False,
                render_contracts=[],
                policy_map={},
            )
        assert ei.value.code == "step.contract_violation"
        assert "outlook_pairs" in ei.value.message

    # ─────────────────────────────────────────────────────────────────
    # 2026-05-10 24-shot deterministic ref-contract fail fix — Fix B
    # build_asset_requirements 가 used_outlook_pairs / state_variant_chars 인자로
    # required_refs.character_outlook 을 narrow 하는지 검증.
    # ─────────────────────────────────────────────────────────────────

    def test_used_outlook_pairs_narrows_character_outlook_required(self) -> None:
        """outlook_pairs 가 [(C01,O01),(C01,O02)] union 후보지만, used_outlook_pairs 가
        {(C01,O02)} 만 표시 → required_refs 에 C01O02 만 들어가야 한다.
        (24-shot deterministic fail 의 root cause — RPC 가 후보 union 으로 over-require.)
        """
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O01"},
                {"character_id": "C01", "outlook_id": "O02"},
            ],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            used_outlook_pairs={("C01", "O02")},
            state_variant_chars=set(),
            render_contracts=[],
            policy_map={},
        )
        outlook_ids = sorted(
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        )
        assert outlook_ids == ["C01O02"], (
            f"narrow 되어야 한다. 받은 ids={outlook_ids}"
        )

    def test_used_outlook_pairs_none_preserves_legacy_union(self) -> None:
        """used_outlook_pairs=None (기본값) 이면 기존 동작 유지 — 모든 outlook_pairs 를
        required 로 박는 legacy contract 보존 (pre-narrow scene_detail / verify
        recompute path 의 trans-period backward 호환)."""
        a = build_asset_requirements(
            visible_entities=["C01", "C02"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O01"},
                {"character_id": "C02", "outlook_id": "O03"},
            ],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            render_contracts=[],
            policy_map={},
        )
        outlook_ids = sorted(
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        )
        assert outlook_ids == ["C01O01", "C02O03"]

    def test_state_variant_chars_excludes_character_outlook_required(self) -> None:
        """state_variant_chars 안 character 의 outlook 은 character_outlook required
        에서 제외 — resolver 가 character_state ref 로 attach 하므로 character_outlook
        ref 가 missing 이어도 정합 (S12_Shot6 dead-character 결함 fix)."""
        a = build_asset_requirements(
            visible_entities=["C05"],
            outlook_pairs=[{"character_id": "C05", "outlook_id": "O09"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            used_outlook_pairs={("C05", "O09")},
            state_variant_chars={"C05"},
            render_contracts=[],
            policy_map={},
        )
        outlook_ids = [
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        ]
        assert outlook_ids == [], (
            f"state_variant character 는 character_outlook required 에서 제외되어야 "
            f"한다. 받은 ids={outlook_ids}"
        )

    def test_state_variant_only_yields_readiness_na(self) -> None:
        """state_variant character 단독 + bg-off → required 비어 readiness=not_applicable."""
        a = build_asset_requirements(
            visible_entities=["C05"],
            outlook_pairs=[{"character_id": "C05", "outlook_id": "O09"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            used_outlook_pairs={("C05", "O09")},
            state_variant_chars={"C05"},
            render_contracts=[],
            policy_map={},
        )
        assert a["required_refs"] == []
        assert a["readiness_policy"] == READINESS_NA

    def test_null_outlook_o00_excluded_from_character_outlook_required(self) -> None:
        """Fix D1 (2026-05-10): C##O00 (Null Outlook) 은 resolver 가 character base
        ref 로 attach (별 kind: 'character'). validator 의 character_outlook
        strict check 가 base 만족 X → producer 에서 O00 outlook 을 required 에서
        제외. S7_Shot1 / S7_Shot3 / S17_Shot1 deterministic 실패 fix.
        """
        a = build_asset_requirements(
            visible_entities=["C13"],
            outlook_pairs=[{"character_id": "C13", "outlook_id": "O00"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            used_outlook_pairs={("C13", "O00")},
            state_variant_chars=set(),
            render_contracts=[],
            policy_map={},
        )
        outlook_ids = [
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        ]
        assert outlook_ids == [], (
            f"O00 outlook 은 character_outlook required 에서 제외되어야 한다. "
            f"받은 ids={outlook_ids}"
        )

    def test_null_outlook_mixed_with_normal_outlook_only_o00_excluded(self) -> None:
        """C13O00 + C01O02 mixed → C01O02 만 required 에 박힘 (O00 만 skip)."""
        a = build_asset_requirements(
            visible_entities=["C01", "C13"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O02"},
                {"character_id": "C13", "outlook_id": "O00"},
            ],
            bg_id=None, is_close_framing=False, background_mode_on=False,
            used_outlook_pairs={("C01", "O02"), ("C13", "O00")},
            state_variant_chars=set(),
            render_contracts=[],
            policy_map={},
        )
        outlook_ids = sorted(
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        )
        assert outlook_ids == ["C01O02"], (
            f"O00 만 제외, 정상 outlook 은 보존되어야 한다. 받은 ids={outlook_ids}"
        )

    def test_used_outlook_pairs_does_not_affect_background_required(self) -> None:
        """narrow 가 character_outlook 만 영향. background required (bg_id) 는 그대로 유지."""
        a = build_asset_requirements(
            visible_entities=["C01", "L01"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O01"},
                {"character_id": "C01", "outlook_id": "O02"},
            ],
            bg_id="L01B01",
            is_close_framing=False,
            background_mode_on=True,
            used_outlook_pairs={("C01", "O02")},
            state_variant_chars=set(),
            render_contracts=[],
            policy_map={},
        )
        bg_refs = [r for r in a["required_refs"] if r["kind"] == "background"]
        assert len(bg_refs) == 1 and bg_refs[0]["id"] == "L01B01"
        outlook_ids = [
            r["id"] for r in a["required_refs"] if r["kind"] == "character_outlook"
        ]
        assert outlook_ids == ["C01O02"]


# =========================================================================
# Task 9: assert_card_shape (R2-B2 / R2-I1)
# =========================================================================
def _valid_card():
    c = build_empty_card(scene_index=1, shot_index=1)
    c["render_strategy"] = build_render_strategy(
        seg={}, shot_info={"camera_direction": "wide"},
        staging={"camera_direction": "wide", "framing_scale": "wide",
                 "lighting_mood": "warm",
                 "subject_reference_policy": []},
        perception_mode=None,
    )
    c["id_policy"] = build_id_policy(
        visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    c["background_binding"] = build_background_binding(
        bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=False, background_mode_on=False,
    )
    c["continuity_elements_used"] = build_continuity_elements_used(
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
    )
    c["asset_requirements"] = build_asset_requirements(
        visible_entities=["C01"], outlook_pairs=[], bg_id=None,
        is_close_framing=False, background_mode_on=False,
        render_contracts=[],
        policy_map={},
    )
    # Area B (2026-05-13) — render_contracts 6-field envelope invariant
    # (assert_card_shape 의 _REQUIRED_TOP_FIELDS 에 포함). 빈 list 가 valid.
    c["render_contracts"] = []
    return c


class TestAssertCardShape:
    def test_valid_passes(self) -> None:
        assert_card_shape(_valid_card())  # noop

    def test_missing_schema_version_raises(self) -> None:
        c = _valid_card()
        del c["schema_version"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert ei.value.code == "step.contract_violation"

    def test_wrong_schema_version_raises(self) -> None:
        c = _valid_card()
        c["schema_version"] = 99
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_missing_shot_key_raises(self) -> None:
        c = _valid_card()
        del c["shot_key"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "shot_key" in ei.value.message

    def test_shot_key_missing_scene_index_raises(self) -> None:
        c = _valid_card()
        c["shot_key"] = {"shot_index": 1}
        with pytest.raises(AppError):
            assert_card_shape(c)

    def test_missing_field_raises(self) -> None:
        for field in (
            "render_strategy", "id_policy", "background_binding",
            "continuity_elements_used", "asset_requirements",
        ):
            c = _valid_card()
            del c[field]
            with pytest.raises(AppError) as ei:
                assert_card_shape(c)
            assert field in ei.value.message

    def test_field_must_be_dict(self) -> None:
        c = _valid_card()
        c["render_strategy"] = "not a dict"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_strategy" in ei.value.message

    def test_render_strategy_invalid_mode_raises(self) -> None:
        c = _valid_card()
        c["render_strategy"]["mode"] = "totally_invented_mode"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "mode" in ei.value.message

    def test_background_binding_invalid_mode_raises(self) -> None:
        c = _valid_card()
        c["background_binding"]["mode"] = "totally_invented_mode"
        with pytest.raises(AppError):
            assert_card_shape(c)

    def test_asset_requirements_invalid_readiness_raises(self) -> None:
        c = _valid_card()
        c["asset_requirements"]["readiness_policy"] = "totally_invented"
        with pytest.raises(AppError):
            assert_card_shape(c)

    def test_input_not_dict_raises(self) -> None:
        with pytest.raises(AppError):
            assert_card_shape("not a dict")  # type: ignore[arg-type]

    def test_where_in_message(self) -> None:
        c = _valid_card()
        del c["render_strategy"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c, where="my_call_site")
        assert "my_call_site" in ei.value.message

    def test_not_applicable_mode_passes(self) -> None:
        # R2-B2: validator 가 `mode="not_applicable"` valid 로 수용.
        c = _valid_card()
        c["render_strategy"]["mode"] = "not_applicable"
        assert_card_shape(c)  # noop — valid

    def test_schema_version_bool_rejects(self) -> None:
        # R2-I1: bool-as-int reject (Python `True == 1` quirk).
        c = _valid_card()
        c["schema_version"] = True  # type: ignore[assignment]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_schema_version_non_int_rejects(self) -> None:
        # R2-I1: 정수가 아니면 reject.
        c = _valid_card()
        c["schema_version"] = "1"  # type: ignore[assignment]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_envelope_hash_format_strict(self) -> None:
        # R2-I1: render_prompt_card_hash invalid format 은 reject.
        c = _valid_card()
        c["render_prompt_card_hash"] = "NOT_HEX_VALUE!!!"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_prompt_card_hash" in ei.value.message

    def test_envelope_hash_uppercase_rejects(self) -> None:
        # R2-I1: lowercase hex 만. uppercase reject (G3.2 iter2 fix carry).
        c = _valid_card()
        c["render_prompt_card_hash"] = "DEADBEEFDEADBEEF"  # uppercase
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_prompt_card_hash" in ei.value.message

    def test_envelope_hash_valid_passes(self) -> None:
        # R2-I1: 정확히 16-char lowercase hex 면 valid.
        c = _valid_card()
        c["render_prompt_card_hash"] = "deadbeef" * 2
        assert_card_shape(c)  # noop — valid

    def test_envelope_hash_absent_passes(self) -> None:
        # R2-I1: hash 부재 (early build 단계) 는 skip — valid.
        c = _valid_card()
        c.pop("render_prompt_card_hash", None)
        assert_card_shape(c)  # noop — valid

    def test_assert_card_shape_render_contracts_required(self) -> None:
        """Area B (2026-05-13) I-1 review fix — _REQUIRED_TOP_FIELDS 에
        render_contracts 가 포함되면 누락 시 assert_card_shape 가 raise.
        defense-in-depth (Area C lesson §6 — production shape deny-list).
        """
        c = _valid_card()
        del c["render_contracts"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_contracts" in ei.value.message

    def test_assert_card_shape_render_contracts_not_list_rejects(self) -> None:
        """Area B I-1 — render_contracts 가 list 아니면 raise."""
        c = _valid_card()
        c["render_contracts"] = {"contract_id": "rc_001"}  # dict — 잘못된 envelope
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_contracts" in ei.value.message
        assert "must be list" in ei.value.message

    def test_assert_card_shape_render_contracts_item_not_dict_rejects(self) -> None:
        """Area B I-1 — render_contracts[i] 가 dict 아니면 raise."""
        c = _valid_card()
        c["render_contracts"] = ["not_a_dict"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_contracts[0]" in ei.value.message
        assert "must be dict" in ei.value.message

    def test_assert_card_shape_render_contracts_empty_list_passes(self) -> None:
        """Area B I-1 — render_contracts 빈 list 는 valid (envelope-only check)."""
        c = _valid_card()
        c["render_contracts"] = []
        assert_card_shape(c)  # noop


# =========================================================================
# Task 9.5: build_render_prompt_card entry point
# =========================================================================
class TestBuildRenderPromptCard:
    def test_full_round_trip(self) -> None:
        c = build_render_prompt_card(
            scene_index=12, shot_index=4,
            seg={}, shot_info={"camera_direction": "medium shot"},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
            staging={"camera_direction": "medium shot",
                     "framing_scale": "medium",
                     "lighting_mood": "warm dim",
                     "key_bg_elements": [],
                     "subject_reference_policy": []},
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="guide text",
            is_close_framing=False, background_mode_on=True,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        # shape valid (assert 통과 = exception 없음)
        assert c["schema_version"] == 1
        assert c["shot_key"] == {"scene_index": 12, "shot_index": 4}
        assert c["render_strategy"]["framing_scale"] == "medium"
        assert c["id_policy"]["allowed_base_entity_ids"] == ["C01"]
        assert c["background_binding"]["bg_id"] == "cb_main_room"
        assert c["asset_requirements"]["readiness_policy"] == "block_if_missing"

    def test_close_framing_propagates_to_all_fields(self) -> None:
        c = build_render_prompt_card(
            scene_index=1, shot_index=1,
            seg={}, shot_info={"camera_direction": "extreme close-up"},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
            staging={"camera_direction": "extreme close-up",
                     "framing_scale": "close",
                     "lighting_mood": "harsh",
                     "key_bg_elements": [],
                     "subject_reference_policy": []},
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="g",
            is_close_framing=True, background_mode_on=True,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["render_strategy"]["framing_scale"] == "close"
        assert c["background_binding"]["mode"] == "skipped_close_framing"
        assert c["background_binding"]["owned_objects"] == []
        assert c["asset_requirements"]["readiness_policy"] == "skipped_by_policy"

    def test_bg_mode_off_propagates(self) -> None:
        c = build_render_prompt_card(
            scene_index=1, shot_index=1,
            seg={}, shot_info={"camera_direction": "wide"},
            visible_entities=["C01"], outlook_pairs=[],
            perception_mode=None,
            staging={"camera_direction": "wide", "framing_scale": "wide",
                     "lighting_mood": "warm",
                     "key_bg_elements": [],
                     "subject_reference_policy": []},
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["background_binding"]["mode"] == "background_mode_off"
        assert c["asset_requirements"]["readiness_policy"] == "not_applicable"


# =========================================================================
# Patch A Task 3: build_id_policy — common_noun_required_when split
# synthetic fixtures only
# =========================================================================
class TestIdPolicyPhotoDepictionSplit:
    def test_common_noun_rule_face_only(self):
        """common_noun_required_when 의 photo depiction 항목이 face-only 로 좁혀짐.

        Area C migration (2026-05-12 review fix-up): noun 6 enumeration
        ('photo/poster/screen/mirror/reflection/projection') 도 제거되고
        abstract 'declared reproduction surface' 로 치환됨."""
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        cnr = ip["common_noun_required_when"]
        # 기존 통합 항목은 사라짐
        assert "photo/poster/screen/mirror/reflection/projection depiction" not in cnr
        # Area C migration — 6-noun slash enumeration 도 사라짐
        assert (
            "a reproduced face inside a photo/poster/screen/mirror/reflection/projection"
            not in cnr
        )
        # 신규 abstract 항목 — face-only intent 보존 ('reproduced face inside').
        assert any(
            "reproduced face inside" in s and "declared reproduction surface" in s
            for s in cnr
        )

    def test_id_use_required_when_for_physical_prop(self):
        """신규 id_use_required_when 항목에 physical prop 보존 rule 명시."""
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        assert "id_use_required_when" in ip
        iur = ip["id_use_required_when"]
        assert any("physical photo" in s and "P##" in s for s in iur)

    def test_constraints_item_one_split_note(self):
        """constraints[#1] 본문에 'NOTE: physical photo prop itself MUST use P## ID' 추가."""
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
            key_bg_elements=[],
            subject_reference_policies=[],
        )
        c1 = ip["constraints"][1]
        assert "NOTE" in c1 and "physical" in c1 and "P##" in c1


# =========================================================================
# Area C — build_id_policy reproduction_surface_rule migration tests
# (2026-05-12 — directionality_class SOT 기반, noun list 제거)
# =========================================================================


def _area_c_make_element(dc: str) -> dict:
    """Test fixture — minimal valid key_bg_elements entry with directionality_class."""
    return {
        "element": f"test-{dc}",
        "state": "neutral",
        "orientation": "front" if dc in {"content_surface", "reflective_surface"} else "",
        "camera_use": "ambient",
        "directionality_class": dc,
    }


def test_area_c_reproduction_applies_false_when_no_content_or_reflective():
    """key_bg_elements 가 transparent / directional_3d / non_directional 만 → applies=False."""
    policy = build_id_policy(
        visible_entities=[],
        outlook_pairs=[],
        perception_mode=None,
        key_bg_elements=[
            _area_c_make_element("transparent_surface"),
            _area_c_make_element("directional_3d"),
            _area_c_make_element("non_directional"),
        ],
        subject_reference_policies=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is False


def test_area_c_reproduction_applies_true_with_content_surface():
    """key_bg_elements 안 content_surface → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_area_c_make_element("content_surface")],
        subject_reference_policies=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_area_c_reproduction_applies_true_with_reflective_surface():
    """key_bg_elements 안 reflective_surface → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_area_c_make_element("reflective_surface")],
        subject_reference_policies=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_area_c_reproduction_applies_true_with_mixed():
    """content_surface + non_directional 혼합 → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[
            _area_c_make_element("non_directional"),
            _area_c_make_element("content_surface"),
        ],
        subject_reference_policies=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_area_c_reproduction_applies_false_with_empty_key_bg_elements():
    """key_bg_elements=[] → applies=False."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is False


def test_area_c_reproduction_directionality_class_missing_raises():
    """element 안 directionality_class 부재 → AppError(missing)."""
    bad_element = {"element": "test", "state": "", "orientation": "", "camera_use": ""}
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
            subject_reference_policies=[],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_missing"


def test_area_c_reproduction_directionality_class_empty_string_raises():
    """element 안 directionality_class='' → AppError(missing)."""
    bad_element = _area_c_make_element("content_surface")
    bad_element["directionality_class"] = ""
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
            subject_reference_policies=[],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_missing"


def test_area_c_reproduction_directionality_class_invalid_enum_raises():
    """element 안 directionality_class='unknown' → AppError(invalid)."""
    bad_element = _area_c_make_element("content_surface")
    bad_element["directionality_class"] = "unknown"
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
            subject_reference_policies=[],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_invalid"


def test_area_c_reproduction_surface_rule_shape():
    """RPC schema = {applies: bool, id_use: str, rationale_summary: str}.

    deny-list: applies_to_surfaces / source 부재."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_area_c_make_element("non_directional")],
        subject_reference_policies=[],
    )
    rule = policy["reproduction_surface_rule"]
    assert "applies" in rule and isinstance(rule["applies"], bool)
    assert "id_use" in rule and isinstance(rule["id_use"], str)
    assert "rationale_summary" in rule and isinstance(rule["rationale_summary"], str)
    assert "applies_to_surfaces" not in rule
    assert "source" not in rule


# ──────────────────────────────────────────────────────────────────────────
# Area C review fix-up (2026-05-12) — constraint string deny/positive pins
# ──────────────────────────────────────────────────────────────────────────


def test_area_c_constraints_do_not_emit_noun_list():
    """Critical regression pin — id_policy.constraints must NOT contain
    reproduction_surface noun enumeration (Area C migration 2026-05-12).

    The producer's constraint string should reference applies bool, not list
    9 nouns inline.

    Note: the Patch A NOTE clause inside constraint[1] legitimately mentions
    'physical photo/poster/document/picture-frame/map/key prop' (preserving
    P## ID for physical prop entities — distinct scope from face
    reproduction). The deny list below targets ONLY tokens unique to the
    pre-Area-C reproduction-surface enumeration:
      - comma-separated forms (`photograph, poster` 등)
      - `TV, mirror` / `window reflection` / `mirror/reflection/projection`
        (2-word + slash forms unique to the removed 6-noun
        `common_noun_required_when` entry)
      - `poster/screen` (the OLD slash enum had `screen`, the Patch A NOTE
        does not).
    """
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    constraints_text = " ".join(policy.get("constraints", []))
    forbidden_substrings = [
        "photograph, poster",
        "poster, painting",
        "painting, portrait",
        "TV, mirror",
        "window reflection",  # 2-word noun unique to old list
        "poster/screen",  # `screen` is unique to OLD enum (not in Patch A NOTE)
        "mirror/reflection/projection",
    ]
    for snippet in forbidden_substrings:
        assert snippet not in constraints_text, (
            f"Area C noun-list regression — {snippet!r} found in constraints. "
            f"Producer must reference reproduction_surface_rule.applies, not "
            f"noun enumeration."
        )
    # Also deny the OLD `common_noun_required_when` 6-noun entry phrasing.
    cnr_joined = " | ".join(policy.get("common_noun_required_when", []))
    assert "photo/poster/screen/mirror/reflection/projection" not in cnr_joined, (
        "Area C regression — 6-noun slash enumeration re-emerged in "
        f"common_noun_required_when: {cnr_joined!r}"
    )


def test_area_c_constraints_reference_applies_field():
    """Sanity — the new constraint string mentions the applies SOT field."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    constraints_text = " ".join(policy.get("constraints", []))
    assert "reproduction_surface_rule.applies" in constraints_text, (
        f"Producer constraints must reference applies field. "
        f"Got: {constraints_text[:300]}..."
    )


def test_area_c_common_noun_required_when_no_noun_enumeration():
    """common_noun_required_when 배열 — Area C migration 후 noun
    enumeration 제거되고 abstract 'declared reproduction surface' 로 치환."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    arr = policy.get("common_noun_required_when", [])
    joined = " | ".join(arr)
    # 새 abstract 표현 존재.
    assert "declared reproduction surface" in joined, (
        f"common_noun_required_when must reference 'declared reproduction "
        f"surface' abstract phrase — got {arr!r}"
    )
    # 옛 6-noun slash enumeration 부재.
    for forbidden in (
        "photo/poster", "poster/screen", "screen/mirror",
        "mirror/reflection", "reflection/projection",
    ):
        assert forbidden not in joined, (
            f"common_noun_required_when regression — {forbidden!r} found"
        )


def test_area_c_build_id_policy_non_dict_key_bg_element_raises():
    """Minor 2 (review fix-up) — non-dict key_bg_elements item must raise
    typed AppError instead of AttributeError on `.get`."""
    for bad_elem in ["just a string", 42, ["nested-list"], True]:
        with pytest.raises(AppError) as exc_info:
            build_id_policy(
                visible_entities=[], outlook_pairs=[], perception_mode=None,
                key_bg_elements=[bad_elem],
                subject_reference_policies=[],
            )
        assert exc_info.value.code == "render_prompt_card.key_bg_element_invalid", (
            f"non-dict elem={bad_elem!r} ({type(bad_elem).__name__}) must "
            f"raise key_bg_element_invalid. Got: {exc_info.value.code}"
        )


# =========================================================================
# Area B (2026-05-13) Task 5: detail_steps visible_entity_details metadata_json
# wiring (C5).
#
# `_derive_card_inputs_from_ctx` (detail_steps.py:432) 안의 `_patch_a_entity_by_sid`
# build site 가 ctx.entities[<etype>][i].metadata_json (JSON string) 를
# visible_entity_details[i].metadata_json (dict) 로 변환하는지 검증.
#
# fixture 패턴은 tests/core/test_d6_scene_detail_chain_unchanged.py 의
# `_make_fake_ctx` 와 동일 — SimpleNamespace 로 minimal ctx 합법화 + chain_bg
# attribute null-safe shape.
# =========================================================================
class TestAreaBVisibleEntityDetailsMetadataJson:
    def _make_ctx_with_prop_entity(self, *, sid: str, name: str,
                                   metadata_json_str: str):
        """detail_steps._derive_card_inputs_from_ctx 가 읽는 모든 ctx attribute
        의 minimal 합법 fake — visible (1, 1) = [<sid>] + ctx.entities.props
        에 metadata_json JSON string 포함.

        `chain_bg_owned_by_shot` 명시 [] (None 이면 builder 가 contract violation
        raise — `detail_steps.py:530-545`).
        """
        from types import SimpleNamespace
        return SimpleNamespace(
            shot_director_ve={(1, 1): [sid]},
            scene_visible={1: [sid]},
            outlook_data=None,
            staging_map={"1_1": {
                "camera_direction": "medium shot",
                "framing_scale": "medium",
                "lighting_mood": "warm",
                "key_bg_elements": [],
            }},
            chain_bg_id_by_shot={(1, 1): "L01B01"},
            chain_bg_owned_by_shot={(1, 1): []},
            chain_bg_camera_meta_by_shot={},
            chain_bg_guide_by_shot={},
            fixed_elements_by_scene={},
            dependencies=[],
            shot_scenes_map={},
            entities={
                "characters": [],
                "locations": [],
                "props": [{
                    "short_id": sid,
                    "name": name,
                    "t2i_prompt": "...prop t2i prompt...",
                    "metadata_json": metadata_json_str,
                }],
            },
        )

    def test_visible_entity_details_carries_metadata_json_for_prop(
        self, monkeypatch
    ):
        """Area B: ctx.entities[props][i].metadata_json (JSON string) →
        visible_entity_details[i].metadata_json (dict).

        consumer (build_render_contracts via entity_metadata helper) 가 직접
        dict access — JSON string round-trip 차단.
        """
        from app.core.steps.detail_steps import _derive_card_inputs_from_ctx
        import json as _json

        monkeypatch.setattr("app.core.config.settings.background_mode", "on")

        md_payload = {
            "location": None,
            "visual_identity": {"reference_required": True},
        }
        ctx = self._make_ctx_with_prop_entity(
            sid="P01",
            name="old photo",
            metadata_json_str=_json.dumps(md_payload),
        )
        seg = {"index": 1, "scene_index": 1, "text": "scene segment text"}
        shot_info = {
            "shot_index": 1,
            "camera_direction": "medium shot",
            "primary_subject": "the subject",
        }

        inputs = _derive_card_inputs_from_ctx(
            ctx=ctx, seg=seg, shot_info=shot_info,
        )
        ved = inputs.get("visible_entity_details") or []
        p01_entry = next((e for e in ved if e.get("short_id") == "P01"), None)
        assert p01_entry is not None, (
            f"P01 entry missing in visible_entity_details — got {ved!r}"
        )
        assert p01_entry.get("metadata_json") == md_payload, (
            f"metadata_json JSON string → dict 변환 실패 — got "
            f"{p01_entry.get('metadata_json')!r}"
        )

    def test_visible_entity_details_metadata_json_invalid_string_falls_back_empty(
        self, monkeypatch
    ):
        """JSON parse 실패 → 빈 dict fallback (consumer helper 가 fail-fast
        책임 — Boundary 2 contract)."""
        from app.core.steps.detail_steps import _derive_card_inputs_from_ctx

        monkeypatch.setattr("app.core.config.settings.background_mode", "on")

        ctx = self._make_ctx_with_prop_entity(
            sid="P02",
            name="malformed prop",
            metadata_json_str="not-json{",
        )
        seg = {"index": 1, "scene_index": 1, "text": "scene segment text"}
        shot_info = {
            "shot_index": 1,
            "camera_direction": "medium shot",
            "primary_subject": "the subject",
        }

        inputs = _derive_card_inputs_from_ctx(
            ctx=ctx, seg=seg, shot_info=shot_info,
        )
        ved = inputs.get("visible_entity_details") or []
        p02_entry = next((e for e in ved if e.get("short_id") == "P02"), None)
        assert p02_entry is not None
        assert p02_entry.get("metadata_json") == {}, (
            f"malformed JSON 문자열은 빈 dict fallback 이어야 함 — got "
            f"{p02_entry.get('metadata_json')!r}"
        )

    def test_visible_entity_details_metadata_json_dict_passthrough(
        self, monkeypatch
    ):
        """이미 dict 인 경우 그대로 통과 — JSON string round-trip 회피."""
        from app.core.steps.detail_steps import _derive_card_inputs_from_ctx
        from types import SimpleNamespace

        monkeypatch.setattr("app.core.config.settings.background_mode", "on")

        md_payload = {"visual_identity": {"reference_required": False}}
        ctx = SimpleNamespace(
            shot_director_ve={(1, 1): ["P03"]},
            scene_visible={1: ["P03"]},
            outlook_data=None,
            staging_map={"1_1": {
                "camera_direction": "medium shot",
                "framing_scale": "medium",
                "lighting_mood": "warm",
                "key_bg_elements": [],
            }},
            chain_bg_id_by_shot={(1, 1): "L01B01"},
            chain_bg_owned_by_shot={(1, 1): []},
            chain_bg_camera_meta_by_shot={},
            chain_bg_guide_by_shot={},
            fixed_elements_by_scene={},
            dependencies=[],
            shot_scenes_map={},
            entities={
                "characters": [],
                "locations": [],
                "props": [{
                    "short_id": "P03",
                    "name": "prop with dict md",
                    "t2i_prompt": "...prop t2i...",
                    "metadata_json": md_payload,  # dict 직접
                }],
            },
        )
        seg = {"index": 1, "scene_index": 1, "text": "scene segment text"}
        shot_info = {
            "shot_index": 1,
            "camera_direction": "medium shot",
            "primary_subject": "the subject",
        }

        inputs = _derive_card_inputs_from_ctx(
            ctx=ctx, seg=seg, shot_info=shot_info,
        )
        ved = inputs.get("visible_entity_details") or []
        p03_entry = next((e for e in ved if e.get("short_id") == "P03"), None)
        assert p03_entry is not None
        assert p03_entry.get("metadata_json") == md_payload


# =========================================================================
# Area B (2026-05-13) — render_contracts producer / validator / consumer.
# spec: docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md
# =========================================================================
class TestBuildRenderContracts:
    """Area B test 축 5 — render_contracts producer."""

    def test_visible_prop_with_reference_required_true_emits_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P01"],
            visible_entity_details=[{
                "short_id": "P01", "name": "old photo", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": True},
                },
            }],
            current_scene_index=26,
            current_shot_index=6,
        )
        assert len(result) == 1
        contract = result[0]
        assert contract["contract_id"] == "rc_001"
        assert contract["scope"] == {
            "scene_index": 26,
            "shot_indices": [6],
            "duration": "single_shot",
        }
        assert contract["targets"] == [
            {"entity_id": "P01", "role": "visual_target"}
        ]
        assert contract["requirements"] == [{
            "dimension": "visual_identity",
            "operation": "preserve",
            "strength": "required",
            "reference_policy": "use_entity_reference",
        }]

    def test_visible_prop_with_reference_required_false_no_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P02"],
            visible_entity_details=[{
                "short_id": "P02", "name": "cup", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": False},
                },
            }],
            current_scene_index=10,
            current_shot_index=3,
        )
        assert result == []

    def test_non_visible_prop_no_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=[],  # P03 not visible
            visible_entity_details=[{
                "short_id": "P03", "name": "map", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": True},
                },
            }],
            current_scene_index=0,
            current_shot_index=0,
        )
        assert result == []

    def test_character_and_location_no_contract(self):
        """B-min: character/location 은 render_contracts emit X (기존 path 보존)."""
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["C01", "L01"],
            visible_entity_details=[
                {"short_id": "C01", "name": "Alice",
                 "entity_type": "character", "t2i_prompt": "...",
                 "metadata_json": {"location": None, "visual_identity": None}},
                {"short_id": "L01", "name": "옥탑방",
                 "entity_type": "location", "t2i_prompt": "...",
                 "metadata_json": {"location": {"space_profile": {
                     "kind": "single_space",
                     "allowed_space_keys": ["main"],
                     "default_space_key": None,
                 }}, "visual_identity": None}},
            ],
            current_scene_index=0,
            current_shot_index=0,
        )
        assert result == []

    def test_multi_prop_sort_and_rc_id(self):
        """multi visible prop with true → contracts sort by target_entity_id, rc_NNN."""
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P05", "P02", "P10"],
            visible_entity_details=[
                {"short_id": "P05", "name": "a", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {
                     "location": None,
                     "visual_identity": {"reference_required": True},
                 }},
                {"short_id": "P02", "name": "b", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {
                     "location": None,
                     "visual_identity": {"reference_required": True},
                 }},
                {"short_id": "P10", "name": "c", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {
                     "location": None,
                     "visual_identity": {"reference_required": True},
                 }},
            ],
            current_scene_index=5,
            current_shot_index=1,
        )
        # lexicographic sort: P02 < P05 < P10
        assert [c["contract_id"] for c in result] == [
            "rc_001", "rc_002", "rc_003",
        ]
        assert [c["targets"][0]["entity_id"] for c in result] == [
            "P02", "P05", "P10",
        ]


class TestValidateRenderContracts:
    """Area B test 축 5 — render_contracts validator."""

    def _valid_contract(self, sid="P01", scene=26, shot=6):
        return {
            "contract_id": "rc_001",
            "scope": {
                "scene_index": scene, "shot_indices": [shot],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": sid, "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }

    def test_valid_contract_passes(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        validate_render_contracts(
            [self._valid_contract()], scene_index=26, shot_index=6,
        )

    def test_unknown_dimension_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract()
        c["requirements"][0]["dimension"] = "information_surface"
        with pytest.raises(AppError) as exc:
            validate_render_contracts([c], scene_index=26, shot_index=6)
        assert exc.value.code == "render_prompt_card.render_contracts_malformed"

    def test_unknown_operation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract()
        c["requirements"][0]["operation"] = "show_information_bearing_side"
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_invalid_entity_id_pattern_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract(sid="C01")  # not ^P[0-9]+$
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_scope_scene_mismatch_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract(scene=10)  # not current
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_targets_max_items_violation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract()
        c["targets"] = [
            {"entity_id": "P01", "role": "visual_target"},
            {"entity_id": "P02", "role": "visual_target"},
        ]
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_shot_indices_multi_violation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        c = self._valid_contract()
        c["scope"]["shot_indices"] = [6, 7]  # not exactly 1
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)


class TestRequiredRefsFromRenderContracts:
    """Area B test 축 5 — render_contracts consumer."""

    def test_contract_to_required_refs(self):
        from app.core.steps.render_prompt_card import (
            required_refs_from_render_contracts,
        )
        contracts = [{
            "contract_id": "rc_001",
            "scope": {
                "scene_index": 26, "shot_indices": [6],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": "P17", "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        result = required_refs_from_render_contracts(contracts)
        assert result == [{"kind": "prop", "id": "P17", "policy": "required"}]

    def test_unknown_dimension_consumer_raises(self):
        """방어적 second check — validator 우회 시 consumer fail-fast."""
        from app.core.steps.render_prompt_card import (
            required_refs_from_render_contracts,
        )
        contracts = [{
            "contract_id": "rc_001",
            "scope": {
                "scene_index": 26, "shot_indices": [6],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": "P17", "role": "visual_target"}],
            "requirements": [{
                "dimension": "information_surface",  # unknown
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        with pytest.raises(AppError) as exc:
            required_refs_from_render_contracts(contracts)
        assert exc.value.code == "render_prompt_card.render_contracts_malformed"

    def test_empty_contracts_empty_refs(self):
        from app.core.steps.render_prompt_card import (
            required_refs_from_render_contracts,
        )
        assert required_refs_from_render_contracts([]) == []

    def test_unknown_strength_consumer_raises(self):
        """Area B (2026-05-13) M-1 review fix — strength defensive check
        (spec §3.2 4-field strict lock symmetry: dimension/operation/strength/
        reference_policy). validator 우회 시 consumer fail-fast.
        """
        from app.core.steps.render_prompt_card import (
            required_refs_from_render_contracts,
        )
        contracts = [{
            "contract_id": "rc_001",
            "scope": {
                "scene_index": 0, "shot_indices": [0],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": "P01", "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "optional",  # unknown — B-min consumer 는 required 만
                "reference_policy": "use_entity_reference",
            }],
        }]
        with pytest.raises(AppError) as exc:
            required_refs_from_render_contracts(contracts)
        assert exc.value.code == "render_prompt_card.render_contracts_malformed"
        assert "strength" in exc.value.message


class TestBuildAssetRequirementsAreaB:
    """Area B test 축 6 — build_asset_requirements 시그니처 변경 + render_contracts integration."""

    def test_new_signature_no_old_args(self):
        """build_asset_requirements 시그니처에서 old 4 args 제거 검증."""
        import inspect
        from app.core.steps.render_prompt_card import build_asset_requirements
        sig = inspect.signature(build_asset_requirements)
        params = set(sig.parameters.keys())
        # Old args 제거 검증
        assert "visible_entity_details" not in params
        assert "shot_description" not in params
        assert "representative_moment" not in params
        assert "t2i_prompts" not in params
        # New arg 추가 검증
        assert "render_contracts" in params

    def test_render_contracts_propagates_to_required_refs(self):
        from app.core.steps.render_prompt_card import build_asset_requirements
        contracts = [{
            "contract_id": "rc_001",
            "scope": {
                "scene_index": 0, "shot_indices": [0],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": "P01", "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        result = build_asset_requirements(
            visible_entities=["P01"],
            outlook_pairs=[],
            bg_id=None,
            is_close_framing=False,
            background_mode_on=False,
            render_contracts=contracts,
            policy_map={},
        )
        prop_refs = [r for r in result["required_refs"] if r.get("kind") == "prop"]
        assert prop_refs == [{"kind": "prop", "id": "P01", "policy": "required"}]


class TestRenderContractsCardHash:
    """Area B test 축 7 — render_contracts ↔ card hash 정합."""

    def _valid_contract(self, *, contract_id, entity_id, scene=5, shot=1):
        return {
            "contract_id": contract_id,
            "scope": {
                "scene_index": scene, "shot_indices": [shot],
                "duration": "single_shot",
            },
            "targets": [{"entity_id": entity_id, "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }

    def _build_card_with_contracts(self, contracts):
        c = _valid_card()
        c["render_contracts"] = contracts
        return c

    def test_render_contracts_order_irrelevant_for_hash(self):
        from app.core.steps.render_prompt_card import compute_card_hash
        c1 = self._valid_contract(contract_id="rc_001", entity_id="P01")
        c2 = self._valid_contract(contract_id="rc_002", entity_id="P02")
        card_a = self._build_card_with_contracts([c1, c2])
        card_b = self._build_card_with_contracts([c2, c1])  # 순서만 다름
        assert compute_card_hash(card_a) == compute_card_hash(card_b)

    def test_render_contracts_content_change_hash_differs(self):
        from app.core.steps.render_prompt_card import compute_card_hash
        c1 = self._valid_contract(contract_id="rc_001", entity_id="P01")
        c1_modified = self._valid_contract(contract_id="rc_001", entity_id="P02")
        card_a = self._build_card_with_contracts([c1])
        card_b = self._build_card_with_contracts([c1_modified])
        assert compute_card_hash(card_a) != compute_card_hash(card_b)


class TestAreaBDeletionGate:
    """Area B closure: legacy noun-list 식별자가 render_prompt_card.py 에서 0."""

    def test_legacy_story_critical_identifiers_removed(self):
        from pathlib import Path
        import app.core.steps.render_prompt_card as mod
        src_path = Path(mod.__file__)
        text = src_path.read_text(encoding="utf-8")
        for ident in (
            "_STORY_CRITICAL_CATEGORY_GROUPS",
            "_STORY_NOUN_TO_GROUP",
            "_STORY_CRITICAL_PROP_NOUNS",
            "_STORY_PROP_MIN_NAME_LEN",
            "_noun_matches_text",
            "story_critical_prop_filter",
            "_VED_NOT_PROVIDED",
        ):
            assert ident not in text, (
                f"Area B legacy identifier {ident!r} not removed from "
                f"render_prompt_card.py"
            )

    def test_build_asset_requirements_signature_area_b(self):
        """Area B: build_asset_requirements signature 검증 — old 4 args 없음."""
        import inspect
        from app.core.steps.render_prompt_card import build_asset_requirements
        sig = inspect.signature(build_asset_requirements)
        params = set(sig.parameters.keys())
        for old_arg in (
            "visible_entity_details", "shot_description",
            "representative_moment", "t2i_prompts",
        ):
            assert old_arg not in params, (
                f"old arg {old_arg!r} should be removed"
            )
        assert "render_contracts" in params

    def test_v9_prompt_pack_area_b_context_no_fixed_category_list(self):
        """Area B: v9 system.md + turn_entity_detail.md 의 Area B 변경 영역에
        fixed noun-category list 부재 검증. closed-list semantic 검증은 사람
        review (Codex round) 영역 — 본 test 는 prompt load 가능 + 명시적
        noun-list phrase 부재만 검증."""
        from app.modules.prompt_loader import load_prompt
        system_text = load_prompt("entity_extractor_v2", "system")
        detail_text = load_prompt("entity_extractor_v2", "turn_entity_detail")
        # load 자체가 성공해야 한다 (v9 pack present).
        assert system_text is not None and len(system_text) > 0
        assert detail_text is not None and len(detail_text) > 0
        # closed-list noun enumeration ban — 정확 phrase ban (자연 등장은 OK,
        # 의미적 closed-list 검증은 Codex review 영역).
        forbidden_phrases = (
            "photograph or picture or frame",
            "사진 또는 액자 또는 지도",
        )
        for forbidden in forbidden_phrases:
            assert forbidden not in system_text, (
                f"Area B v9 system.md should not contain "
                f"fixed noun list phrase: {forbidden!r}"
            )
            assert forbidden not in detail_text, (
                f"Area B v9 turn_entity_detail.md should not contain "
                f"fixed noun list phrase: {forbidden!r}"
            )


# ── Group C-1: frame_spatial_contract inject + cross-check ────────────────
# Area Frame Spatial Contract (2026-05-14) — Task 4 build_render_strategy
# inject path: validate_and_prepare carry + constraint_id assign +
# visible_entities cross-check.


def _fsc_contract_obj():
    return {
        "reason": "points_to_anchor",
        "constraints": [{
            "target_kind": "character", "target_id": "C02", "label": "B",
            "screen_zone": "lower_right", "depth_plane": "foreground",
            "gesture_action": "points_to", "gesture_target_label": "door",
        }],
    }


def test_build_render_strategy_carries_frame_spatial_contract():
    """staging 의 frame_spatial_contract 가 render_strategy.frame_spatial_contract 로 carry."""
    staging = {
        "camera_direction": "Wide eye level",
        "framing_scale": "wide",
        "lighting_mood": "soft",
        "key_bg_elements": [],
        "frame_spatial_contract": _fsc_contract_obj(),
    
        "subject_reference_policy": [],
    }
    shot_info = {"primary_subject": "B"}
    visible_entities = ["C02"]  # SID list (render_prompt_card.py:1020 일관)
    rs = build_render_strategy(
        seg={}, shot_info=shot_info, staging=staging,
        perception_mode="direct", visible_entities=visible_entities,
    )
    fsc = rs["frame_spatial_contract"]
    assert fsc is not None
    assert fsc["constraints"][0]["constraint_id"] == "fsc_001"


def test_build_render_strategy_null_contract_carries_null():
    """staging.frame_spatial_contract == None → render_strategy.frame_spatial_contract == None."""
    staging = {
        "camera_direction": "Wide", "framing_scale": "wide", "lighting_mood": "x",
        "key_bg_elements": [], "frame_spatial_contract": None,
    
        "subject_reference_policy": [],
    }
    rs = build_render_strategy(
        seg={}, shot_info={"primary_subject": ""}, staging=staging,
        perception_mode="direct", visible_entities=[],
    )
    assert rs["frame_spatial_contract"] is None


def test_build_render_strategy_non_visible_target_w4a_dropped():
    """FINDING 6 W4a — visible_entities 에 없는 well-formed character target
    constraint 는 consumer-boundary 에서 deterministic drop 된다 (옛 동작 =
    fsc_cross_check_failed AppError raise). _fsc_contract_obj 는 C02 단일
    constraint 이므로 전부 drop → frame_spatial_contract == None."""
    staging = {
        "camera_direction": "Wide", "framing_scale": "wide", "lighting_mood": "x", "key_bg_elements": [],
        "frame_spatial_contract": _fsc_contract_obj(),  # C02 사용 (visible 밖)
        "subject_reference_policy": [],
    }
    rs = build_render_strategy(
        seg={}, shot_info={"primary_subject": ""}, staging=staging,
        perception_mode="direct", visible_entities=[],
    )
    assert rs["frame_spatial_contract"] is None


def test_build_render_strategy_visible_entities_required_when_contract_present():
    """frame_spatial_contract 가 emit 됐는데 visible_entities=None → AppError.

    Task 1 No Silent Fallback gate: helper 가 visible_entities is None 을 fail-fast.
    Caller 의 `or []` fallback 금지 (silent absorption regression 차단).
    """
    # background-only contract — target_id 없이도 cross-check 단계 진입
    bg_contract = {
        "reason": "required_background_position",
        "constraints": [{
            "target_kind": "background", "target_id": "", "label": "entrance door",
            "screen_zone": "upper_center", "depth_plane": "background",
            "gesture_action": "none", "gesture_target_label": "",
        }],
    }
    staging = {
        "camera_direction": "Wide",
        "framing_scale": "wide",
        "lighting_mood": "soft",
        "key_bg_elements": [],
        "frame_spatial_contract": bg_contract,
    
        "subject_reference_policy": [],
    }

    # visible_entities 생략 → helper의 None guard 가 raise
    with pytest.raises(AppError) as exc_info:
        build_render_strategy(
            seg={}, shot_info={"primary_subject": ""}, staging=staging,
            perception_mode="direct",
            # visible_entities 생략 → default None
        )
    assert "fsc_invalid" in exc_info.value.code
    assert "visible_entities is None" in exc_info.value.message


# ─────────────────────────────────────────────
# Area #1 — build_id_policy required subject_reference_policies kwarg
# ─────────────────────────────────────────────

def test_build_id_policy_requires_subject_reference_policies_kwarg():
    """build_id_policy 가 subject_reference_policies kwarg 없으면 TypeError (required)."""
    from app.core.steps.render_prompt_card import build_id_policy
    with pytest.raises(TypeError):
        build_id_policy(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
            perception_mode="direct",
            key_bg_elements=[],
            # subject_reference_policies 누락
        )


def test_build_id_policy_injects_subject_reference_policy_empty_array():
    """exceptions-first empty array 도 항상 inject (Gate 4 정합)."""
    from app.core.steps.render_prompt_card import build_id_policy
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "subject_reference_policy" in ip
    assert ip["subject_reference_policy"] == []


def test_build_id_policy_injects_subject_reference_policy_populated():
    from app.core.steps.render_prompt_card import build_id_policy
    items = [{
        "subject_id": "C01",
        "policy_type": "identity_reference",
        "policy": "base_id_required",
        "reason": "test",
    }]
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=items,
    )
    assert ip["subject_reference_policy"] == items


def test_build_id_policy_no_longer_emits_body_part_focus_rule():
    """폐기 entry — body_part_focus_rule / close_framing_face_phrasing 0."""
    from app.core.steps.render_prompt_card import build_id_policy
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "body_part_focus_rule" not in ip
    assert "close_framing_face_phrasing" not in ip
