"""G4.3 ID-policy lift — Rule H + composite/photo-mirror/body-part/demographic
prose 4 sections → id_policy 5 sub-fields + 6 base constraints + 4 new
_card_metadata keys + compute_id_policy_snapshot_hash().

본 테스트는 G4.3 (2026-05-04) 의 spec/plan 에 정의된 lift 동작을 unit test
로 검증한다. v17 system.md 에서 제거된 4 prose section (composite ID 사용
규칙 / 극단 클로즈업 face / 사진·포스터·화면·거울 reproduction surface /
demographic descriptor Rule H) 이 `build_id_policy()` 의 5 신규 builder-static
sub-field + 6 base constraints 로 single source 가 되었는지, _card_metadata
envelope-sibling 이 4 신규 G4.3 lift_status / rule_source key 로 확장됐는지,
그리고 `compute_id_policy_snapshot_hash()` 가 canary pinning 산출용
partial-hash 를 deterministic 하게 산출하는지 확인한다.

Coverage (Area #1 폐기 2 sub-field → 남은 3 sub-field + Area #1 subject_reference_policy
W7 추가 예정):
  - module-level constants set+len equality (R2-B4) — _ID_BODY_PART_* /
    _ID_CLOSE_FACE_* 4 폐기
  - 3 sub-field (Area #1 후) 각 shape + len + content (face_identifiability_rule /
    reproduction_surface_rule / demographic_descriptor_policy)
  - constraints list len 5 base (Area #1: body-part + close-framing inline 폐기 → 1 per-subject inject) + perception_mode reflection branch +1
  - input validation (None / [] / 3 sub-field present per R2-I4)
  - compute_id_policy_snapshot_hash deterministic / drift / fail-fast
  - _assert_id_policy_shape strict validation per sub-field
  - _card_metadata G4.3 4 신규 keys + hash invariance (R1-B1 carry)
  - card hash drift on id_policy.constraints content selector change (R4-I2)

Reference: docs/superpowers/specs/2026-05-04-g4.3-id-policy-lift-design.md
            docs/superpowers/plans/2026-05-04-g4.3-id-policy-lift-implementation.md
"""
from __future__ import annotations

import copy
from typing import Any, Dict, List, Optional

import pytest

from app.core.errors import AppError
from app.core.steps.render_prompt_card import (
    _ID_AGE_BANDS,
    _ID_ETHNICITY_COMPONENTS,
    _REPRODUCTION_SURFACE_CLASSES,
    _assert_id_policy_shape,
    build_id_policy,
    build_render_prompt_card,
    compute_card_hash,
    compute_id_policy_snapshot_hash,
)

# Area C migration (2026-05-12) + review fix-up: build_id_policy() now derives
# reproduction surface applicability from shot_staging.key_bg_elements
# directionality_class enum (SOT). The legacy 9-noun module-level tuple
# `_ID_REPRODUCTION_SURFACES` was removed in Task 1, and the inline 9-noun
# enumeration in constraint[1] was eliminated in the review fix-up. The
# producer's constraint string now references `reproduction_surface_rule
# .applies` (SOT bool), so any test pinning the old 9-noun inline list has
# been removed. See `test_area_c_constraints_do_not_emit_noun_list` (deny
# pin) and `test_area_c_constraints_reference_applies_field` (positive pin)
# in tests/unit/test_render_prompt_card.py for the Area C contract.


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 / G4.2 plan-R1-I4 carry — _DEFAULT_SENTINEL pattern).
# `visible_entities or []` / `outlook_pairs or default` 등 silent absorb
# 패턴 절대 금지 — production drift cascade 의 origin (Wave subagent
# hardcoded empty trap, G4.1 carry).
# ──────────────────────────────────────────────────────────────────────────

_DEFAULT_SENTINEL = object()


def _make_id_policy(
    *,
    visible_entities: Any = _DEFAULT_SENTINEL,
    outlook_pairs: Any = _DEFAULT_SENTINEL,
    perception_mode: Optional[str] = None,
    key_bg_elements: Any = _DEFAULT_SENTINEL,
) -> Dict[str, Any]:
    """build_id_policy() wrapper. None / [] / default 명시 구분.

    Sentinel 의도 (G4.1 함정 1 carry):
      - visible_entities=None → builder 가 AppError raise (producer missing)
      - visible_entities=[] → 빈 list (R2-I4: 5 sub-field 모두 present)
      - visible_entities=_DEFAULT_SENTINEL (default) → ['C01','C02']
    `visible_entities or []` 류 silent absorb 패턴 절대 금지.

    Area C (2026-05-12): build_id_policy 가 key_bg_elements 를 요구. 본 fixture
    의 기본값은 빈 list (applies=False — 비-재현면 shot). 재현면 shot 케이스를
    명시적으로 테스트하려면 `key_bg_elements=[{"directionality_class":
    "content_surface", ...}]` 처럼 명시.
    """
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"},
        ]
    if key_bg_elements is _DEFAULT_SENTINEL:
        key_bg_elements = []
    return build_id_policy(
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
        key_bg_elements=key_bg_elements,
        subject_reference_policies=[],  # Area #1 exceptions-first default
    )


