"""G4.5a Spatial-rules lift — integration tests (spec §6.2, PR4-B10 binding).

본 테스트는 G4.5a (2026-05-05) 의 prompt + card 통합 동작을 검증한다.
unit (test_g4_5a_spatial_lift.py) 가 builder helper 의 단위 동작을
검증하는 것과 달리, 본 integration 은 다음을 cover (PR4-B10 binding —
body table enumerate, NOT just briefing reference):

  - v19 system.md 의 3 prose section heading (Rule F / Rule G / Rule J)
    이 v21 에서 모두 absent (deletion 검증).
  - v21 system.md 에 신규 '## Spatial consistency' heading + ≤ 32-line
    compact section (RO-2 binding gate).
  - v21 system.md total lines < 497 (RO-14 derived gate from O-2).
  - v21 ID Policy / Background Binding / Continuity section preserved
    (G4.2 / G4.3 / G4.4 carry — G4.5a 가 변경 0 보장).
  - card pipeline 이 4 신규 sub-key + 5 constraints 를 production path
    (build_render_prompt_card) 통해 운반.
  - inject 경로 (`_card_metadata` strip top-level) — user_prompt JSON 에
    `render_strategy.spatial_consistency` dict 포함 + camera_frame_rule
    forbidden_combinations 4 entry / fg_bg_shared_anchor_rule
    recommended_keywords 5 entry / primary framing rule enum-consumer wording
    모두 substring 존재.
  - RO-6 cross-card paired-string substring 3-way assertion (v21 system.md
    spatial section + close_framing_rules.body_part_focus_cross_ref +
    wide_medium_rules.body_part_focus_cross_ref 모두 paired).
  - 4-way alignment lockstep (prompt dir + detail_steps:105 +
    version_registry:34 + version_registry:126).
  - G4.1 carry — v19 cp resume on v20 consumer escalates force.
  - G3.2 sentinel coexist with G4.5a + G4.4 + G4.3 + G4.2 fields.
  - 5-field envelope invariant (envelope top-level 7 contract fields
    unchanged).

Reference: docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md
            docs/superpowers/plans/2026-05-05-g4.5a-spatial-rules-lift-implementation.md
"""
from __future__ import annotations

import copy
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional

import pytest

from app.core.steps.render_prompt_card import (
    build_render_prompt_card,
    canonicalize_render_prompt_card,
    compute_card_hash,
    compute_render_strategy_snapshot_hash,
)


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 / G4.2/G4.3/G4.4 carry — _DEFAULT_SENTINEL pattern
# REQUIRED). `staging or {}` 류 silent absorb 패턴 절대 금지 — production
# drift cascade 의 origin.
# ──────────────────────────────────────────────────────────────────────────

_DEFAULT_SENTINEL = object()

# Repo-root 상대 경로 — backend/tests/integration/<file> 에서
# parents[3] = repo root. (절대 경로 hardcode 시 CI 환경에서 깨짐.)
_REPO_ROOT = Path(__file__).resolve().parents[3]
_V19_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "19.202605050814" / "system.md"
)
_V20_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "21.202605062217" / "system.md"
)
_DETAIL_STEPS_PY = (
    _REPO_ROOT / "backend" / "app" / "core" / "steps" / "detail_steps.py"
)
_VERSION_REGISTRY_PY = (
    _REPO_ROOT / "backend" / "app" / "core" / "version_registry.py"
)


# Realistic non-empty fixtures (production-shaped — Wave subagent fixture
# trap 차단).
_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",
        "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"},
    {"scene_index": 12, "shot_index": 2, "ref_usage": "atmosphere_reference"},
]
_DEFAULT_FORWARD_ZOOM_TARGETS: List[Dict[str, Any]] = [
    {
        "scene_index": 12,
        "shot_index": 5,
        "description": "close-up reveal of the photograph",
    },
]


