"""G4.4 Continuity lift — Sections A/B/C prose (cross-shot substitution +
ref_usage + single-view) → continuity_elements_used 3 신규 sibling policy
dicts + 7 constraints + 4 _card_metadata lift_status keys + 3 rule_source
keys + extended shape validators + compute_continuity_snapshot_hash().

본 테스트는 G4.4 (2026-05-05) 의 spec/plan 에 정의된 lift 동작을 unit test
로 검증한다. v18 system.md 의 3 prose section (## 교차 샷 고정 요소 통합 규칙
/ ## 앞쪽 참조 샷 처리 — ref_usage 유형별 지시 / ## 절대 규칙 — 단일 시점)
이 `build_continuity_elements_used()` 의 3 신규 sibling policy dict
(cross_shot_id_substitution_rule / ref_usage_constraints / view_consistency)
+ G4.1 base 2 → G4.4 7 constraints 로 single source 가 되었는지,
_card_metadata envelope-sibling 이 G4.4 4 신규 lift_status / 3 rule_source
key 로 확장됐는지, 그리고 strict shape validator 들이 누락 키를 fail-fast
하는지 확인한다.

Coverage (46 distinct test functions per plan Phase 5 Task 5.1 table):
  - 1-4   cross_shot_id_substitution_rule sub-field shape + content
  - 5-13  ref_usage_constraints (3 nested sub-keys with required keys + counts)
  - 14-21 view_consistency 9 required keys + content (incl. mixing_forbidden
          bool literal trap + cross-card paired substring)
  - 22-29 constraints list 7 strings (G4.1 base + 6 inline content)
  - 30-32 input validation None raises (silent absorb ban)
  - 33-34 builder-static invariant on empty inputs (Override O-9)
  - 35    Override O-11 multi-ref atmosphere shot fixture filter
  - 36-37 _card_metadata G4.4 4 신규 lift_status + 3 신규 rule_source keys
  - 38    G4.4 _card_metadata mutation does NOT change compute_card_hash()
          (G4.2 R1-B1 / G4.3 R1-I11 carry)
  - 39-42 _assert_continuity_elements_used_shape() strict validator coverage
  - 43-46 _assert_ref_usage_constraints_shape() nested helper coverage

Reference: docs/superpowers/specs/2026-05-05-g4.4-continuity-lift-design.md
            docs/superpowers/plans/2026-05-05-g4.4-continuity-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 (
    _CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT,
    _CONTINUITY_CROSS_SHOT_ID_SUB_REQUIRED_KEYS,
    _CONTINUITY_EXACT_PERMITTED_ADDITIONS_COUNT,
    _CONTINUITY_FRAMING_SCOPE_OPTIONS,
    _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL,
    _CONTINUITY_REF_USAGE_ATMOSPHERE_REQUIRED_KEYS,
    _CONTINUITY_REF_USAGE_EXACT_REQUIRED_KEYS,
    _CONTINUITY_REF_USAGE_TOP_REQUIRED_KEYS,
    _CONTINUITY_REF_USAGE_ZOOM_REQUIRED_KEYS,
    _CONTINUITY_VIEW_CONSISTENCY_REQUIRED_KEYS,
    _CONTINUITY_ZOOM_FORBIDDEN_ADDITIONS_COUNT,
    _CONTINUITY_ZOOM_REQUIRED_PHRASINGS_COUNT,
    _assert_continuity_elements_used_shape,
    _assert_ref_usage_constraints_shape,
    build_continuity_elements_used,
    build_render_prompt_card,
    compute_card_hash,
)


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

_DEFAULT_SENTINEL = object()

# Realistic non-empty default lists for sentinel-aware fixtures. fixed_element
# entries follow the production 9-field shape (R1-I2 carry), previous_shot_ref
# follows scene_index/shot_index/ref_usage shape, forward_zoom_target follows
# scene_index/shot_index/description shape.
_DEFAULT_FIXED_ELEMENTS: List[Dict[str, Any]] = [
    {
        "element_id": "fe_test_001",
        "element_type": "character_pose",
        "character_name": "C01",
        "description": "An Asian man stands by the doorway, hand resting on the frame",
        "applies_to_shots": [{"scene_index": 12, "shot_index": 4}],
        "element_scope": "full",  # Area #4 W3 — required field
        "source_facts": ["scene segment text mentions doorway"],
        "visual_inferences": ["doorway implies threshold pose"],
        "creative_decisions": ["pose mirrors prior shot continuity"],
        "confidence": "high",
    },
]
_DEFAULT_PREVIOUS_SHOT_REFS: List[Dict[str, Any]] = [
    {"scene_index": 12, "shot_index": 3, "ref_usage": "exact_background"},
]
_DEFAULT_FORWARD_ZOOM_TARGETS: List[Dict[str, Any]] = [
    {
        "scene_index": 12,
        "shot_index": 5,
        "description": "close-up reveal of the photograph in his hand",
    },
]


def _make_continuity(
    *,
    fixed_elements: Any = _DEFAULT_SENTINEL,
    previous_shot_refs: Any = _DEFAULT_SENTINEL,
    forward_zoom_targets: Any = _DEFAULT_SENTINEL,
) -> Dict[str, Any]:
    """build_continuity_elements_used() wrapper. None / [] / default 명시 구분.

    Sentinel 의도 (G4.1+G4.2+G4.3 carry):
      - fixed_elements=None → builder 가 AppError raise (producer missing)
      - fixed_elements=[] → 빈 list (builder-static contract — 3 sibling
        policy dicts present 보장)
      - fixed_elements=_DEFAULT_SENTINEL (default) → realistic non-empty
        default list (production-shaped fixture)
    `fixed_elements or []` 류 silent absorb 패턴 절대 금지.
    """
    if fixed_elements is _DEFAULT_SENTINEL:
        fixed_elements = copy.deepcopy(_DEFAULT_FIXED_ELEMENTS)
    if previous_shot_refs is _DEFAULT_SENTINEL:
        previous_shot_refs = copy.deepcopy(_DEFAULT_PREVIOUS_SHOT_REFS)
    if forward_zoom_targets is _DEFAULT_SENTINEL:
        forward_zoom_targets = copy.deepcopy(_DEFAULT_FORWARD_ZOOM_TARGETS)
    return build_continuity_elements_used(
        fixed_elements=fixed_elements,
        previous_shot_refs=previous_shot_refs,
        forward_zoom_targets=forward_zoom_targets,
    )


def _make_full_card(
    *,
    fixed_elements: Any = _DEFAULT_SENTINEL,
    previous_shot_refs: Any = _DEFAULT_SENTINEL,
    forward_zoom_targets: Any = _DEFAULT_SENTINEL,
    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 — G4.4 sentinel-aware fixture.

    Carry priority (Wave 4 R4 carry per plan §5.1):
      G4.3 helper carry 후 G4.4 신규 sub-field 가 추가된 확장 helper.
      _DEFAULT_SENTINEL pattern + None vs [] vs default 셋 명시 구분.
    """
    if fixed_elements is _DEFAULT_SENTINEL:
        fixed_elements = copy.deepcopy(_DEFAULT_FIXED_ELEMENTS)
    if previous_shot_refs is _DEFAULT_SENTINEL:
        previous_shot_refs = copy.deepcopy(_DEFAULT_PREVIOUS_SHOT_REFS)
    if forward_zoom_targets is _DEFAULT_SENTINEL:
        forward_zoom_targets = copy.deepcopy(_DEFAULT_FORWARD_ZOOM_TARGETS)
    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=12,
        shot_index=4,
        seg={"index": 12, "text": "scene segment text content here"},
        shot_info={
            "shot_index": 4,
            "camera_direction": (
                "extreme close-up" if is_close_framing else "medium shot"
            ),
            "primary_subject": "the subject at the doorway",
        },
        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) — required by build_id_policy. 빈 list =
            # 비-재현면 shot (applies=False).
            "key_bg_elements": [],
            # Area #1 W5 (2026-05-16) — shot_staging v12 top-level required
            # field. helper SOT graceful empty.
            "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=fixed_elements,
        previous_shot_refs=previous_shot_refs,
        forward_zoom_targets=forward_zoom_targets,
    )


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