def _make_full_card(
    *,
    visible_entities: Any = _DEFAULT_SENTINEL,
    outlook_pairs: Any = _DEFAULT_SENTINEL,
    perception_mode: Optional[str] = None,
    bg_id: Optional[str] = "cb_test_001",
    bg_owned: Any = _DEFAULT_SENTINEL,
    bg_camera_meta: Any = _DEFAULT_SENTINEL,
    is_close_framing: bool = False,
    background_mode_on: bool = True,
) -> Dict[str, Any]:
    """build_render_prompt_card() entire call — None/[]/default 명시 구분."""
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"},
        ]
    if bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"]
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = {
            "camera_position": "south",
            "camera_height": "eye-level",
            "lens_hint": "35mm",
            "framing_notes": "wide",
        }
    return build_render_prompt_card(
        scene_index=1,
        shot_index=1,
        seg={"index": 1, "text": "scene segment text content here"},
        shot_info={
            "shot_index": 1,
            "camera_direction": (
                "extreme close-up" if is_close_framing else "medium shot"
            ),
            "primary_subject": "the subject at the table",
        },
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
        staging={
            "camera_direction": (
                "extreme close-up" if is_close_framing else "medium shot"
            ),
            # framing_scale enum SOT v1 (2026-05-15): helper read fail-fast 정합.
            "framing_scale": "close" if is_close_framing else "medium",
            "lighting_mood": "warm",
            # Area C (2026-05-12) — build_id_policy 가 staging.key_bg_elements
            # 에서 reproduction surface 적용 여부 derive. 빈 list = 비-재현면
            # shot (applies=False). 재현면 케이스는 별도 fixture 변수로 명시.
            "key_bg_elements": [],
            # Area #1 (2026-05-16) — shot_staging v12 신규 field. exceptions-first
            # empty array (default policy id_and_outlook_required).
            "subject_reference_policy": [],
        },
        bg_id=bg_id,
        bg_owned=bg_owned,
        bg_camera_meta=bg_camera_meta,
        bg_guide="general view",
        is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
        fixed_elements=[],
        previous_shot_refs=[],
        forward_zoom_targets=[],
    )


def _strip_metadata_for_shape_check(
    ip: Dict[str, Any], drop_key: str,
) -> Dict[str, Any]:
    """deepcopy id_policy and remove drop_key — used to test shape validator
    rejects the missing key (without mutating caller's dict).
    """
    out = copy.deepcopy(ip)
    out.pop(drop_key, None)
    return out


# ──────────────────────────────────────────────────────────────────────────
# Class A: TestModuleConstants — 7 module-level constants ground-truth.
# spec §9 Glossary R3-B1/B2/B3 + R2-B4 set+len equality.
# ──────────────────────────────────────────────────────────────────────────


class TestModuleConstants:
    """Module-level constants imported, NOT hardcoded in test fixtures
    (drift ban — R1-I2 / R2-I6). set + len equality (R2-B4) — substring
    or partial-list assertions are banned.
    """

    # Area #1 (2026-05-16): _ID_BODY_PART_TRIGGERS / _ID_BODY_PART_FOCUS_APPLIES_TO
    # / _ID_CLOSE_FACE_FORBIDDEN_PHRASES / _ID_CLOSE_FACE_RECOMMENDED_PHRASINGS
    # 모두 폐기 — subject_reference_policy SOT (helper module) 가 대체.
    # 관련 test 4 개 삭제. W7 이 subject_reference_policy 검증 test 신설.

    def test_reproduction_surface_classes_exact(self) -> None:
        """Area C migration (2026-05-12) — reproduction surface SOT 가 9-noun
        list 에서 directionality_class enum subset 으로 이전. 2 enum 값
        (`content_surface` ∪ `reflective_surface`) 만 applies=True 를 트리거.
        shot_staging.ORIENTATION_REQUIRED_CLASSES 와 정확 동일 (drift 차단).
        """
        assert _REPRODUCTION_SURFACE_CLASSES == frozenset({
            "content_surface", "reflective_surface",
        })
        assert len(_REPRODUCTION_SURFACE_CLASSES) == 2

    def test_id_ethnicity_components_exact(self) -> None:
        """R1-I1 — 10 ethnicity entries (Latina+Latino split)."""
        expected = {
            "Asian", "East Asian", "South Asian", "Southeast Asian",
            "Black", "Middle Eastern", "Hispanic",
            "Latina", "Latino", "Caucasian",
        }
        assert set(_ID_ETHNICITY_COMPONENTS) == expected
        assert len(_ID_ETHNICITY_COMPONENTS) == 10

    def test_id_age_bands_exact(self) -> None:
        """6 age-band entries."""
        assert len(_ID_AGE_BANDS) == 6
        # All are non-empty strings.
        for entry in _ID_AGE_BANDS:
            assert isinstance(entry, str) and entry.strip(), (
                f"age band entry empty/whitespace: {entry!r}"
            )


