"""G4.3 ID-policy lift — integration tests (spec §6.2).

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

  - v18 system.md 의 4 prose section (composite ID / 극단 close-up face /
    reproduction surface / Rule H demographic) 제거 + 신규 'ID Policy'
    compact section 존재 (spec §6.2 grep checks).
  - v18 line count 감소 vs v17 (prose lift 의 line-count 영향).
  - line 395 in-place 교체 substring presence/absence 양방향 검증
    (R1R2-Q1).
  - card pipeline 이 5 신규 id_policy sub-field 를 production path
    (build_render_prompt_card) 통해 운반.
  - inject 경로 (`_card_metadata` strip) 가 G4.3 4 신규 lift_status
    key 도 inject 에서 제외 (canonicalize 와 동일 격리).

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

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

import pytest

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


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 carry — _DEFAULT_SENTINEL pattern REQUIRED).
# `visible_entities or []` / `outlook_pairs or default` 류 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]
_V17_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "17.202605042018" / "system.md"
)
_V18_SYSTEM_MD = (
    _REPO_ROOT / "prompts" / "_base" / "scene_detail"
    / "18.202605041549" / "system.md"
)

# v17 → v18 line delta 기대치 (R4-M4 carry — actual measured ≈ -82).
# tolerance ±5: [-90, -75]. tolerance 가 spec/실측에서 자유 조정되면
# G4.3 의 "compact section" 약속이 약화됨 → 지키는 임계값.
_V18_LINE_DELTA_LO = -90
_V18_LINE_DELTA_HI = -75


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,
) -> Dict[str, Any]:
    """build_render_prompt_card 의 input ctx — G4.3 integration fixture.

    Sentinel 의도 (G4.1 함정 1 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 = []
    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 정합.
            "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": [],
        "forward_zoom_targets": [],
    }