# ──────────────────────────────────────────────────────────────────────────
# Tests 1-4 — cross_shot_id_substitution_rule sub-field (Override O-6).
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_has_cross_shot_id_substitution_rule() -> None:
    """test 1 — cross_shot_id_substitution_rule dict + 6 required key superset."""
    ce = _make_continuity()
    assert "cross_shot_id_substitution_rule" in ce
    rule = ce["cross_shot_id_substitution_rule"]
    assert isinstance(rule, dict)
    expected = set(_CONTINUITY_CROSS_SHOT_ID_SUB_REQUIRED_KEYS)
    assert set(rule.keys()) >= expected, (
        f"cross_shot_id_substitution_rule missing required keys "
        f"{sorted(expected - set(rule.keys()))!r}"
    )


def test_continuity_cross_shot_id_substitution_rule_required_keys_superset() -> None:
    """test 2 — Override O-5 superset (NOT set equality). 6 required keys."""
    ce = _make_continuity()
    returned_keys = set(ce["cross_shot_id_substitution_rule"].keys())
    expected = {
        "applies_when",
        "substitution",
        "double_description_forbidden",
        "preserve_pose_unchanged",
        "no_repeat_after_pose",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"cross_shot_id_substitution_rule keys not superset of expected — "
        f"missing {sorted(expected - returned_keys)!r}"
    )