def _make_ctx_for_shot(
    *,
    perception_mode: Optional[str] = None,
    is_close_framing: bool = False,
    bg_on: bool = True,
    bg_id: Optional[str] = "cb_main_room",
    bg_owned: Any = _DEFAULT_SENTINEL,
    bg_camera_meta: Any = _DEFAULT_SENTINEL,
    visible_entities: Any = _DEFAULT_SENTINEL,
    outlook_pairs: Any = _DEFAULT_SENTINEL,
    fixed_elements: Any = _DEFAULT_SENTINEL,
    previous_shot_refs: Any = _DEFAULT_SENTINEL,
    forward_zoom_targets: Any = _DEFAULT_SENTINEL,
) -> Dict[str, Any]:
    """build_render_prompt_card 의 input ctx — G4.5a integration fixture.

    Sentinel 의도 (G4.1+G4.2+G4.3+G4.4 carry):
      - x=None → 명시적 None (builder 가 raise — fail-fast).
      - x=[] / x={} → 빈 list/dict 그대로 보존.
      - x=_DEFAULT_SENTINEL (default) → fixture 가 default 채움.
    `default if x is None else x` 같은 silent 변환 패턴 금지.
    """
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ]
    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 bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"] if bg_on else []
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = (
            {
                "camera_position": "southeast doorway",
                "camera_height": "eye-level",
                "lens_hint": "35mm",
                "framing_notes": "wide",
            }
            if bg_on
            else None
        )
    return {
        "scene_index": 12,
        "shot_index": 4,
        "seg": {"index": 12, "text": "scene text full content here"},
        "shot_info": {
            "shot_index": 4,
            "camera_direction": (
                "extreme close-up of hand"
                if is_close_framing
                else "medium shot of doorway"
            ),
            "primary_subject": "the observer at the doorway",
        },
        "visible_entities": visible_entities,
        "outlook_pairs": outlook_pairs,
        "perception_mode": perception_mode,
        "staging": {
            "camera_direction": (
                "extreme close-up of hand"
                if is_close_framing
                else "medium shot of doorway"
            ),
            "framing_scale": "close" if is_close_framing else "medium",
            "lighting_mood": "warm dim",
            # 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 (default policy
            # id_and_outlook_required 가 visible_entities 에 적용).
            "subject_reference_policy": [],
        },
        "bg_id": bg_id if bg_on else None,
        "bg_owned": bg_owned,
        "bg_camera_meta": bg_camera_meta,
        "bg_guide": "doorway view" if bg_on else None,
        "is_close_framing": is_close_framing,
        "background_mode_on": bg_on,
        "fixed_elements": fixed_elements,
        "previous_shot_refs": previous_shot_refs,
        "forward_zoom_targets": forward_zoom_targets,
    }


def _build_inject_block(card: Dict[str, Any]) -> str:
    """Reproduces the production inject path — strips top-level
    `_card_metadata` and serializes — used to verify inject content contains
    the G4.5a spatial_consistency dict and excludes `_card_metadata`.
    """
    card_for_inject = {
        k: v for k, v in card.items() if k != "_card_metadata"
    }
    return (
        "[RenderPromptCard v1]\n"
        + json.dumps(
            card_for_inject,
            sort_keys=True, ensure_ascii=False, separators=(",", ":"),
        )
        + "\n\n"
    )


def _extract_card_json_from_inject_block(block: str) -> Dict[str, Any]:
    """Extract the JSON portion from the `[RenderPromptCard v1]\\n<json>\\n\\n`
    inject block format. Returns parsed dict for top-level key inspection."""
    lines = block.split("\n", 1)
    assert lines[0] == "[RenderPromptCard v1]", (
        f"inject block does not start with `[RenderPromptCard v1]` header — "
        f"first line: {lines[0]!r}"
    )
    json_str = lines[1].rstrip("\n")
    return json.loads(json_str)