def _build_inject_block(card: Dict[str, Any]) -> str:
    """Reproduces the production inject path from `detail_steps.py:1979-1990`.
    Strips `_card_metadata` and serializes — used to verify inject content
    contains the G4.3 5 sub-fields 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"
    )


# ──────────────────────────────────────────────────────────────────────────
# Class 1: TestV18PromptStructure — v18 system.md prose-lift verification.
# spec §3.2 + §6.2 — 4 prose sections gone, single ID Policy section.
# ──────────────────────────────────────────────────────────────────────────


class TestV18PromptStructure:
    """Verify v18 system.md prose-lift outcome:
      - 4 old prose sections deleted (composite ID 사용 규칙 / 극단 클로즈업
        face / 사진·포스터·화면·거울 reproduction surface / Rule H demographic)
      - 1 new compact 'ID Policy' section added (~30 lines)
      - line 395 in-place 교체 (substring presence + absence)
    """

    def test_v18_system_prompt_4_deletion_target_headings_absent(
        self,
    ) -> None:
        """spec §3.2 — v18 system.md does NOT contain any of the 4 deleted
        section headings (composite ID / close-up face / reproduction
        surface / Rule H demographic).
        """
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        deleted_headings = [
            "## C## 사용 규칙 — 참조 이미지 연동",
            "## 극단 클로즈업 표현 — 몸과 분리된 얼굴 방지",
            "## 사진·포스터·화면·거울 속 인물 규칙",
            "## entity ID 인물의 demographic descriptor 명시",
        ]
        for heading in deleted_headings:
            assert heading not in text, (
                f"v18 system.md still contains deleted heading "
                f"{heading!r} — Wave 1-B prose-lift incomplete"
            )

    def test_v18_system_prompt_compact_id_policy_section_present(
        self,
    ) -> None:
        """spec §3.3 — '## ID Policy' compact section heading present."""
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## ID Policy" in text, (
            "v18 system.md missing '## ID Policy' compact section heading "
            "— Wave 1-B prose-lift incomplete"
        )

    def test_v18_system_prompt_compact_id_policy_section_30_lines(
        self,
    ) -> None:
        """R4-M4 carry — the compact section spans exactly 30 lines
        from '## ID Policy' through (but not including) the next '## '
        heading. `splitlines()` excludes the trailing newline so the
        captured section count is 30 (not 31).
        """
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        match = re.search(
            r"## ID Policy.*?(?=\n## )",
            text,
            flags=re.DOTALL,
        )
        assert match is not None, (
            "regex 'ID Policy' section capture failed — "
            "next '## ' heading missing"
        )
        # `splitlines()` excludes trailing newline → 30 lines exactly.
        line_count = len(match.group(0).splitlines())
        assert line_count == 30, (
            f"ID Policy section line count {line_count} != 30 "
            f"(R4-M4 / R1-I3 / R2-I3) — section drift"
        )

    def test_v18_system_prompt_line_395_inplace_replaced_substring_absent(
        self,
    ) -> None:
        """R1R2-Q1 carry — old reminder substring '얼굴 참조 이미지가 강제
        주입' must NOT appear anywhere in v18 system.md (in-place replaced).
        """
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "얼굴 참조 이미지가 강제 주입" not in text, (
            "v18 system.md still contains old in-place reminder phrase "
            "'얼굴 참조 이미지가 강제 주입' — line 395 replacement missed"
        )

    def test_v18_system_prompt_line_395_inplace_replaced_substring_present(
        self,
    ) -> None:
        """R1R2-Q1 carry — historical v18 archival regression guard.

        v18 prompt 는 G4.3 (2026-05-04) 시점 historical archival. 본 test 는
        v18 file 의 line 395 in-place replacement (옛 wording 제거 + 새 reminder
        substring 추가) 가 보존된 상태 검증. Area #1 W5 (2026-05-16) 이후
        active prompt 는 v25 — v25 안 `id_policy.body_part_focus_rule` 자체
        폐기. 본 test 는 v18 archival 만 검증하며 v25 wording 과 무관.
        """
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        assert "id_policy.body_part_focus_rule" in text, (
            "v18 system.md missing new reminder substring "
            "'id_policy.body_part_focus_rule' — line 395 replacement missed "
            "(v18 archival guard; v25 active prompt 와 별개 regression target)"
        )

    def test_v18_system_prompt_line_count_delta_within_tolerance(
        self,
    ) -> None:
        """R4-M4 carry — v18 line count vs v17 in range
        [v17 - 90, v17 - 75] (allow ±5 around -82 measured).
        """
        v17_lines = _V17_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        v18_lines = _V18_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        delta = len(v18_lines) - len(v17_lines)
        assert _V18_LINE_DELTA_LO <= delta <= _V18_LINE_DELTA_HI, (
            f"v18 ({len(v18_lines)} lines) vs v17 ({len(v17_lines)} lines) "
            f"delta={delta} not in [{_V18_LINE_DELTA_LO}, "
            f"{_V18_LINE_DELTA_HI}]"
        )

    def test_v18_system_prompt_no_remaining_id_policy_prose_orphans(
        self,
    ) -> None:
        """Defensive — assert no orphan references like 'absolute ban —
        Focus on C##O##' remain (deleted prose remnants).
        """
        text = _V18_SYSTEM_MD.read_text(encoding="utf-8")
        for orphan in (
            "absolute ban — Focus on C##O##'s",
            "absolute ban — close on C##O##'s",
        ):
            assert orphan not in text, (
                f"v18 system.md still contains orphan deleted-prose "
                f"snippet {orphan!r}"
            )


# ──────────────────────────────────────────────────────────────────────────
# Class 2: TestRenderPromptCardPipeline — full builder + inject path.
# spec §6.2 + R2-I4 — 5 신규 id_policy sub-field 보존.
# ──────────────────────────────────────────────────────────────────────────


class TestRenderPromptCardPipeline:
    """build_render_prompt_card() entire call → 5 신규 id_policy sub-field
    + _card_metadata G4.3 4 신규 keys + inject path strips _card_metadata.
    """

    def test_render_prompt_card_pipeline_produces_card_with_5_new_sub_fields(
        self,
    ) -> None:
        """R2-I4 + Area #1 W5 — full builder path → 4 sub-fields present
        (W3 가 body_part_focus_rule / close_framing_face_phrasing 폐기 +
        subject_reference_policy 신규 = 5 - 2 + 1 = 4)."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        ip = card["id_policy"]
        for sub in (
            "face_identifiability_rule",
            "reproduction_surface_rule",
            "demographic_descriptor_policy",
            "subject_reference_policy",
        ):
            assert sub in ip, (
                f"4 sub-field (G4.3 + Area #1 W5) {sub!r} missing in production "
                f"build_render_prompt_card output"
            )
        # Area #1 W5 폐기 2 sub-field 부재 확인.
        assert "body_part_focus_rule" not in ip
        assert "close_framing_face_phrasing" not in ip

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

    def test_render_prompt_card_card_metadata_rule_source_includes_g4_3_keys(
        self,
    ) -> None:
        """4 G4.3 rule_source keys: demographic_rule + composite_id_rule
        + close_framing_face_rule + reproduction_surface_rule.
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        rs = card["_card_metadata"]["rule_source"]
        assert rs["demographic_rule"] == "H"
        for key in (
            "composite_id_rule",
            "close_framing_face_rule",
            "reproduction_surface_rule",
        ):
            assert key in rs, (
                f"G4.3 rule_source {key!r} missing — got "
                f"{sorted(rs.keys())!r}"
            )

    def test_render_prompt_card_inject_strips_card_metadata(self) -> None:
        """G4.2 R2-I4 carry — inject JSON does NOT contain
        '_card_metadata' substring (strip 위치 production: detail_steps:1979).
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        assert '"_card_metadata"' not in block, (
            "inject block leaked '_card_metadata' — strip in production "
            "inject path broken (detail_steps.py:1979)"
        )

    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 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_render_prompt_card_inject_block_carries_5_id_policy_subfields(
        self,
    ) -> None:
        """Production inject path → user_prompt 안 4 id_policy sub-field
        substring 모두 포함 (R2-I4 + Area #1 W5: body_part_focus_rule /
        close_framing_face_phrasing 폐기 + subject_reference_policy 신규)."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = _build_inject_block(card)
        for sub in (
            '"face_identifiability_rule"',
            '"reproduction_surface_rule"',
            '"demographic_descriptor_policy"',
            '"subject_reference_policy"',
        ):
            assert sub in block, (
                f"inject block missing sub-field {sub!r} — "
                f"production inject contract broken"
            )
        # Area #1 W5 폐기 2 sub-field 의 substring 부재 확인.
        for deprecated in (
            '"body_part_focus_rule"',
            '"close_framing_face_phrasing"',
        ):
            assert deprecated not in block, (
                f"inject block contains deprecated sub-field {deprecated!r} — "
                f"Area #1 W5 cleanup 누락"
            )


# ──────────────────────────────────────────────────────────────────────────
# Class 3: TestG4_3CarryPaths — close-framing / reproduction surface /
# id_policy snapshot hash + G3.2 sentinel + G4.2 background_binding 공존.
# ──────────────────────────────────────────────────────────────────────────


class TestG4_3CarryPaths:
    """Cross-mode integration — close-framing, reproduction surfaces,
    snapshot hash drift, and coexistence with G3.2 sentinel + G4.2
    background_binding constraints on the same shot.
    """

    def test_close_framing_shot_card_carries_face_phrasing_rules(
        self,
    ) -> None:
        """close-framing path — Area #1 W5 (2026-05-16): close_framing_face_phrasing
        sub-field 폐기 부재 확인. 구 forbidden_phrases list 검증은 의미 상실
        (sub-field 자체 제거)."""
        ctx = _make_ctx_for_shot(is_close_framing=True)
        card = build_render_prompt_card(**ctx)
        assert "close_framing_face_phrasing" not in card["id_policy"], (
            "close_framing_face_phrasing should be deprecated (Area #1 W5)"
        )

    def test_reproduction_surface_shot_card_applies_bool_shape(self) -> None:
        """spec §6.2 #11 — every shot path → user_prompt has
        reproduction_surface_rule with applies bool + id_use + rationale.

        Area C migration (2026-05-12): shape changed from
        `{applies_to_surfaces: [9 nouns], ...}` to `{applies: bool, ...}` —
        `applies` derived from shot_staging.key_bg_elements directionality_class
        SOT. Default fixture (`key_bg_elements=[]`) → applies=False.
        Deny-assertion preserves regression-pinning that legacy noun list
        MUST NOT reappear.
        """
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        rule = card["id_policy"]["reproduction_surface_rule"]
        # New shape: 3 keys (applies bool + id_use + rationale_summary).
        assert set(rule.keys()) == {"applies", "id_use", "rationale_summary"}, (
            f"reproduction_surface_rule shape mismatch — got "
            f"{sorted(rule.keys())!r}"
        )
        # applies must be bool (not None, not str).
        assert isinstance(rule["applies"], bool), (
            f"applies must be bool, got {type(rule['applies']).__name__}"
        )
        # Default fixture has empty key_bg_elements → applies=False.
        assert rule["applies"] is False
        # Legacy shape eradication.
        assert "applies_to_surfaces" not in rule, (
            "legacy 'applies_to_surfaces' key MUST NOT appear after Area C "
            f"migration — got rule={rule!r}"
        )

    def test_card_hash_drift_on_id_policy_constraint_change_in_verify(
        self,
    ) -> None:
        """spec §6.2 #13 — id_policy.constraints content selector change
        in verify path → hash drift detected (R4-I2 carry).
        """
        ctx = _make_ctx_for_shot()
        card_a = build_render_prompt_card(**ctx)
        h_a = compute_card_hash(card_a)
        # Surgical mutation via deep-copy.
        # Area #1 W5 (2026-05-16): close_framing_face_phrasing 폐기로
        # "face filling the entire frame" wording 사라짐. constraints[0] 직접
        # mutation 으로 selector 변경 검증 (의미 보존 — hash drift 가 핵심).
        import copy as _copy
        card_b = _copy.deepcopy(card_a)
        constraints = card_b["id_policy"]["constraints"]
        assert len(constraints) >= 1, (
            f"id_policy.constraints empty — fixture broken: {constraints!r}"
        )
        constraints[0] = "MUTATED — drift sentinel string"
        h_b = compute_card_hash(card_b)
        assert h_a != h_b, (
            "card hash did not drift on id_policy.constraints content "
            "selector change — verify_completion would mark this clean"
        )

    def test_snapshot_hash_drift_on_id_policy_change(self) -> None:
        """compute_id_policy_snapshot_hash() partial hash drift on
        id_policy mutation (canary pinning use case)."""
        ctx = _make_ctx_for_shot()
        card_a = build_render_prompt_card(**ctx)
        import copy as _copy
        card_b = _copy.deepcopy(card_a)
        # Mutate a single id_policy sub-field literal (1-char tail change).
        card_b["id_policy"]["face_identifiability_rule"]["id_use_summary"] = (
            card_b["id_policy"]["face_identifiability_rule"][
                "id_use_summary"
            ] + "X"
        )
        assert compute_id_policy_snapshot_hash(card_a) != (
            compute_id_policy_snapshot_hash(card_b)
        )

    def test_g3_2_sentinel_coexists_with_g4_3_id_policy(self) -> None:
        """spec §6.2 #14 — synthetic CP shape with G3.2 owned sentinel
        (variation-level) + G4.1+G4.2 card (shot-level) + G4.3 id_policy
        + background_binding.constraints all coexist on the same result.
        """
        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": 2,
                    "validator": "scene_detail_owned_objects.v1",
                    "owned_hash": "x" * 16,
                    "camera_direction_hash": "y" * 16,
                    "t2i_prompt_hash": "z" * 16,
                    "owned_usage_hash": "0123456789abcdef",  # C2 v1 sentinel v2
                    "violations": [],
                },
            }],
        }
        # G4.3 + Area #1 W5: 4 id_policy sub-field present
        # (body_part_focus_rule + close_framing_face_phrasing 폐기 + subject_reference_policy 신규).
        for sub in (
            "face_identifiability_rule",
            "reproduction_surface_rule",
            "demographic_descriptor_policy",
            "subject_reference_policy",
        ):
            assert sub in cp_shape["render_prompt_card"]["id_policy"]
        # Area #1 W5: 폐기 sub-field 부재 확인.
        ip_check = cp_shape["render_prompt_card"]["id_policy"]
        assert "body_part_focus_rule" not in ip_check
        assert "close_framing_face_phrasing" not in ip_check
        # 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"