def test_continuity_cross_shot_id_substitution_rule_substitution_substring() -> None:
    """test 3 — substitution contains 'replace the common-noun person reference'."""
    ce = _make_continuity()
    sub = ce["cross_shot_id_substitution_rule"]["substitution"]
    assert "replace the common-noun person reference" in sub, (
        f"substitution missing substring — {sub!r}"
    )


def test_continuity_cross_shot_id_substitution_rule_no_repeat_after_pose_substring() -> None:
    """test 4 — no_repeat_after_pose contains '별도 문장으로 반복하지 마라'."""
    ce = _make_continuity()
    no_repeat = ce["cross_shot_id_substitution_rule"]["no_repeat_after_pose"]
    assert "별도 문장으로 반복하지 마라" in no_repeat, (
        f"no_repeat_after_pose missing substring — {no_repeat!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 5-13 — ref_usage_constraints (3 sub-key + nested required keys +
# counts).
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_has_ref_usage_constraints() -> None:
    """test 5 — ref_usage_constraints dict + 3 sub-key superset."""
    ce = _make_continuity()
    assert "ref_usage_constraints" in ce
    ru = ce["ref_usage_constraints"]
    assert isinstance(ru, dict)
    expected = set(_CONTINUITY_REF_USAGE_TOP_REQUIRED_KEYS)
    assert set(ru.keys()) >= expected, (
        f"ref_usage_constraints missing required sub-keys "
        f"{sorted(expected - set(ru.keys()))!r}"
    )


def test_continuity_ref_usage_zoom_in_detail_required_keys_superset() -> None:
    """test 6 — zoom_in_detail 5 required keys superset."""
    ce = _make_continuity()
    zid = ce["ref_usage_constraints"]["zoom_in_detail"]
    returned_keys = set(zid.keys())
    # Area #1 W5 (2026-05-16): body_part_cross_ref → subject_reference_policy_cross_ref rename.
    expected = {
        "scope_summary",
        "forbidden_additions",
        "required_phrasings",
        "subject_reference_policy_cross_ref",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"zoom_in_detail keys not superset of expected — "
        f"missing {sorted(expected - returned_keys)!r}"
    )


def test_continuity_ref_usage_zoom_in_detail_forbidden_additions_count() -> None:
    """test 7 — len(forbidden_additions) == 5 (count constant)."""
    ce = _make_continuity()
    fa = ce["ref_usage_constraints"]["zoom_in_detail"]["forbidden_additions"]
    assert len(fa) == _CONTINUITY_ZOOM_FORBIDDEN_ADDITIONS_COUNT
    assert _CONTINUITY_ZOOM_FORBIDDEN_ADDITIONS_COUNT == 5


def test_continuity_ref_usage_zoom_in_detail_required_phrasings_count() -> None:
    """test 8 — len(required_phrasings) == 3 (count constant)."""
    ce = _make_continuity()
    rp = ce["ref_usage_constraints"]["zoom_in_detail"]["required_phrasings"]
    assert len(rp) == _CONTINUITY_ZOOM_REQUIRED_PHRASINGS_COUNT
    assert _CONTINUITY_ZOOM_REQUIRED_PHRASINGS_COUNT == 3


# test 9 폐기 (Area #1 W5, 2026-05-16): paired-drift 차단 (zoom_in_detail
# body_part_cross_ref + id_policy.body_part_focus_rule literal) — cross_ref
# key 자체 rename (subject_reference_policy_cross_ref) + paired literal value
# rename (id_policy.subject_reference_policy). paired-drift 의미 상실.
# (`_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` constant value 도 W3 가 갱신.)


def test_continuity_ref_usage_exact_background_required_keys_superset() -> None:
    """test 10 — exact_background 5 required keys superset."""
    ce = _make_continuity()
    eb = ce["ref_usage_constraints"]["exact_background"]
    returned_keys = set(eb.keys())
    expected = {
        "scope_summary",
        "permitted_additions",
        "background_consistency_rule",
        "ignore_keep_handled_elsewhere",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"exact_background keys not superset of expected — "
        f"missing {sorted(expected - returned_keys)!r}"
    )


def test_continuity_ref_usage_exact_background_permitted_additions_count() -> None:
    """test 11 — len(permitted_additions) == 2 (count constant)."""
    ce = _make_continuity()
    pa = ce["ref_usage_constraints"]["exact_background"]["permitted_additions"]
    assert len(pa) == _CONTINUITY_EXACT_PERMITTED_ADDITIONS_COUNT
    assert _CONTINUITY_EXACT_PERMITTED_ADDITIONS_COUNT == 2


def test_continuity_ref_usage_atmosphere_reference_required_keys_superset() -> None:
    """test 12 — atmosphere_reference 4 required keys superset."""
    ce = _make_continuity()
    ar = ce["ref_usage_constraints"]["atmosphere_reference"]
    returned_keys = set(ar.keys())
    expected = {
        "scope_summary",
        "forbidden_imports",
        "required_handling",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"atmosphere_reference keys not superset of expected — "
        f"missing {sorted(expected - returned_keys)!r}"
    )


def test_continuity_ref_usage_atmosphere_reference_forbidden_imports_count() -> None:
    """test 13 — len(forbidden_imports) == 3 (count constant)."""
    ce = _make_continuity()
    fi = ce["ref_usage_constraints"]["atmosphere_reference"]["forbidden_imports"]
    assert len(fi) == _CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT
    assert _CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT == 3


# ──────────────────────────────────────────────────────────────────────────
# Tests 14-21 — view_consistency 9 required keys + content + bool trap.
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_has_view_consistency() -> None:
    """test 14 — view_consistency dict + 9 required key superset."""
    ce = _make_continuity()
    assert "view_consistency" in ce
    vc = ce["view_consistency"]
    assert isinstance(vc, dict)
    expected = set(_CONTINUITY_VIEW_CONSISTENCY_REQUIRED_KEYS)
    assert set(vc.keys()) >= expected, (
        f"view_consistency missing required keys "
        f"{sorted(expected - set(vc.keys()))!r}"
    )


def test_continuity_view_consistency_required_keys_superset() -> None:
    """test 15 — Override O-5 superset, NOT set equality. 9 keys explicit."""
    ce = _make_continuity()
    returned_keys = set(ce["view_consistency"].keys())
    expected = {
        "framing_scope_options",
        "single_camera_rule",
        "third_person_handling",
        "close_up_handling",
        "focus_on_is_focus_area_not_view_switch",
        "subject_reference_policy_cross_ref",
        "prop_orientation_rule",
        "mixing_forbidden",
        "rationale_summary",
    }
    assert returned_keys >= expected, (
        f"view_consistency keys not superset — missing "
        f"{sorted(expected - returned_keys)!r}"
    )


def test_continuity_view_consistency_framing_scope_options_exact() -> None:
    """test 16 — view_consistency.framing_scope_options == list equality
    (정확 2 entry list, NOT set superset)."""
    ce = _make_continuity()
    fso = ce["view_consistency"]["framing_scope_options"]
    expected = list(_CONTINUITY_FRAMING_SCOPE_OPTIONS)
    assert fso == expected, (
        f"framing_scope_options drift — got {fso!r} expected {expected!r}"
    )
    assert len(fso) == 2


def test_continuity_view_consistency_mixing_forbidden_is_bool_true() -> None:
    """test 17 — mixing_forbidden is True AND type(...) is bool
    (G3.2 / G4.3 bool int subclass trap carry)."""
    ce = _make_continuity()
    mixing_forbidden = ce["view_consistency"]["mixing_forbidden"]
    assert mixing_forbidden is True and type(mixing_forbidden) is bool, (
        f"mixing_forbidden must be bool literal True — got "
        f"type={type(mixing_forbidden).__name__} value={mixing_forbidden!r}"
    )


# test 18 폐기 (Area #1 W5, 2026-05-16): paired-drift 차단 (view_consistency
# id_policy_cross_ref_for_body_part_focus) — key 자체 rename + literal value
# 모두 폐기. paired-drift 의미 상실.
#
# test 19 폐기 (Area #1 W5, 2026-05-16): cross-card paired-drift 차단 — 양쪽
# (view_consistency + ref_usage zoom_in_detail) cross_ref key + literal value
# 모두 폐기 / rename. paired 검증 의미 상실.


def test_continuity_view_consistency_focus_on_is_focus_area_not_view_switch_string_present() -> None:
    """test 20 — Area #1 W5 (2026-05-16) — focus_on_is_focus_area_not_view_switch
    wording 갱신 ("area of focus" / "NOT a view switch" → "framing-intent" /
    "does not switch the view"). framing-intent neutral wording 으로 semantic
    동일성 검증."""
    ce = _make_continuity()
    focus = ce["view_consistency"]["focus_on_is_focus_area_not_view_switch"]
    assert "framing-intent" in focus, (
        f"focus_on_is_focus_area_not_view_switch missing 'framing-intent' "
        f"— {focus!r}"
    )
    assert "does not switch the view" in focus, (
        f"focus_on_is_focus_area_not_view_switch missing 'does not switch "
        f"the view' — {focus!r}"
    )


def test_continuity_view_consistency_prop_orientation_rule_string_present() -> None:
    """test 21 — prop_orientation_rule contains 'one direction only'."""
    ce = _make_continuity()
    prop = ce["view_consistency"]["prop_orientation_rule"]
    assert "one direction only" in prop, (
        f"prop_orientation_rule missing 'one direction only' — {prop!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 22-29 — constraints list 7 strings (G4.1 base 2 → G4.4 7).
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_constraints_grew_to_seven_strings() -> None:
    """test 22 — len(constraints) == 7 (G4.1 base 2 → G4.4 7)."""
    ce = _make_continuity()
    assert len(ce["constraints"]) == 7, (
        f"expected 7 constraints, got {len(ce['constraints'])}: "
        f"{ce['constraints']!r}"
    )


def test_continuity_constraints_string_1_fixed_binding_carry() -> None:
    """test 23 — constraints[0] == G4.1 carry verbatim string equality."""
    ce = _make_continuity()
    expected = (
        "fixed_elements are continuity inputs derived from prior shots — "
        "treat them as binding context, not as source facts to be invented"
    )
    assert ce["constraints"][0] == expected, (
        f"constraints[0] G4.1 carry drifted — got {ce['constraints'][0]!r}"
    )


def test_continuity_constraints_string_2_cross_shot_substitution_inline() -> None:
    """test 24 — constraints[1] inline substrings."""
    ce = _make_continuity()
    c1 = ce["constraints"][1]
    assert "replace the common-noun person reference" in c1, (
        f"constraints[1] missing 'replace the common-noun ...' — {c1!r}"
    )
    assert "do not also write a separate common-noun" in c1, (
        f"constraints[1] missing 'do not also write a separate common-noun' "
        f"— {c1!r}"
    )


def test_continuity_constraints_string_3_zoom_in_detail_inline() -> None:
    """test 25 — constraints[2] inline zoom_in_detail substrings."""
    ce = _make_continuity()
    c2 = ce["constraints"][2]
    assert "ref_usage='zoom_in_detail'" in c2, (
        f"constraints[2] missing zoom_in_detail label — {c2!r}"
    )
    assert "no new persons, props, background" in c2, (
        f"constraints[2] missing 'no new persons, props, background' — {c2!r}"
    )


def test_continuity_constraints_string_4_exact_background_inline() -> None:
    """test 26 — constraints[3] inline exact_background substrings."""
    ce = _make_continuity()
    c3 = ce["constraints"][3]
    assert "ref_usage='exact_background'" in c3, (
        f"constraints[3] missing exact_background label — {c3!r}"
    )
    assert "keep background elements consistent" in c3, (
        f"constraints[3] missing 'keep background elements consistent' "
        f"— {c3!r}"
    )


def test_continuity_constraints_string_5_atmosphere_reference_inline() -> None:
    """test 27 — constraints[4] inline atmosphere_reference substrings."""
    ce = _make_continuity()
    c4 = ce["constraints"][4]
    assert "ref_usage='atmosphere_reference'" in c4, (
        f"constraints[4] missing atmosphere_reference label — {c4!r}"
    )
    assert "do not import furniture layout" in c4, (
        f"constraints[4] missing 'do not import furniture layout' — {c4!r}"
    )


def test_continuity_constraints_string_6_single_camera_inline() -> None:
    """test 28 — constraints[5] inline single_camera + body-part xref
    (Override O-7 cross-card paired)."""
    ce = _make_continuity()
    c5 = ce["constraints"][5]
    assert "one t2i_prompt = one camera position" in c5, (
        f"constraints[5] missing 'one t2i_prompt = one camera position' "
        f"— {c5!r}"
    )
    assert "id_policy.subject_reference_policy" in c5, (
        f"constraints[5] missing 'id_policy.subject_reference_policy' "
        f"— Area #1 W5 rename (paired literal value updated) — {c5!r}"
    )


def test_continuity_constraints_string_7_prop_orientation_inline() -> None:
    """test 29 — constraints[6] inline prop_orientation_rule substring."""
    ce = _make_continuity()
    c6 = ce["constraints"][6]
    assert "one direction only" in c6, (
        f"constraints[6] missing 'one direction only' — {c6!r}"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 30-32 — input validation None raises (silent absorb ban — G4.1+G4.2+
# G4.3 carry, Override O-8).
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_fixed_elements_none_raises() -> None:
    """test 30 — fixed_elements=None → AppError (silent absorb ban —
    `or []` pattern absent in production builder)."""
    with pytest.raises(AppError):
        _make_continuity(fixed_elements=None)


def test_continuity_previous_shot_refs_none_raises() -> None:
    """test 31 — previous_shot_refs=None → AppError."""
    with pytest.raises(AppError):
        _make_continuity(previous_shot_refs=None)


def test_continuity_forward_zoom_targets_none_raises() -> None:
    """test 32 — forward_zoom_targets=None → AppError."""
    with pytest.raises(AppError):
        _make_continuity(forward_zoom_targets=None)


# ──────────────────────────────────────────────────────────────────────────
# Tests 33-34 — builder-static invariant on empty inputs (Override O-9).
# Empty list usage here is INTENTIONAL — the test validates that the 3
# sibling policy dicts + constraints list are present even when all input
# lists are empty (NOT the Wave subagent fixture trap pattern).
# ──────────────────────────────────────────────────────────────────────────


def test_continuity_fixed_elements_empty_list_valid_with_static_subfields() -> None:
    """test 33 — fixed_elements=[] + previous_shot_refs=[] +
    forward_zoom_targets=[] → no raise + 3 sibling policy dicts present +
    constraints len == 7 (R2-I4 from G4.3 carry — builder-static contract).
    """
    ce = _make_continuity(
        fixed_elements=[],
        previous_shot_refs=[],
        forward_zoom_targets=[],
    )
    assert "cross_shot_id_substitution_rule" in ce
    assert "ref_usage_constraints" in ce
    assert "view_consistency" in ce
    assert len(ce["constraints"]) == 7


def test_continuity_4_subfields_independent_of_input_lists() -> None:
    """test 34 — Override O-9 builder-static invariant. Compare two outputs:
      a) all empty lists  b) realistic non-empty defaults.
    cross_shot_id_substitution_rule + ref_usage_constraints +
    view_consistency + constraints 4 subfield 모두 동일 (입력 무관). 만약
    builder 에 `if previous_shot_refs:` 류 truthy guard 가 있으면 두 출력의
    sibling policy dict 가 달라져 fail.
    """
    ce_empty = _make_continuity(
        fixed_elements=[],
        previous_shot_refs=[],
        forward_zoom_targets=[],
    )
    ce_full = _make_continuity()  # default = realistic non-empty
    # 3 sibling policy dicts deep equality.
    assert ce_empty["cross_shot_id_substitution_rule"] == (
        ce_full["cross_shot_id_substitution_rule"]
    ), "cross_shot_id_substitution_rule input-dependent (Override O-9 violated)"
    assert ce_empty["ref_usage_constraints"] == (
        ce_full["ref_usage_constraints"]
    ), "ref_usage_constraints input-dependent (Override O-9 violated)"
    assert ce_empty["view_consistency"] == ce_full["view_consistency"], (
        "view_consistency input-dependent (Override O-9 violated)"
    )
    # constraints list deep equality (builder-static — no input branching).
    assert ce_empty["constraints"] == ce_full["constraints"], (
        "constraints list input-dependent (Override O-9 violated)"
    )


# ──────────────────────────────────────────────────────────────────────────
# Test 35 — Override O-11 multi-ref atmosphere shot fixture filter.
# ──────────────────────────────────────────────────────────────────────────


def test_g4_4_view_mixing_canary_multi_ref_shot_filter() -> None:
    """test 35 — Override O-11 carry — multi-ref shot fixture (atmosphere +
    exact_background co-presence) — verify atmosphere_reference filter
    inclusion. The atmosphere_no_layout_import canary checks shots where
    `any(r.ref_usage == 'atmosphere_reference' for r in previous_shot_refs)`
    holds — even if exact_background also present.
    """
    multi_ref = [
        {"scene_index": 12, "shot_index": 2, "ref_usage": "exact_background"},
        {"scene_index": 12, "shot_index": 3, "ref_usage": "atmosphere_reference"},
    ]
    ce = _make_continuity(previous_shot_refs=multi_ref)
    # The previous_shot_refs are preserved verbatim (R1-I7 carry — full list,
    # no truncation).
    assert ce["previous_shot_refs"] == multi_ref, (
        "previous_shot_refs lost or transformed — R1-I7 lossless adapter "
        "violated"
    )
    # Override O-11 filter predicate — multi-ref shot includes
    # atmosphere_reference → canary applies atmosphere check.
    has_atmosphere = any(
        r.get("ref_usage") == "atmosphere_reference"
        for r in ce["previous_shot_refs"]
    )
    assert has_atmosphere, (
        "multi-ref shot fixture should include atmosphere_reference for "
        "Override O-11 canary filter coverage"
    )
    # ref_usage_constraints.atmosphere_reference sibling policy still present
    # (builder-static — Override O-9).
    assert "atmosphere_reference" in ce["ref_usage_constraints"]
    # forbidden_imports list len == 3 (count constant).
    fi = ce["ref_usage_constraints"]["atmosphere_reference"]["forbidden_imports"]
    assert len(fi) == _CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT


# ──────────────────────────────────────────────────────────────────────────
# Tests 36-38 — _card_metadata G4.4 4 신규 lift_status + 3 신규 rule_source
# keys + hash invariance (G4.2 R1-B1 / G4.3 R1-I11 carry).
# ──────────────────────────────────────────────────────────────────────────


def test_card_metadata_lift_status_includes_continuity_keys() -> None:
    """test 36 — Override O-9 — 4 G4.4 lift_status keys all present + True
    (canonical names — abbrev 금지)."""
    card = _make_full_card()
    lift_status = card["_card_metadata"]["lift_status"]
    for key in (
        "continuity_cross_shot_id_substitution_lifted",
        "continuity_ref_usage_constraints_lifted",
        "continuity_view_consistency_lifted",
        "continuity_constraints_extended",
    ):
        assert key in lift_status, (
            f"G4.4 lift_status key {key!r} missing — got "
            f"{sorted(lift_status.keys())!r}"
        )
        assert lift_status[key] is True, (
            f"G4.4 {key!r} expected True, got {lift_status[key]!r}"
        )


def test_card_metadata_rule_source_includes_continuity_keys() -> None:
    """test 37 — _card_metadata.rule_source 3 신규 G4.4 keys present:
    cross_shot_id_substitution_rule / ref_usage_rule / view_consistency_rule.
    """
    card = _make_full_card()
    rs = card["_card_metadata"]["rule_source"]
    for key in (
        "cross_shot_id_substitution_rule",
        "ref_usage_rule",
        "view_consistency_rule",
    ):
        assert key in rs, (
            f"G4.4 rule_source key {key!r} missing — got "
            f"{sorted(rs.keys())!r}"
        )


def test_card_metadata_excluded_from_hash_after_g4_4_keys_added() -> None:
    """test 38 — G4.2 R1-B1 + G4.3 R1-I11 carry — adding 4 G4.4 lift_status
    keys + mutating `_card_metadata.lift_status` does NOT change
    `compute_card_hash()` result (envelope-sibling hash exclusion)."""
    card_a = _make_full_card()
    h_a = compute_card_hash(card_a)
    card_b = copy.deepcopy(card_a)
    # Mutate G4.4-specific lift_status booleans (canonical names).
    card_b["_card_metadata"]["lift_status"][
        "continuity_cross_shot_id_substitution_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "continuity_ref_usage_constraints_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "continuity_view_consistency_lifted"
    ] = False
    card_b["_card_metadata"]["lift_status"][
        "continuity_constraints_extended"
    ] = False
    # Mutate G4.4 rule_source.
    card_b["_card_metadata"]["rule_source"][
        "cross_shot_id_substitution_rule"
    ] = "MUTATED"
    # Add an extra free-form key.
    card_b["_card_metadata"]["custom_g4_4_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.4 keys"
    )


# ──────────────────────────────────────────────────────────────────────────
# Tests 39-42 — _assert_continuity_elements_used_shape() strict validator
# (R4-I3 nested validator carry).
# ──────────────────────────────────────────────────────────────────────────


def test_assert_continuity_shape_validates_cross_shot_id_substitution_rule_shape() -> None:
    """test 39 — cross_shot_id_substitution_rule missing OR any of 6
    required keys missing → AppError (Override O-5 superset 차단)."""
    ce = _make_continuity()
    # Missing top-level dict.
    ce_missing = _strip_metadata_for_shape_check(ce, "cross_shot_id_substitution_rule")
    with pytest.raises(AppError):
        _assert_continuity_elements_used_shape(ce_missing, where="test")
    # Each of 6 required keys removed → AppError.
    for required_key in (
        "applies_when",
        "substitution",
        "double_description_forbidden",
        "preserve_pose_unchanged",
        "no_repeat_after_pose",
        "rationale_summary",
    ):
        ce_partial = copy.deepcopy(ce)
        ce_partial["cross_shot_id_substitution_rule"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_continuity_elements_used_shape(ce_partial, where="test")


def test_assert_continuity_shape_validates_ref_usage_constraints_shape() -> None:
    """test 40 — ref_usage_constraints dict missing / 3 sub-key missing /
    nested required keys missing → AppError (Risk Register R3-I3 nested
    validator carry)."""
    ce = _make_continuity()
    # Top-level missing.
    ce_missing = _strip_metadata_for_shape_check(ce, "ref_usage_constraints")
    with pytest.raises(AppError):
        _assert_continuity_elements_used_shape(ce_missing, where="test")
    # Each of 3 sub-keys removed → AppError.
    for sub_key in ("zoom_in_detail", "exact_background", "atmosphere_reference"):
        ce_partial = copy.deepcopy(ce)
        ce_partial["ref_usage_constraints"].pop(sub_key, None)
        with pytest.raises(AppError):
            _assert_continuity_elements_used_shape(ce_partial, where="test")
    # Nested key missing (zoom_in_detail.scope_summary) → AppError.
    ce_nested = copy.deepcopy(ce)
    ce_nested["ref_usage_constraints"]["zoom_in_detail"].pop("scope_summary", None)
    with pytest.raises(AppError):
        _assert_continuity_elements_used_shape(ce_nested, where="test")


def test_assert_continuity_shape_validates_view_consistency_shape() -> None:
    """test 41 — view_consistency dict missing → AppError (Override O-5
    superset top-level dict presence 차단)."""
    ce = _make_continuity()
    ce_missing = _strip_metadata_for_shape_check(ce, "view_consistency")
    with pytest.raises(AppError):
        _assert_continuity_elements_used_shape(ce_missing, where="test")


def test_assert_continuity_shape_validates_view_consistency_required_keys() -> None:
    """test 42 — view_consistency 9 required keys 중 1개라도 missing →
    AppError (Override O-5 superset)."""
    ce = _make_continuity()
    for required_key in (
        "framing_scope_options",
        "single_camera_rule",
        "third_person_handling",
        "close_up_handling",
        "focus_on_is_focus_area_not_view_switch",
        "subject_reference_policy_cross_ref",
        "prop_orientation_rule",
        "mixing_forbidden",
        "rationale_summary",
    ):
        ce_partial = copy.deepcopy(ce)
        ce_partial["view_consistency"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_continuity_elements_used_shape(ce_partial, where="test")


# ──────────────────────────────────────────────────────────────────────────
# Tests 43-46 — _assert_ref_usage_constraints_shape() nested helper
# (R4-I3 carry).
# ──────────────────────────────────────────────────────────────────────────


def test_assert_ref_usage_constraints_shape_zoom_in_detail_required_keys() -> None:
    """test 43 — zoom_in_detail 5 required keys 중 1개 missing → AppError."""
    ce = _make_continuity()
    # Area #1 W5 (2026-05-16): body_part_cross_ref → subject_reference_policy_cross_ref rename.
    for required_key in (
        "scope_summary",
        "forbidden_additions",
        "required_phrasings",
        "subject_reference_policy_cross_ref",
        "rationale_summary",
    ):
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["zoom_in_detail"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")


def test_assert_ref_usage_constraints_shape_exact_background_required_keys() -> None:
    """test 44 — exact_background 5 required keys 중 1개 missing → AppError."""
    ce = _make_continuity()
    for required_key in (
        "scope_summary",
        "permitted_additions",
        "background_consistency_rule",
        "ignore_keep_handled_elsewhere",
        "rationale_summary",
    ):
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["exact_background"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")


def test_assert_ref_usage_constraints_shape_atmosphere_reference_required_keys() -> None:
    """test 45 — atmosphere_reference 4 required keys 중 1개 missing → AppError."""
    ce = _make_continuity()
    for required_key in (
        "scope_summary",
        "forbidden_imports",
        "required_handling",
        "rationale_summary",
    ):
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["atmosphere_reference"].pop(required_key, None)
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")


def test_assert_ref_usage_constraints_shape_invalid_sub_key_raises() -> None:
    """test 46 — sub-key 가 dict 아닐 때 (None / [] / 'string') → AppError
    (silent absorb 차단 — `feedback_no_silent_fallback.md` carry)."""
    ce = _make_continuity()
    for invalid in (None, [], "string", 42):
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["zoom_in_detail"] = invalid
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["exact_background"] = invalid
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")
        ru = copy.deepcopy(ce["ref_usage_constraints"])
        ru["atmosphere_reference"] = invalid
        with pytest.raises(AppError):
            _assert_ref_usage_constraints_shape(ru, where="test")
