"""G4.4 Continuity lift — integration tests (spec §6.2).

본 테스트는 G4.4 (2026-05-05) 의 prompt + card 통합 동작을 검증한다.
unit (test_g4_4_continuity_lift.py) 가 builder helper 의 단위 동작을
검증하는 것과 달리, 본 integration 은 다음을 cover:

  - v18 system.md 의 3 prose section heading (Section A/B/C —
    교차 샷 고정 요소 통합 규칙 / 앞쪽 참조 샷 처리 — ref_usage 유형별
    지시 / 절대 규칙 — 단일 시점) heading 존재 (baseline)
    + v19 에서 모두 absent (Section A/B/C 삭제 검증).
  - v19 system.md 에 신규 '## Continuity (cross-shot substitution +
    ref_usage + single-view)' heading + ≤ 32-line compact section
    (Override O-2 binding gate).
  - v19 system.md total lines ≤ 550 (Override O-14 derived gate
    587 - 32 - 5 = 550).
  - v19 ID Policy / Background Binding section preserved (G4.3 / G4.2
    carry — G4.4 가 변경 0 보장).
  - detail_steps.py inline zoom_in_detail prose 9 줄 + has_zoom_in_detail
    변수 모두 absent (Override O-3 — Wave 2 Task 3.3 deletion 검증).
  - card pipeline 이 3 신규 sibling policy dicts + 7 constraints 를
    production path (build_render_prompt_card) 통해 운반.
  - inject 경로 (`_card_metadata` strip top-level) — user_prompt JSON 에
    top-level `_card_metadata` key absent (Override O-10 carry).
  - Override O-7 cross-card paired-string substring v19 system.md 와
    user_prompt JSON 양쪽에 모두 등장 (paired drift 차단).

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
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_continuity_snapshot_hash,
)


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 / G4.2 plan-R1-I4 / G4.3 plan-R1-I4 carry —
# _DEFAULT_SENTINEL pattern REQUIRED).
# `fixed_elements or []` / `previous_shot_refs 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]
_V18_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "18.202605041549" / "system.md"
)
_V19_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "19.202605050814" / "system.md"
)
_DETAIL_STEPS_PY = (
    _REPO_ROOT / "backend" / "app" / "core" / "steps" / "detail_steps.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.4 integration fixture.

    Sentinel 의도 (G4.1+G4.2+G4.3 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 enum SOT v1 (2026-05-15): helper read fail-fast
            # 정합. is_close_framing 인자 따라 enum 분기.
            "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 from `detail_steps.py:1966-1977`.
    Strips top-level `_card_metadata` and serializes — used to verify inject
    content contains the G4.4 3 sibling policy dicts 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
    (e.g. `_card_metadata` absence)."""
    # Inject block format: `[RenderPromptCard v1]\n<json>\n\n`
    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: TestV18AndV19PromptStructure — v18 baseline + v19 prose-lift
# verification (spec §6.2 substring presence/absence).
# ──────────────────────────────────────────────────────────────────────────