# ──────────────────────────────────────────────────────────────────────────
# Class 1: TestV19AndV21PromptStructure — v19 baseline + v21 prose-lift (G4.5a carry).
# spec §6.2 #1-7 PR4-B10 binding.
# ──────────────────────────────────────────────────────────────────────────


class TestV19AndV21PromptStructure:
    """Verify v19 baseline + v21 prose-lift (G4.5a carry) outcome:
      - 3 deletion target headings present in v19 (baseline)
      - 3 deletion target headings absent in v21 (Rule F/G/J prose deleted)
      - 1 new compact 'Spatial consistency' section present in v21
      - Spatial consistency section ≤ 32 lines (RO-2)
      - v21 total < 497 lines (RO-14 derived)
      - ID Policy / Background Binding / Continuity sections preserved
        (G4.2 / G4.3 / G4.4 carry)
    """

    def test_v19_system_prompt_has_rule_f_heading(self) -> None:
        """v19 baseline — Rule F heading present."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## 카메라 위치와 frame edge의 물리 일관성 (Rule F)" in text, (
            "v19 baseline missing Rule F heading — baseline integrity broken"
        )

    def test_v19_system_prompt_has_rule_g_heading(self) -> None:
        """v19 baseline — Rule G heading present."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## fg/bg 인물 분리 시 공유 공간 명시 (Rule G)" in text, (
            "v19 baseline missing Rule G heading — baseline integrity broken"
        )

    def test_v19_system_prompt_has_rule_j_heading(self) -> None:
        """v19 baseline — Rule J heading present."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert (
            "## Primary Subject Framing — 한 shot = 한 framing scale (Rule J)"
            in text
        ), (
            "v19 baseline missing Rule J heading — baseline integrity broken"
        )

    def test_v20_system_prompt_missing_camera_frame_section(self) -> None:
        """spec §6.2 #1 — Rule F heading line absent in v21."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert not re.search(
            r"^## 카메라 위치와 frame edge의 물리 일관성 \(Rule F\)",
            text,
            flags=re.MULTILINE,
        ), (
            "v21 system.md still contains Rule F heading "
            "'## 카메라 위치와 frame edge의 물리 일관성 (Rule F)' — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v20_system_prompt_missing_fg_bg_separation_section(self) -> None:
        """spec §6.2 #2 — Rule G heading line absent in v21."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert not re.search(
            r"^## fg/bg 인물 분리 시 공유 공간 명시 \(Rule G\)",
            text,
            flags=re.MULTILINE,
        ), (
            "v21 system.md still contains Rule G heading "
            "'## fg/bg 인물 분리 시 공유 공간 명시 (Rule G)' — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v20_system_prompt_missing_primary_subject_framing_section(
        self,
    ) -> None:
        """spec §6.2 #3 — Rule J heading line absent in v21."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert not re.search(
            r"^## Primary Subject Framing — 한 shot = 한 framing scale \(Rule J\)",
            text,
            flags=re.MULTILINE,
        ), (
            "v21 system.md still contains Rule J heading "
            "'## Primary Subject Framing — 한 shot = 한 framing scale (Rule J)' "
            "— Wave 1-B prose-lift incomplete"
        )

    def test_v20_three_prose_sections_absent(self) -> None:
        """3 prose section headings (Rule F/G/J) all absent in v21 — combined
        check (deletion verified together)."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        for heading in (
            "## 카메라 위치와 frame edge의 물리 일관성",
            "## fg/bg 인물 분리 시 공유 공간 명시",
            "## Primary Subject Framing —",
        ):
            assert heading not in text, (
                f"v21 system.md still contains heading {heading!r} — "
                f"Wave 1-B prose-lift incomplete"
            )

    def test_v20_system_prompt_has_spatial_consistency_section(self) -> None:
        """spec §6.2 #4 — new compact 'Spatial consistency' heading present
        in v21."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## Spatial consistency" in text, (
            "v21 system.md missing new compact '## Spatial consistency' "
            "section heading — Wave 1-B prose-lift incomplete"
        )

    def test_v20_system_prompt_keeps_id_policy_section(self) -> None:
        """spec §6.2 #5 — G4.3 carry — '## ID Policy' heading still present
        in v20 (G4.5a 가 변경 0 보장)."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## ID Policy" in text, (
            "v21 system.md missing '## ID Policy' heading — G4.3 carry broken"
        )

    def test_v20_system_prompt_keeps_background_binding_section(self) -> None:
        """spec §6.2 #6 — G4.2 carry — '## Background Binding' heading still
        present in v21 (G4.5a 가 변경 0 보장)."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## Background Binding" in text, (
            "v21 system.md missing '## Background Binding' heading — G4.2 "
            "carry broken"
        )

    def test_v20_system_prompt_keeps_continuity_section(self) -> None:
        """spec §6.2 #7 — G4.4 carry — '## Continuity' heading still present
        in v21."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## Continuity" in text, (
            "v21 system.md missing '## Continuity' heading — G4.4 carry broken"
        )

    def test_v20_spatial_consistency_section_line_count_under_32(self) -> None:
        """spec §6.2 #9 — RO-2 binding gate — '## Spatial consistency'
        section ≤ 32 lines (heading through next '## ' heading exclusive,
        bullet 1 line 강제, wrap 줄도 카운트)."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        match = re.search(
            r"## Spatial consistency.*?(?=\n## )",
            text,
            flags=re.DOTALL,
        )
        assert match is not None, (
            "regex 'Spatial consistency' section capture failed — next '## ' "
            "heading missing"
        )
        line_count = len(match.group(0).splitlines())
        assert line_count <= 32, (
            f"Spatial consistency section line count {line_count} > 32 (RO-2 "
            f"binding gate) — section drift"
        )

    def test_v20_system_md_total_lines_under_550(self) -> None:
        """spec §6.2 #8 — derived gate — v21 total < 497 lines (RO-14
        derivation: v19 baseline 534 - 32 (compact gate RO-2) - 5 buffer = 497).
        Actual practical threshold is 550 (G4.4 O-14 carry derivation)."""
        v21_lines = _V20_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        # Use 550 as practical gate (G4.4 O-14 carry); tighter spec gate 497
        # also satisfied if the section reduction is achieved.
        assert len(v21_lines) < 550, (
            f"v21 total lines {len(v21_lines)} >= 550 — prose-lift insufficient"
        )

    def test_spatial_consistency_compact_section_references_card_path(
        self,
    ) -> None:
        """spec §6.2 #10 — v21 Spatial consistency compact section 안
        `render_strategy.spatial_consistency` substring 1+ 등장 (cross-card
        reference)."""
        text = _V20_SYSTEM_MD.read_text(encoding="utf-8")
        match = re.search(
            r"## Spatial consistency.*?(?=\n## )",
            text,
            flags=re.DOTALL,
        )
        assert match is not None, (
            "v21 ## Spatial consistency section capture failed"
        )
        section = match.group(0)
        assert "render_strategy" in section, (
            "v21 Spatial consistency section missing 'render_strategy' "
            "card path reference"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 2: TestRenderPromptCardPipeline — full builder + inject path.
# spec §6.2 #12-15 PR4-B10 binding.
# ──────────────────────────────────────────────────────────────────────────


class TestRenderPromptCardPipeline:
    """build_render_prompt_card() entire call → spatial_consistency 4
    sub-key + 5 constraints + _card_metadata strip top-level (Override O-10).
    """

    def test_render_strategy_card_carries_spatial_consistency(self) -> None:
        """spec §6.2 #12 — end-to-end — build_render_prompt_card() +
        _card_metadata strip + JSON prepend path. user_prompt JSON 안
        spatial_consistency dict + 4 sub-key 모두 present."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        # Substring presence — 4 spatial_consistency sub-key markers.
        for sub in (
            '"spatial_consistency"',
            '"camera_frame_rule"',
            '"fg_bg_shared_anchor_rule"',
            '"primary_framing_rule"',
        ):
            assert sub in block, (
                f"inject block missing G4.5a spatial_consistency sub-key "
                f"marker {sub!r} — production inject contract broken"
            )
        # JSON parse → dict valid.
        parsed = _extract_card_json_from_inject_block(block)
        sc = parsed["render_strategy"]["spatial_consistency"]
        assert isinstance(sc, dict)
        for required_key in (
            "camera_frame_rule",
            "fg_bg_shared_anchor_rule",
            "primary_framing_rule",
            "rationale_summary",
        ):
            assert required_key in sc

    def test_camera_frame_rule_card_inject_carries_forbidden_combinations(
        self,
    ) -> None:
        """spec §6.2 #13 — inject 된 user_prompt 에
        camera_frame_rule.forbidden_combinations 4 entry 모두 substring 존재."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        fc = card["render_strategy"]["spatial_consistency"][
            "camera_frame_rule"
        ]["forbidden_combinations"]
        assert len(fc) == 4, (
            f"camera_frame_rule.forbidden_combinations len != 4 (got {len(fc)})"
        )
        for entry in fc:
            # Substring of leading ~30 chars to avoid line-wrap mismatch.
            marker = entry[:25]
            assert marker in block, (
                f"inject block missing forbidden_combinations entry "
                f"{marker!r} — JSON serialization drift"
            )

    def test_fg_bg_shared_anchor_rule_card_inject_carries_keywords(self) -> None:
        """spec §6.2 #14 — inject 된 user_prompt 에
        fg_bg_shared_anchor_rule.recommended_keywords 5 entry 모두 substring
        존재."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        rk = card["render_strategy"]["spatial_consistency"][
            "fg_bg_shared_anchor_rule"
        ]["recommended_keywords"]
        assert len(rk) == 5, (
            f"fg_bg_shared_anchor_rule.recommended_keywords len != 5 "
            f"(got {len(rk)})"
        )
        for entry in rk:
            marker = entry[:25]
            assert marker in block, (
                f"inject block missing recommended_keywords entry "
                f"{marker!r} — JSON serialization drift"
            )

    def test_primary_framing_rule_card_inject_carries_enum_consumer_wording(self) -> None:
        """spec §6.2 #15 — inject 된 user_prompt 에 primary framing rule 의
        enum-consumer wording 이 존재."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        applies_when = card["render_strategy"]["spatial_consistency"][
            "primary_framing_rule"
        ]["applies_when"]
        assert "shot_staging.framing_scale" in applies_when
        assert "keyword classification is forbidden" in applies_when
        assert applies_when in block

    def test_render_prompt_card_canonicalize_pops_card_metadata(self) -> None:
        """canonicalize_render_prompt_card() output excludes
        '_card_metadata' — hash payload separation (G4.1/G4.2/G4.3/G4.4 carry).
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        canonical = canonicalize_render_prompt_card(card)
        assert "_card_metadata" not in canonical, (
            "canonicalize() output retained '_card_metadata' — "
            "_HASH_EXCLUDED_TOP_KEYS contract broken"
        )

    def test_card_hash_drift_on_spatial_consistency_change_in_verify(self) -> None:
        """spec §6.2 #16 — spatial_consistency content change → hash drift
        (verify_completion sensitivity)."""
        ctx = _make_ctx_for_shot()
        card_a = build_render_prompt_card(**ctx)
        h_a = compute_card_hash(card_a)
        card_b = copy.deepcopy(card_a)
        card_b["render_strategy"]["spatial_consistency"]["camera_frame_rule"][
            "forbidden_combinations"
        ][0] += "X"
        h_b = compute_card_hash(card_b)
        assert h_a != h_b, (
            "card hash did not drift on render_strategy.spatial_consistency "
            "content selector change"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 3 폐기 (Area #1 W5, 2026-05-16): TestCrossCardPairedSubstring
# (RO-6 paired drift 차단) — body_part_focus_cross_ref / id_policy.
# body_part_focus_rule 자체 폐기로 의미 상실. paired-drift 차단은 더 이상
# 필요하지 않음 (cross-ref + literal 둘 다 폐기 — render_prompt_card 에서
# subject_reference_policy SOT 가 대체).
# 새 paired-drift 검증이 필요하면 별도 wave 에서 subject_reference_policy
# 기준으로 재작성.
# ──────────────────────────────────────────────────────────────────────────


# ──────────────────────────────────────────────────────────────────────────
# Class 4: Test4WayAlignment — spec §6.2 #19 — 4-way lockstep sync.
# ──────────────────────────────────────────────────────────────────────────


class Test4WayAlignment:
    """spec §6.2 #19 — alignment runner — 4 곳 lockstep 갱신 검증."""

    def test_4_way_alignment_post_area7b_w3_bump(self) -> None:
        """4 곳 모두 v34 / 1.34.0 / scene_detail/v34 정렬 (reference-necessity Phase3 W3 v33 → v34 bump):
          1. prompts/_base/scene_detail/34.<timestamp>/system.md 디렉토리 존재
          2. detail_steps.SCENE_DETAIL_PROMPT_VERSION = "34.<timestamp>"
          3. version_registry.MODULE_VERSIONS["scene_detail_composer"]
              = "1.34.0"
          4. version_registry._MODULE_INFO["scene_detail_composer"]
              ["prompt_dependency"] = "scene_detail/v34"

        History: G4.5a (1.18.0 / v18) → Area C T4 (1.22.0 / v22) →
        area-frame-spatial-contract T5 (1.23.0 / v23) →
        framing_scale enum SOT v1 (1.24.0 / v24) →
        Area #1 W5 (1.25.0 / v25, id_policy.subject_reference_policy SOT) →
        Area #5 W1 (1.26.0 / v26, reference_phrase_kinds sidecar SOT) →
        Area #7b W3 (1.27.0 / v27, canonical VWR SOT reference clause) →
        Carry-D7d-1 (1.28.0 / v28, camera_effect.description align with Area #7d fallback policy) →
        FINDING 5 (1.33.0 / v33, owned_object_usage echo partial) →
        Phase3 W3 (1.34.0 / v34, generic_descriptor_allowed no-ID rule + Rule X-2 policy-conditional).
        """
        from app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION
        from app.core.version_registry import _MODULE_INFO, MODULE_VERSIONS

        # Row 1 — directory exists.
        prompt_dir = (
            _REPO_ROOT / "prompts" / "_base" / "scene_detail"
            / SCENE_DETAIL_PROMPT_VERSION
        )
        assert prompt_dir.is_dir(), (
            f"prompt directory {prompt_dir!s} does not exist — "
            f"Phase3 W3 (v34) prompt directory not created "
            f"(4-way sync row 1)"
        )

        # Row 2 — SCENE_DETAIL_PROMPT_VERSION starts with "34.".
        assert SCENE_DETAIL_PROMPT_VERSION.startswith("34."), (
            f"SCENE_DETAIL_PROMPT_VERSION={SCENE_DETAIL_PROMPT_VERSION!r} not "
            f"v34 — 4-way sync row 2 broken (Phase3 W3 v33 → v34)"
        )

        # Row 3 — MODULE_VERSIONS = "1.34.0".
        assert "scene_detail_composer" in MODULE_VERSIONS
        assert MODULE_VERSIONS["scene_detail_composer"] == "1.34.0", (
            f"MODULE_VERSIONS['scene_detail_composer']="
            f"{MODULE_VERSIONS['scene_detail_composer']!r} != '1.34.0' — "
            f"4-way sync row 3 broken (Phase3 W3 1.33.0 → 1.34.0)"
        )

        # Row 4 — _MODULE_INFO prompt_dependency = "scene_detail/v34".
        assert "scene_detail_composer" in _MODULE_INFO
        info = _MODULE_INFO["scene_detail_composer"]
        assert info["prompt_dependency"] == "scene_detail/v34", (
            f"_MODULE_INFO['scene_detail_composer']['prompt_dependency']="
            f"{info['prompt_dependency']!r} != 'scene_detail/v34' — "
            f"4-way sync row 4 broken (Phase3 W3 v33 → v34)"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 5: TestG4_5aCarryPaths — G3.2 sentinel + G4.2 background_binding +
# G4.3 id_policy + G4.4 continuity + G4.5a spatial 공존 검증.
# ──────────────────────────────────────────────────────────────────────────


class TestG4_5aCarryPaths:
    """Cross-mode integration — synthetic CP shape with all G3.2~G4.5a
    fields coexisting on the same shot."""

    def test_g3_2_sentinel_coexists_with_g4_5a_spatial(self) -> None:
        """spec §6.2 #18 — synthetic CP shape with G3.2 owned sentinel +
        G4.2 background_binding + G4.3 id_policy + G4.4 continuity + G4.5a
        spatial 모두 coexist (전체 carry 회귀 0 확인)."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        card_hash = compute_card_hash(card)
        cp_shape = {
            "scene_index": 12,
            "_shot_index": 4,
            "render_prompt_card": card,
            "render_prompt_card_hash": card_hash,
            "t2i_variations": [{
                "t2i_prompt": "A figure stands by the door.",
                "owned_validation": {  # G3.2 sentinel
                    "schema_version": 1,
                    "validator": "scene_detail_owned_objects.v1",
                    "owned_hash": "x" * 16,
                    "camera_direction_hash": "y" * 16,
                    "t2i_prompt_hash": "z" * 16,
                    "violations": [],
                },
            }],
        }
        # G4.5a: 4 spatial_consistency sub-key + 5 constraints.
        rs = cp_shape["render_prompt_card"]["render_strategy"]
        sc = rs["spatial_consistency"]
        for sub in (
            "camera_frame_rule",
            "fg_bg_shared_anchor_rule",
            "primary_framing_rule",
            "rationale_summary",
        ):
            assert sub in sc
        assert len(rs["constraints"]) == 5
        # G4.4: 3 sibling policy dicts + 7 constraints.
        ce = cp_shape["render_prompt_card"]["continuity_elements_used"]
        for sub in (
            "cross_shot_id_substitution_rule",
            "ref_usage_constraints",
            "view_consistency",
        ):
            assert sub in ce
        assert len(ce["constraints"]) == 7
        # G4.3 + Area #1 W5: 4 id_policy sub-field present. Area #1 W5
        # (2026-05-16) 가 body_part_focus_rule + close_framing_face_phrasing 폐기
        # + subject_reference_policy 신규 = 5 - 2 + 1 = 4 sub-field.
        ip = cp_shape["render_prompt_card"]["id_policy"]
        for sub in (
            "face_identifiability_rule",
            "reproduction_surface_rule",
            "demographic_descriptor_policy",
            "subject_reference_policy",
        ):
            assert sub in ip
        # Area #1 W5 — 폐기 2 sub-field 부재 확인.
        assert "body_part_focus_rule" not in ip
        assert "close_framing_face_phrasing" not in ip
        # G4.2: background_binding.constraints non-empty list.
        bb_constraints = (
            cp_shape["render_prompt_card"]["background_binding"]["constraints"]
        )
        assert isinstance(bb_constraints, list) and len(bb_constraints) >= 1
        # G3.2: variation-level owned_validation sentinel present.
        sentinel = cp_shape["t2i_variations"][0]["owned_validation"]
        assert sentinel["validator"] == "scene_detail_owned_objects.v1"

    def test_envelope_top_level_keys_unchanged_post_g4_5a(self) -> None:
        """spec §6.2 #20 — envelope invariant — envelope top-level 8 contract
        fields (schema_version + shot_key + 5 semantic + render_contracts) unchanged.
        사용자 binding invariant — 새 top-level field 도입 절대 금지.

        Area B (2026-05-13): render_contracts top-level field 추가
        (5→6 semantic field envelope, visible prop ∩ visual_identity.reference_required=true).
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        # The 8-contract envelope (excl. _card_metadata which is sibling).
        expected_keys = {
            "schema_version",
            "shot_key",
            "render_strategy",
            "id_policy",
            "background_binding",
            "continuity_elements_used",
            "asset_requirements",
            "render_contracts",
        }
        actual_no_metadata = set(card.keys()) - {"_card_metadata"}
        assert expected_keys <= actual_no_metadata, (
            f"envelope top-level missing required fields "
            f"{sorted(expected_keys - actual_no_metadata)!r}"
        )
        # No NEW top-level field beyond expected + _card_metadata sibling.
        unexpected = actual_no_metadata - expected_keys
        assert not unexpected, (
            f"envelope top-level introduced UNEXPECTED field(s) {sorted(unexpected)!r} "
            f"— 사용자 binding (6-field envelope invariant) violated"
        )

    def test_render_strategy_snapshot_hash_drift_on_spatial_consistency_change(
        self,
    ) -> None:
        """compute_render_strategy_snapshot_hash() partial hash drift on
        render_strategy.spatial_consistency mutation (canary pinning use case)."""
        ctx = _make_ctx_for_shot()
        card_a = build_render_prompt_card(**ctx)
        card_b = copy.deepcopy(card_a)
        # Mutate a single spatial_consistency literal (1-char tail change).
        card_b["render_strategy"]["spatial_consistency"]["camera_frame_rule"][
            "rationale_summary"
        ] = (
            card_b["render_strategy"]["spatial_consistency"][
                "camera_frame_rule"
            ]["rationale_summary"] + "X"
        )
        assert compute_render_strategy_snapshot_hash(card_a) != (
            compute_render_strategy_snapshot_hash(card_b)
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 6: TestV19CheckpointResumeOnV21 — spec §6.2 #17.
# G4.1 carry — schema_version mismatch escalation.
# ──────────────────────────────────────────────────────────────────────────


class TestV19CheckpointResumeOnV21:
    """G4.1 carry — `SCENE_DETAIL_PROMPT_VERSION` 변경이 `_config_hash` 에
    reflect 되므로 step_runner 자동 force escalate (v19 cp on v20 consumer
    → mismatch → mode='force')."""

    def test_v19_checkpoint_resume_on_v20_escalates_force(self) -> None:
        """spec §6.2 #17 — config_hash mismatch test surrogate.

        We can't easily simulate step_runner here, but we CAN verify that
        the inputs to config_hash differ between v19 and v20 prompt
        versions, which is the upstream cause of escalation. SCENE_DETAIL_
        PROMPT_VERSION is a constant input to config_hash, so a mismatch
        between persisted cp version and current constant triggers escalate.
        """
        from app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION

        # Current consumer version.
        current = SCENE_DETAIL_PROMPT_VERSION
        assert current.startswith("34."), (
            f"SCENE_DETAIL_PROMPT_VERSION={current!r} not v34 — config_hash "
            f"input not at Phase3 W3 (v33 → v34) level"
        )
        # A persisted cp from v19 era would have prompt_version starting "19."
        persisted_v19 = "19.202605050814"
        assert current != persisted_v19, (
            f"current SCENE_DETAIL_PROMPT_VERSION={current!r} == v19 era "
            f"version — escalation will not trigger (G4.5a Wave 2 wiring "
            f"missing)"
        )