# ──────────────────────────────────────────────────────────────────────────
# Class B: TestFaceIdentifiabilityRule — 4 keys + content (R1R2-B1).
# ──────────────────────────────────────────────────────────────────────────


class TestFaceIdentifiabilityRule:
    """face_identifiability_rule sub-field — 4 required keys + content."""

    def test_id_policy_has_face_identifiability_rule(self) -> None:
        """sub-field present + dict + 4 required keys."""
        ip = _make_id_policy()
        assert "face_identifiability_rule" in ip
        rule = ip["face_identifiability_rule"]
        assert isinstance(rule, dict)
        for k in (
            "use_entity_id_when",
            "common_noun_required_when",
            "id_use_summary",
            "rationale_summary",
        ):
            assert k in rule, f"missing required key {k!r}"

    def test_id_policy_face_identifiability_use_entity_id_when_exact(
        self,
    ) -> None:
        """R1R2-B1 — 2 entries set+len equality."""
        ip = _make_id_policy()
        rule = ip["face_identifiability_rule"]
        expected = {
            "face identifiable: front, profile, three-quarter, "
            "or eyes closed",
            "OTS framing with any visible face",
        }
        assert set(rule["use_entity_id_when"]) == expected
        assert len(rule["use_entity_id_when"]) == 2

    def test_id_policy_face_identifiability_common_noun_required_when_exact(
        self,
    ) -> None:
        """R1R2-B1 — 3 entries set+len equality."""
        ip = _make_id_policy()
        rule = ip["face_identifiability_rule"]
        expected = {
            "back to camera with no visible face",
            "silhouette or blurred outline only",
            "OTS with only back of head/shoulder, face entirely hidden",
        }
        assert set(rule["common_noun_required_when"]) == expected
        assert len(rule["common_noun_required_when"]) == 3

    def test_id_policy_face_identifiability_id_use_summary_substring(
        self,
    ) -> None:
        """id_use_summary contains 'identifiable AND framing is not dominated'
        substring."""
        ip = _make_id_policy()
        summary = ip["face_identifiability_rule"]["id_use_summary"]
        assert "identifiable AND framing is not dominated" in summary, (
            f"id_use_summary missing substring — {summary!r}"
        )

    def test_id_policy_face_identifiability_present_with_default_perception(
        self,
    ) -> None:
        """R2-I4 — face_identifiability_rule present even when
        perception_mode is None (builder-static, not gated)."""
        ip = _make_id_policy(perception_mode=None)
        assert "face_identifiability_rule" in ip


# ──────────────────────────────────────────────────────────────────────────
# Area #1 (2026-05-16): Class C (TestBodyPartFocusRule) + Class D
# (TestCloseFramingFacePhrasing) 전체 삭제. body_part_focus_rule +
# close_framing_face_phrasing 폐기 → subject_reference_policy SOT 대체.
# W7 이 subject_reference_policy 검증 test 신설.
# ──────────────────────────────────────────────────────────────────────────


# ──────────────────────────────────────────────────────────────────────────
# Class E: TestReproductionSurfaceRule — 3 keys + content (R1R2-B3).
# ──────────────────────────────────────────────────────────────────────────