class TestV18AndV19PromptStructure:
    """Verify v18 baseline + v19 prose-lift outcome:
      - 3 deletion target headings present in v18 (baseline)
      - 3 deletion target headings absent in v19 (Sections A/B/C deleted)
      - 1 new compact 'Continuity' section present in v19
      - Continuity section ≤ 32 lines (Override O-2)
      - v19 total ≤ 550 lines (Override O-14)
      - ID Policy / Background Binding sections preserved (G4.3 / G4.2 carry)
    """

    def test_v18_system_prompt_has_section_a_cross_shot_heading(self) -> None:
        """spec §6.2 #1 — v18 baseline — Section A heading present."""
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## 교차 샷 고정 요소 통합 규칙" in text, (
            "v18 baseline missing Section A heading — baseline integrity "
            "broken (cannot validate v19 deletion)"
        )

    def test_v18_system_prompt_has_section_b_ref_usage_heading(self) -> None:
        """spec §6.2 #2 — v18 baseline — Section B heading present."""
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## 앞쪽 참조 샷 처리 — ref_usage 유형별 지시" in text, (
            "v18 baseline missing Section B heading — baseline integrity "
            "broken (cannot validate v19 deletion)"
        )

    def test_v18_system_prompt_has_section_c_single_view_heading(self) -> None:
        """spec §6.2 #3 — v18 baseline — Section C heading present."""
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## 절대 규칙 — 단일 시점" in text, (
            "v18 baseline missing Section C heading — baseline integrity "
            "broken (cannot validate v19 deletion)"
        )

    def test_v19_system_prompt_missing_section_a(self) -> None:
        """spec §6.2 #4 — Override O-10 inclusive — Section A heading line
        absent (heading-line check, not body-text reference)."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        # Heading-line check: `^## 교차 샷 ...` line start.
        assert not re.search(
            r"^## 교차 샷 고정 요소 통합 규칙",
            text,
            flags=re.MULTILINE,
        ), (
            "v19 system.md still contains Section A heading "
            "'## 교차 샷 고정 요소 통합 규칙' — Wave 1-B prose-lift incomplete"
        )

    def test_v19_system_prompt_missing_section_b(self) -> None:
        """spec §6.2 #5 — Section B heading line absent."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert not re.search(
            r"^## 앞쪽 참조 샷 처리 — ref_usage 유형별 지시",
            text,
            flags=re.MULTILINE,
        ), (
            "v19 system.md still contains Section B heading "
            "'## 앞쪽 참조 샷 처리 — ref_usage 유형별 지시' — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v19_system_prompt_missing_section_c(self) -> None:
        """spec §6.2 #6 — Section C heading line absent."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert not re.search(
            r"^## 절대 규칙 — 단일 시점",
            text,
            flags=re.MULTILINE,
        ), (
            "v19 system.md still contains Section C heading "
            "'## 절대 규칙 — 단일 시점' — Wave 1-B prose-lift incomplete"
        )

    def test_v19_system_prompt_has_continuity_compact_heading(self) -> None:
        """spec §6.2 #7 — new compact 'Continuity' heading present."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert (
            "## Continuity (cross-shot substitution + ref_usage + single-view)"
            in text
        ), (
            "v19 system.md missing new compact 'Continuity' section heading "
            "— Wave 1-B prose-lift incomplete"
        )

    def test_v19_continuity_section_line_count_under_32(self) -> None:
        """spec §6.2 #8 — Override O-2 binding gate — '## Continuity' section
        ≤ 32 lines (heading through next '## ' heading exclusive)."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        match = re.search(
            r"## Continuity.*?(?=\n## )",
            text,
            flags=re.DOTALL,
        )
        assert match is not None, (
            "regex 'Continuity' section capture failed — next '## ' "
            "heading missing"
        )
        line_count = len(match.group(0).splitlines())
        assert line_count <= 32, (
            f"Continuity section line count {line_count} > 32 (Override O-2 "
            f"binding gate) — section drift"
        )

    def test_v19_system_md_total_lines_under_550(self) -> None:
        """spec §6.2 #9 — Override O-14 derived gate — v19 total < 550 lines.
        Derivation: v18 baseline 587 - 32 (compact gate O-2) - 5 (buffer)."""
        v19_lines = _V19_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        assert len(v19_lines) < 550, (
            f"v19 total lines {len(v19_lines)} >= 550 (Override O-14 derived "
            f"gate) — prose-lift insufficient"
        )

    def test_v19_id_policy_section_preserved_unchanged(self) -> None:
        """spec §6.2 #10 — G4.3 carry — '## ID Policy' heading still present
        in v19 (G4.4 가 변경 0 보장)."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## ID Policy" in text, (
            "v19 system.md missing '## ID Policy' heading — G4.3 carry broken"
        )

    def test_v19_background_binding_section_preserved_unchanged(self) -> None:
        """spec §6.2 #11 — G4.2 carry — '## Background Binding' heading still
        present in v19 (G4.4 가 변경 0 보장)."""
        text = _V19_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## Background Binding" in text, (
            "v19 system.md missing '## Background Binding' heading — G4.2 "
            "carry broken"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 2: TestDetailStepsInlineDeletion — Override O-3 carry — Wave 2
# inline zoom_in_detail prose deletion verification.
# ──────────────────────────────────────────────────────────────────────────


class TestDetailStepsInlineDeletion:
    """Verify Override O-3 — detail_steps.py 안 inline zoom_in_detail prose
    9 줄 + has_zoom_in_detail 변수 정의/사용 모두 dead code 제거."""

    def test_detail_steps_zoom_in_detail_inline_prose_absent_after_g4_4(
        self,
    ) -> None:
        """spec §6.2 #12 — Override O-3 — inline prose 9-line block absent
        in detail_steps.py."""
        text = _DETAIL_STEPS_PY.read_text(encoding="utf-8")
        # 9-line prepend block markers.
        assert "[중요 — 확대 샷 원칙 (zoom_in_detail)]" not in text, (
            "detail_steps.py still contains '[중요 — 확대 샷 원칙 "
            "(zoom_in_detail)]' inline prose marker — Wave 2 Task 3.3 "
            "deletion incomplete"
        )
        assert "확대 샷 원칙" not in text, (
            "detail_steps.py still contains '확대 샷 원칙' inline prose "
            "substring — Wave 2 Task 3.3 deletion incomplete"
        )
        assert "새 인물·소품·자세 추가 금지" not in text, (
            "detail_steps.py still contains '새 인물·소품·자세 추가 금지' "
            "substring — Wave 2 Task 3.3 deletion incomplete"
        )
        # `if has_zoom_in_detail:` 9 line prepend block absent.
        assert "if has_zoom_in_detail:" not in text, (
            "detail_steps.py still contains `if has_zoom_in_detail:` block — "
            "Wave 2 Task 3.3 deletion incomplete"
        )

    def test_detail_steps_has_zoom_in_detail_variable_no_dead_uses(
        self,
    ) -> None:
        """spec §6.2 #13 — Override O-3 maintainer carry — has_zoom_in_detail
        variable definition + uses 모두 dead code 제거."""
        text = _DETAIL_STEPS_PY.read_text(encoding="utf-8")
        assert "has_zoom_in_detail" not in text, (
            "detail_steps.py still references `has_zoom_in_detail` variable "
            "— Wave 2 Task 3.3 dead code cleanup incomplete (Override O-3)"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 3: TestRenderPromptCardPipeline — full builder + inject path.
# spec §6.2 #14-16 — 3 sibling policy dicts + 7 constraints + _card_metadata
# strip top-level.
# ──────────────────────────────────────────────────────────────────────────


class TestRenderPromptCardPipeline:
    """build_render_prompt_card() entire call → 3 G4.4 sibling policy dicts
    + 7 constraints + _card_metadata strip top-level (Override O-10).
    """

    def test_continuity_card_to_scene_detail_user_prompt_inject_3_sibling_dicts(
        self,
    ) -> None:
        """spec §6.2 #14 — end-to-end — _collect_card_inputs() +
        build_render_prompt_card() + _card_metadata strip + JSON prepend
        path. user_prompt JSON 안 3 sibling policy dict 모두 present.
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        # Substring presence — sibling policy dict key markers in JSON.
        for sub in (
            '"cross_shot_id_substitution_rule"',
            '"ref_usage_constraints"',
            '"view_consistency"',
        ):
            assert sub in block, (
                f"inject block missing G4.4 sibling policy dict {sub!r} — "
                f"production inject contract broken"
            )

    def test_continuity_card_to_scene_detail_user_prompt_inject_constraints_7_strings(
        self,
    ) -> None:
        """spec §6.2 #15 — user_prompt JSON 안 constraints len == 7 +
        constraint[0] G4.1 verbatim string equality + constraint[1..6]
        substring 검증."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        ce = card["continuity_elements_used"]
        # 7 strings.
        assert len(ce["constraints"]) == 7
        # constraint[0] G4.1 verbatim.
        expected_c0 = (
            "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_c0
        # constraint[1] cross_shot_id_substitution inline.
        assert "replace the common-noun person reference" in ce["constraints"][1]
        # constraint[2] zoom_in_detail inline.
        assert "ref_usage='zoom_in_detail'" in ce["constraints"][2]
        # constraint[3] exact_background inline.
        assert "ref_usage='exact_background'" in ce["constraints"][3]
        # constraint[4] atmosphere_reference inline.
        assert "ref_usage='atmosphere_reference'" in ce["constraints"][4]
        # constraint[5] single_camera + subject_reference_policy xref
        # (Area #1 W5: paired literal value rename body_part_focus_rule →
        # subject_reference_policy).
        assert "one t2i_prompt = one camera position" in ce["constraints"][5]
        assert "id_policy.subject_reference_policy" in ce["constraints"][5]
        # constraint[6] prop_orientation.
        assert "one direction only" in ce["constraints"][6]

    def test_continuity_card_to_scene_detail_user_prompt_inject_card_metadata_stripped(
        self,
    ) -> None:
        """spec §6.2 #16 — Override O-10 carry — `_card_metadata` is envelope
        TOP-LEVEL sibling (NOT nested under `continuity_elements_used`).
        user_prompt JSON parsed dict's top-level absent (R3-I1 path
        correction)."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        # Substring level (G4.2 / G4.3 carry).
        assert '"_card_metadata"' not in block, (
            "inject block leaked '_card_metadata' substring — strip in "
            "production inject path broken (detail_steps.py:1966)"
        )
        # Parse JSON and verify top-level key absence (R3-I1 path correction).
        parsed_card = _extract_card_json_from_inject_block(block)
        assert "_card_metadata" not in parsed_card, (
            "parsed inject card JSON contains top-level '_card_metadata' "
            "— top-level strip contract broken"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 4: TestCrossCardPairedSubstring — Override O-7 paired drift 차단.
# spec §6.2 #17.
# ──────────────────────────────────────────────────────────────────────────


class TestCrossCardPairedSubstring:
    """Cross-card paired-string substring drift 차단 — Area #1 W5 (2026-05-16)
    rename 정합: 옛 v19 system.md `id_policy.body_part_focus_rule` →
    v25 system.md `id_policy.subject_reference_policy` (cross-ref key 도 rename:
    id_policy_cross_ref_for_body_part_focus → subject_reference_policy_cross_ref,
    body_part_cross_ref → subject_reference_policy_cross_ref).

    원본 test 는 v19 시점 paired-drift 차단. Area #1 W5 이후 paired literal
    + key 모두 폐기/rename — 본 test 도 새 literal/key 로 rewrite (의미 보존:
    cross-card paired drift 차단).
    """

    def test_cross_card_paired_substring_in_v19_system_md_and_card_inject(
        self,
    ) -> None:
        """Area #1 W5: paired-string drift 차단 (rewritten — v25 active prompt
        + new subject_reference_policy_cross_ref key 검증).
        """
        # Area #1 W5: card cross-card paired-string side 만 검증
        # (system.md 의 v25 spatial section 안 paired literal 검증은
        # test_scene_detail_continuity_alignment 가 cover).
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        ce = card["continuity_elements_used"]
        LITERAL = "id_policy.subject_reference_policy"
        # view_consistency side.
        xref_view = ce["view_consistency"][
            "subject_reference_policy_cross_ref"
        ]
        assert LITERAL in xref_view, (
            f"view_consistency.subject_reference_policy_cross_ref missing "
            f"paired literal {LITERAL!r} — paired drift"
        )
        # ref_usage_constraints.zoom_in_detail side.
        xref_ref = ce["ref_usage_constraints"]["zoom_in_detail"][
            "subject_reference_policy_cross_ref"
        ]
        assert LITERAL in xref_ref, (
            f"ref_usage_constraints.zoom_in_detail.subject_reference_policy_cross_ref "
            f"missing paired literal {LITERAL!r} — paired drift"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 5: TestG4_4CarryPaths — G3.2 sentinel + G4.2 background_binding +
# G4.3 id_policy + G4.4 continuity_elements_used 공존 검증.
# ──────────────────────────────────────────────────────────────────────────


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

    def test_render_prompt_card_inject_block_carries_3_continuity_subfields(
        self,
    ) -> None:
        """Production inject path → user_prompt 안 3 신규 sibling policy
        dict substring 모두 포함."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        for sub in (
            '"cross_shot_id_substitution_rule"',
            '"ref_usage_constraints"',
            '"view_consistency"',
        ):
            assert sub 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 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_continuity_constraint_change(self) -> None:
        """G4.4 content selector — continuity_elements_used.constraints
        content change → hash drift."""
        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)
        constraints = card_b["continuity_elements_used"]["constraints"]
        # Content selector — verbatim G4.1 carry constraint[0] prefix.
        idx = next(
            (i for i, c in enumerate(constraints)
             if "fixed_elements are continuity inputs" in c),
            None,
        )
        assert idx is not None
        constraints[idx] = "MUTATED — drift sentinel string"
        h_b = compute_card_hash(card_b)
        assert h_a != h_b, (
            "card hash did not drift on continuity_elements_used.constraints "
            "content selector change — verify_completion would mark this clean"
        )

    def test_continuity_snapshot_hash_drift_on_continuity_change(self) -> None:
        """compute_continuity_snapshot_hash() partial hash drift on
        continuity_elements_used 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 continuity sub-field literal (1-char tail change).
        ce = card_b["continuity_elements_used"]
        ce["cross_shot_id_substitution_rule"]["substitution"] = (
            ce["cross_shot_id_substitution_rule"]["substitution"] + "X"
        )
        assert compute_continuity_snapshot_hash(card_a) != (
            compute_continuity_snapshot_hash(card_b)
        )

    def test_g3_2_g4_2_g4_3_coexist_with_g4_4_continuity(self) -> None:
        """spec §6.2 #20 carry — synthetic CP shape with G3.2 owned sentinel
        + G4.1+G4.2 background_binding + G4.3 id_policy + G4.4 continuity 모두
        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.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
        # (body_part_focus_rule + close_framing_face_phrasing 폐기 + subject_reference_policy 신규).
        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 폐기 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"