class TestReproductionSurfaceRule:
    """reproduction_surface_rule sub-field — 3 required keys + content.

    Area C migration (2026-05-12): shape changed from
    `{applies_to_surfaces: [9 nouns], id_use, rationale_summary}` to
    `{applies: bool, id_use, rationale_summary}`. `applies` derived from
    shot_staging.key_bg_elements directionality_class enum (SOT).
    """

    def test_id_policy_has_reproduction_surface_rule(self) -> None:
        """3 required keys + id_use 'forbidden ...' summary.

        Area C migration — `applies` (bool) replaces legacy `applies_to_surfaces`
        (noun list). Deny-assertion preserves regression-pinning that legacy
        key MUST NOT reappear.
        """
        ip = _make_id_policy()
        assert "reproduction_surface_rule" in ip
        rule = ip["reproduction_surface_rule"]
        assert isinstance(rule, dict)
        for k in ("applies", "id_use", "rationale_summary"):
            assert k in rule
        # Legacy shape eradication — Area C SOT migration.
        assert "applies_to_surfaces" not in rule, (
            "legacy 'applies_to_surfaces' key MUST NOT appear after Area C "
            f"migration — got rule={rule!r}"
        )
        assert rule["id_use"].startswith("forbidden"), (
            f"id_use must begin with 'forbidden' — got {rule['id_use']!r}"
        )

    def test_id_policy_reproduction_surface_applies_false_on_empty_key_bg(
        self,
    ) -> None:
        """Area C — key_bg_elements=[] (비-재현면 shot) → applies=False."""
        ip = _make_id_policy(key_bg_elements=[])
        rule = ip["reproduction_surface_rule"]
        assert rule["applies"] is False, (
            f"applies must be False when no reproduction-surface element — "
            f"got {rule!r}"
        )

    def test_id_policy_reproduction_surface_applies_true_on_content_surface(
        self,
    ) -> None:
        """Area C — key_bg_elements 에 content_surface directionality_class
        element 1 개라도 있으면 applies=True (재현면 shot).
        """
        ip = _make_id_policy(key_bg_elements=[
            {
                "element": "photo on wall",
                "directionality_class": "content_surface",
            },
        ])
        rule = ip["reproduction_surface_rule"]
        assert rule["applies"] is True, (
            f"applies must be True on content_surface key_bg_elements — "
            f"got {rule!r}"
        )

    def test_id_policy_reproduction_surface_applies_true_on_reflective_surface(
        self,
    ) -> None:
        """Area C — reflective_surface directionality_class 도 applies=True
        를 트리거 (거울, 반사면). 두 enum 값 (content_surface ∪
        reflective_surface) 가 _REPRODUCTION_SURFACE_CLASSES SOT.
        """
        ip = _make_id_policy(key_bg_elements=[
            {
                "element": "wall mirror",
                "directionality_class": "reflective_surface",
            },
        ])
        rule = ip["reproduction_surface_rule"]
        assert rule["applies"] is True

    def test_id_policy_reproduction_surface_applies_false_on_non_reproduction(
        self,
    ) -> None:
        """Area C — non-reproduction directionality_class (directional_3d,
        non_directional, transparent_surface) 는 applies=False.
        """
        for non_repro_class in (
            "directional_3d", "non_directional", "transparent_surface",
        ):
            ip = _make_id_policy(key_bg_elements=[
                {
                    "element": "an object",
                    "directionality_class": non_repro_class,
                },
            ])
            rule = ip["reproduction_surface_rule"]
            assert rule["applies"] is False, (
                f"directionality_class={non_repro_class!r} must NOT trigger "
                f"applies=True — got {rule!r}"
            )

    def test_id_policy_reproduction_surface_rationale_summary_substring(
        self,
    ) -> None:
        """rationale_summary contains '실물 크기로 합성' substring."""
        ip = _make_id_policy()
        summary = ip["reproduction_surface_rule"]["rationale_summary"]
        assert "실물 크기로 합성" in summary, (
            f"rationale_summary missing substring — {summary!r}"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class F: TestDemographicDescriptorPolicy — Rule H lift (5 tests).
# ──────────────────────────────────────────────────────────────────────────


class TestDemographicDescriptorPolicy:
    """demographic_descriptor_policy sub-field — Rule H lift, components +
    format templates + first-appearance gate.
    """

    def test_id_policy_has_demographic_descriptor_policy(self) -> None:
        """Required top-level keys + first-appearance + token range +
        applies_to_id_forms."""
        ip = _make_id_policy()
        assert "demographic_descriptor_policy" in ip
        ddp = ip["demographic_descriptor_policy"]
        assert isinstance(ddp, dict)
        assert ddp["required_on_first_appearance"] is True
        assert ddp["token_count_range"] == [1, 2]
        assert ddp["applies_to_id_forms"] == ["C##", "C##O##"]

    def test_id_policy_demographic_components_ethnicity_exact(self) -> None:
        """R1-I1 / R2-B4 — components.ethnicity = _ID_ETHNICITY_COMPONENTS."""
        ip = _make_id_policy()
        comp = ip["demographic_descriptor_policy"]["components"]
        assert set(comp["ethnicity"]) == set(_ID_ETHNICITY_COMPONENTS)
        assert len(comp["ethnicity"]) == 10

    def test_id_policy_demographic_components_age_band_exact(self) -> None:
        """R2-B4 — components.age_band = _ID_AGE_BANDS (set+len)."""
        ip = _make_id_policy()
        comp = ip["demographic_descriptor_policy"]["components"]
        assert set(comp["age_band"]) == set(_ID_AGE_BANDS)
        assert len(comp["age_band"]) == 6

    def test_id_policy_demographic_components_gender_and_role_hint_len(
        self,
    ) -> None:
        """gender = {man, woman, figure} (3); role_hint_from_outfit len 4."""
        ip = _make_id_policy()
        comp = ip["demographic_descriptor_policy"]["components"]
        assert set(comp["gender"]) == {"man", "woman", "figure"}
        assert len(comp["gender"]) == 3
        assert len(comp["role_hint_from_outfit"]) == 4

    def test_id_policy_demographic_format_templates_substrings(self) -> None:
        """format_template_a / format_template_b token-shaped substrings +
        scenario_dependency_ban present + no scenario proper nouns
        (substring scan for sample names — none must appear)."""
        ip = _make_id_policy()
        ddp = ip["demographic_descriptor_policy"]
        assert "<옷 1-3 단어>" in ddp["format_template_a"]
        assert "<demographic descriptor>" in ddp["format_template_a"]
        assert "<자세 표현>" in ddp["format_template_b"]
        # scenario_dependency_ban present.
        assert "no work-specific proper nouns" in ddp["scenario_dependency_ban"]
        # 시나리오 의존 0 — placeholder names must NOT appear.
        for proper_noun in (
            "민지", "Jiyeon", "Bukhan", "Itaewon", "Hongdae",
        ):
            for entry in ddp["components"]["ethnicity"]:
                assert proper_noun not in entry, (
                    f"ethnicity entry {entry!r} contains scenario "
                    f"proper noun {proper_noun!r}"
                )


# ──────────────────────────────────────────────────────────────────────────
# Class G: TestConstraintsList — 6 base + content selectors (R4-I2 carry).
# ──────────────────────────────────────────────────────────────────────────


class TestConstraintsList:
    """constraints list — base 5 strings (Area #1 후 perception_mode None);
    +1 with reflection branch.
    """

    def test_id_policy_constraints_grew_to_five_base(self) -> None:
        """perception_mode None → len(constraints) == 5 (Area #1)."""
        ip = _make_id_policy(perception_mode=None)
        assert len(ip["constraints"]) == 5, (
            f"expected 5 base constraints (Area #1), got {len(ip['constraints'])}: "
            f"{ip['constraints']!r}"
        )

    def test_id_policy_constraints_content_selectors(self) -> None:
        """5 constraints content selectors. body-part / close-framing inline
        2 폐기 → subject_reference_policy 단일 inline 1 로 통합."""
        ip = _make_id_policy()
        constraints: List[str] = ip["constraints"]
        # constraints[0]: composite IDs use.
        assert any(
            "C##O## composite IDs" in c for c in constraints
        ), f"missing composite-IDs constraint — got {constraints!r}"
        # constraints[1]: reproduction surface — Area C migration (review
        # fix-up 2026-05-12) references `reproduction_surface_rule.applies`
        # SOT bool, NOT 9-noun inline enumeration.
        rs_constraint = next(
            (c for c in constraints
             if "reproduction_surface_rule.applies" in c
             and "reproduced face" in c),
            None,
        )
        assert rs_constraint is not None, (
            f"missing reproduction-surface constraint — got {constraints!r}"
        )
        # Regression-deny: legacy 9-noun inline enumeration must not return.
        for forbidden in (
            "photograph, poster", "poster, painting", "painting, portrait",
            "TV, mirror", "window reflection",
        ):
            assert forbidden not in rs_constraint, (
                f"Area C regression — {forbidden!r} re-emerged in "
                f"reproduction-surface constraint — {rs_constraint!r}"
            )
        # constraints[2]: Area #1 per-subject reference policy SOT.
        srp_constraint = next(
            (c for c in constraints
             if "subject_reference_policy" in c),
            None,
        )
        assert srp_constraint is not None
        assert "id_and_outlook_required" in srp_constraint
        # constraints[3]: demographic first-appearance.
        df_constraint = next(
            (c for c in constraints if "first time" in c),
            None,
        )
        assert df_constraint is not None
        assert "1-2 token demographic descriptor" in df_constraint
        # constraints[4]: format templates.
        ft_constraint = next(
            (c for c in constraints if "<옷" in c
             or "<demographic descriptor>" in c),
            None,
        )
        assert ft_constraint is not None


# ──────────────────────────────────────────────────────────────────────────
# Class H: TestPerceptionModeBranch — reflection adds 7th constraint.
# ──────────────────────────────────────────────────────────────────────────


class TestPerceptionModeBranch:
    """perception_mode reflection branch — base 5 + 1 = 6 constraints
    (R1-I4 / R2-I5 — defense in depth, no contradiction with
    reproduction_surface_rule).
    """

    def test_id_policy_perception_mode_reflection_adds_constraint(self) -> None:
        """perception_mode='reflection' → constraints len == 6 (Area #1)."""
        ip = _make_id_policy(perception_mode="reflection")
        assert len(ip["constraints"]) == 6, (
            f"reflection branch expected 6 constraints (Area #1), got "
            f"{len(ip['constraints'])}: {ip['constraints']!r}"
        )

    def test_id_policy_perception_mode_reflection_does_not_contradict(
        self,
    ) -> None:
        """Reflection branch constraint string and reproduction_surface_rule
        must NOT contradict (defense in depth — R1-I4 / R2-I5).
        Both should treat reproduced faces as common-noun-only.
        """
        ip = _make_id_policy(perception_mode="reflection")
        # 7th constraint: reflective/projected surface — common nouns.
        last_constraint = ip["constraints"][-1]
        assert (
            "reflective" in last_constraint
            or "projected" in last_constraint
        )
        assert "common noun" in last_constraint
        # reproduction_surface_rule sub-field still present and consistent.
        rsr = ip["reproduction_surface_rule"]
        assert rsr["id_use"].startswith("forbidden"), (
            "reproduction_surface_rule.id_use mismatch — "
            "perception_mode branch must not contradict it"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class I: TestInputValidation — None / [] / 5 sub-field present (R2-I4).
# ──────────────────────────────────────────────────────────────────────────


class TestInputValidation:
    """Input validation — None raises (silent fallback ban, G4.1 / G4.2 carry);
    [] valid + all 5 sub-field present (R2-I4).
    """

    def test_id_policy_visible_entities_none_raises(self) -> None:
        """visible_entities=None → AppError (silent absorb ban — `or []`
        pattern absent in production builder)."""
        with pytest.raises(AppError):
            _make_id_policy(visible_entities=None)

    def test_id_policy_outlook_pairs_none_raises(self) -> None:
        """outlook_pairs=None → AppError."""
        with pytest.raises(AppError):
            _make_id_policy(outlook_pairs=None)

    def test_id_policy_visible_entities_empty_list_valid_with_subfields(
        self,
    ) -> None:
        """R2-I4 — visible_entities=[] + outlook_pairs=[] valid; remaining
        Area #1 후 sub-field 모두 present (builder-static, not gated by
        visible_entities). body-part / close-framing 2 폐기."""
        ip = _make_id_policy(visible_entities=[], outlook_pairs=[])
        for sub in (
            "face_identifiability_rule",
            "reproduction_surface_rule",
            "demographic_descriptor_policy",
        ):
            assert sub in ip, (
                f"sub-field {sub!r} missing on visible_entities=[] — "
                f"R4-M6 builder-static contract violated"
            )


# ──────────────────────────────────────────────────────────────────────────
# Class J: TestComputeIdPolicySnapshotHash — partial hash determinism + drift.
# ──────────────────────────────────────────────────────────────────────────


class TestComputeIdPolicySnapshotHash:
    """compute_id_policy_snapshot_hash() — canary pinning partial hash.
    Deterministic / drift / fail-fast (R2-I2).
    """

    def test_compute_id_policy_snapshot_hash_deterministic(self) -> None:
        """Same id_policy → same 16-char hex hash on repeat call."""
        card_a = _make_full_card()
        card_b = copy.deepcopy(card_a)
        h_a = compute_id_policy_snapshot_hash(card_a)
        h_b = compute_id_policy_snapshot_hash(card_b)
        assert h_a == h_b
        assert len(h_a) == 16
        assert all(c in "0123456789abcdef" for c in h_a), (
            f"snapshot hash not lowercase hex — {h_a!r}"
        )

    def test_compute_id_policy_snapshot_hash_drifts_on_id_policy_change(
        self,
    ) -> None:
        """Modify id_policy in 1 char → hash drifts."""
        card_a = _make_full_card()
        card_b = copy.deepcopy(card_a)
        # 1-char mutation in any id_policy sub-field.
        card_b["id_policy"]["face_identifiability_rule"]["id_use_summary"] = (
            card_b["id_policy"]["face_identifiability_rule"]["id_use_summary"]
            + "X"
        )
        assert compute_id_policy_snapshot_hash(card_a) != (
            compute_id_policy_snapshot_hash(card_b)
        )

    def test_compute_id_policy_snapshot_hash_card_missing_id_policy_raises(
        self,
    ) -> None:
        """card lacking id_policy → AppError (silent fallback ban)."""
        with pytest.raises(AppError):
            compute_id_policy_snapshot_hash({})
        # non-dict.
        with pytest.raises(AppError):
            compute_id_policy_snapshot_hash(None)  # type: ignore[arg-type]


# ──────────────────────────────────────────────────────────────────────────
# Class K: TestAssertIdPolicyShape — strict shape validator.
# spec R2-B3 + R4-I5.
# ──────────────────────────────────────────────────────────────────────────


class TestAssertIdPolicyShape:
    """`_assert_id_policy_shape()` strict validator — each sub-field absence
    triggers AppError. components.ethnicity absence inside
    demographic_descriptor_policy also raises.
    """

    def test_assert_id_policy_shape_validates_face_identifiability_rule(
        self,
    ) -> None:
        """face_identifiability_rule missing → AppError."""
        ip = _make_id_policy()
        ip = _strip_metadata_for_shape_check(
            ip, "face_identifiability_rule",
        )
        with pytest.raises(AppError):
            _assert_id_policy_shape(ip, where="test")

    # Area #1 (2026-05-16): body_part_focus_rule + close_framing_face_phrasing
    # 폐기 — _assert_id_policy_shape() shape validation 대상 X. W7 이
    # subject_reference_policy missing → AppError 검증 추가.

    def test_assert_id_policy_shape_validates_reproduction_surface_rule(
        self,
    ) -> None:
        """reproduction_surface_rule missing → AppError."""
        ip = _make_id_policy()
        ip = _strip_metadata_for_shape_check(
            ip, "reproduction_surface_rule",
        )
        with pytest.raises(AppError):
            _assert_id_policy_shape(ip, where="test")

    def test_assert_id_policy_shape_validates_demographic_descriptor_policy(
        self,
    ) -> None:
        """demographic_descriptor_policy missing → AppError."""
        ip = _make_id_policy()
        ip = _strip_metadata_for_shape_check(
            ip, "demographic_descriptor_policy",
        )
        with pytest.raises(AppError):
            _assert_id_policy_shape(ip, where="test")

    def test_assert_id_policy_shape_validates_demographic_components_keys(
        self,
    ) -> None:
        """demographic_descriptor_policy.components.ethnicity missing →
        AppError (R2-B3 components.required_keys)."""
        ip = _make_id_policy()
        # Surgically remove components.ethnicity (deepcopy to avoid touching
        # the original — also avoids mutating shared module state).
        ip = copy.deepcopy(ip)
        ip["demographic_descriptor_policy"]["components"].pop(
            "ethnicity", None,
        )
        with pytest.raises(AppError):
            _assert_id_policy_shape(ip, where="test")


# ──────────────────────────────────────────────────────────────────────────
# Class L: TestCardMetadataG4_3 — 4 신규 keys + hash invariance.
# spec §2.2 + R1-B1 / R4-I4 carry.
# ──────────────────────────────────────────────────────────────────────────


class TestCardMetadataG4_3:
    """`_card_metadata` G4.3 4 신규 keys (rule_h_lifted +
    id_policy_composite_lifted + id_policy_close_framing_face_lifted +
    id_policy_reproduction_surface_lifted) + hash invariance.
    """

    def test_card_metadata_lift_status_includes_g4_3_keys(self) -> None:
        """4 G4.3 keys all present + all True (perception 무관, single row
        per spec §2.2 matrix)."""
        card = _make_full_card()
        lift_status = card["_card_metadata"]["lift_status"]
        for key in (
            "rule_h_lifted",
            "id_policy_composite_lifted",
            "id_policy_close_framing_face_lifted",
            "id_policy_reproduction_surface_lifted",
        ):
            assert key in lift_status, (
                f"G4.3 lift_status key {key!r} missing — got "
                f"{sorted(lift_status.keys())!r}"
            )
            assert lift_status[key] is True, (
                f"G4.3 {key!r} expected True, got {lift_status[key]!r}"
            )

    def test_card_metadata_rule_source_includes_g4_3_keys(self) -> None:
        """4 G4.3 rule_source keys: demographic_rule='H' + composite_id_rule
        + close_framing_face_rule + reproduction_surface_rule.
        v17 line range string hardcoded (시나리오 의존 0).
        """
        card = _make_full_card()
        rs = card["_card_metadata"]["rule_source"]
        assert rs["demographic_rule"] == "H"
        assert "composite_id_rule" in rs
        assert "close_framing_face_rule" in rs
        # builder uses key 'reproduction_surface_rule' (production).
        assert "reproduction_surface_rule" in rs, (
            f"missing reproduction_surface_rule — got "
            f"{sorted(rs.keys())!r}"
        )

    def test_card_metadata_excluded_from_hash_after_g4_3_keys_added(
        self,
    ) -> None:
        """G4.2 R1-B1 carry — even after adding 4 new G4.3 keys, mutating
        `_card_metadata.lift_status` does NOT change compute_card_hash().
        """
        card_a = _make_full_card()
        h_a = compute_card_hash(card_a)
        card_b = copy.deepcopy(card_a)
        # Mutate G4.3-specific lift_status booleans.
        card_b["_card_metadata"]["lift_status"]["rule_h_lifted"] = False
        card_b["_card_metadata"]["lift_status"][
            "id_policy_composite_lifted"
        ] = False
        # Add an extra free-form key.
        card_b["_card_metadata"]["custom_g4_3_extra"] = "diagnostic-only"
        assert compute_card_hash(card_b) == h_a, (
            "card hash drifted on _card_metadata.lift_status mutation — "
            "R1-B1 (envelope-sibling hash exclusion) broken for G4.3 keys"
        )

    def test_card_hash_invariant_to_card_metadata_lift_status_dynamic_keys(
        self,
    ) -> None:
        """R4-I4 carry — dynamic boolean values in `_card_metadata.lift_status`
        (e.g., rule_e_lifted close_skip vs off) MUST NOT alter compute_card_hash()
        when only the metadata differs. If Wave 1-A misplaced the
        `_card_metadata` strip or G4.3 keys leaked outside the envelope,
        this test would fail.
        """
        # Build a not_applicable card (rule_e_lifted=False) and a close
        # card (rule_e_lifted=True) ON DIFFERENT shape — but both with
        # identical envelope contract fields by re-aliasing _card_metadata
        # alone.
        card_base = _make_full_card()
        h_base = compute_card_hash(card_base)
        card_mut = copy.deepcopy(card_base)
        # Flip dynamic G4.2 + G4.3 metadata booleans.
        card_mut["_card_metadata"]["lift_status"]["rule_e_lifted"] = (
            not card_base["_card_metadata"]["lift_status"]["rule_e_lifted"]
        )
        card_mut["_card_metadata"]["lift_status"]["rule_h_lifted"] = False
        card_mut["_card_metadata"]["lift_status"][
            "id_policy_close_framing_face_lifted"
        ] = False
        card_mut["_card_metadata"]["rule_source"]["demographic_rule"] = "X"
        assert compute_card_hash(card_mut) == h_base, (
            "card hash drifted on `_card_metadata` dynamic key mutation — "
            "envelope-sibling hash exclusion contract broken (R4-I4)"
        )

    def test_card_hash_drifts_on_id_policy_constraint_change(self) -> None:
        """R4-I2 — content selector (Area #1 'subject_reference_policy'),
        NOT positional index. Modify constraint string → hash drifts.
        """
        card_a = _make_full_card()
        h_a = compute_card_hash(card_a)
        card_b = copy.deepcopy(card_a)
        constraints = card_b["id_policy"]["constraints"]
        # Content selector — Area #1 per-subject policy constraint.
        idx = next(
            (i for i, c in enumerate(constraints)
             if "subject_reference_policy" in c),
            None,
        )
        assert idx is not None, (
            "subject_reference_policy constraint not present in id_policy — "
            "fixture or builder broken"
        )
        constraints[idx] = (
            "MUTATED — different reminder string for hash-drift detection"
        )
        h_b = compute_card_hash(card_b)
        assert h_a != h_b, (
            "card hash did not drift on id_policy.constraints content "
            "change — verify_completion would falsely mark this clean"
        )
