"""G4.2 background-binding lift — integration tests.

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

  - v17 system.md 의 3 prose section (Rule A/C/E) 제거 + 신규 'Background
    Binding (Rules A / C / E)' compact section 존재 (spec §6 grep checks).
  - v17 token reduction vs v16 (prose lift 의 line-count 영향).
  - non-close / close / not_applicable shot 별 card.background_binding 모양.
  - card hash drift on owned change (verify_completion 와 동일 contract).
  - G3.2 sentinel + G4.1+G4.2 card 의 동시 공존 (cp dual-shape).

Reference: docs/superpowers/specs/2026-05-04-g4.2-background-binding-lift-design.md
"""
from __future__ import annotations

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

import pytest

from app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION
from app.core.steps.render_prompt_card import (
    BG_MODE_NOT_APPLICABLE,
    BG_MODE_OFF,
    BG_MODE_REF_ATTACHED,
    BG_MODE_SKIPPED_CLOSE,
    assert_card_shape,
    build_background_binding,
    build_render_prompt_card,
    canonicalize_render_prompt_card,
    compute_card_hash,
)


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 carry — _DEFAULT_SENTINEL pattern REQUIRED).
# `bg_owned or []` / `outlook_pairs or default` 류 silent absorb 패턴 금지.
# ──────────────────────────────────────────────────────────────────────────

_DEFAULT_SENTINEL = object()

# Repo-root 상대 경로 — backend/tests/integration/<file> 에서 parents[3] = repo root.
# (절대 경로 hardcode 시 CI/다른 checkout 환경에서 깨짐.)
_REPO_ROOT = Path(__file__).resolve().parents[3]
_V17_SYSTEM_MD = _REPO_ROOT / "prompts" / "_base" / "scene_detail" / "17.202605042018" / "system.md"
_V16_SYSTEM_MD = _REPO_ROOT / "prompts" / "_base" / "scene_detail" / "16.202605041200" / "system.md"

# v17 token reduction 하한선 — spec §3.4 net -61 lines 기대치 (절대 -83 + 신규 +22).
# 60 line 마진 = 신규 compact section 길이 ±10% 변동을 허용. 본 임계값을 spec
# 외에서 자유 조정하면 G4.2 의 "compact section" 약속이 약화됨.
_V17_MIN_LINE_REDUCTION = 60


def _make_ctx_for_shot(
    *,
    close_framing: bool = False,
    bg_on: bool = True,
    bg_id: Optional[str] = "cb_main_room",
    bg_owned=_DEFAULT_SENTINEL,
    bg_camera_meta=_DEFAULT_SENTINEL,
    outlook_pairs=_DEFAULT_SENTINEL,
    fixed_elements=_DEFAULT_SENTINEL,
) -> Dict[str, Any]:
    """build_render_prompt_card 의 input ctx — integration fixture.

    Sentinel 의도 (G4.1 함정 1 carry):
      - x=None → 명시적 None (builder 가 raise — fail-fast).
      - x=[] / x={} → 빈 list/dict 그대로 보존.
      - x=_DEFAULT_SENTINEL (= 인자 미전달) → fixture 가 default 채움.
    `default if x is None else x` 같은 silent 변환 패턴 금지.
    """
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [{"character_id": "C01", "outlook_id": "O02"}]
    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 close_framing
                else "medium shot of doorway"
            ),
            "primary_subject": "the observer at the doorway",
        },
        "visible_entities": ["C01"],
        "outlook_pairs": outlook_pairs,
        "perception_mode": None,
        "staging": {
            "camera_direction": (
                "extreme close-up of hand"
                if close_framing
                else "medium shot of doorway"
            ),
            # framing_scale enum SOT v1 (2026-05-15): helper read fail-fast 정합.
            "framing_scale": "close" if 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": close_framing,
        "background_mode_on": bg_on,
        "fixed_elements": fixed_elements,
        "previous_shot_refs": [],
        "forward_zoom_targets": [],
    }


# ──────────────────────────────────────────────────────────────────────────
# Class 1: TestV17PromptStructure — v17 system.md prose lift verification.
# spec §3 + §6 — Rule A/C/E sections gone, single Background Binding section.
# ──────────────────────────────────────────────────────────────────────────


class TestV17PromptStructure:
    """Verify v17 system.md prose-lift outcome:
      - 3 old Rule sections deleted (Rule A / C / E)
      - 1 new compact 'Background Binding (Rules A / C / E)' section added
      - line-count reduction vs v16 ≥ 60 (prose moved to card constraints)
    """

    def test_v17_system_prompt_missing_rule_a_section(self) -> None:
        """spec §3 — v17 system.md does NOT contain the deleted Rule A
        section header '## chain_bg reference 카메라 일관성'."""
        text = _V17_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## chain_bg reference 카메라 일관성" not in text, (
            "v17 system.md still contains deleted Rule A section header — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v17_system_prompt_missing_rule_c_section(self) -> None:
        """spec §3 — v17 system.md does NOT contain the deleted Rule C
        section header '## chain_bg 객체 중복 묘사 금지'."""
        text = _V17_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## chain_bg 객체 중복 묘사 금지" not in text, (
            "v17 system.md still contains deleted Rule C section header — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v17_system_prompt_missing_rule_e_section(self) -> None:
        """spec §3 — v17 system.md does NOT contain the deleted Rule E
        section header '## close framing 시 reference image 부재 가정'."""
        text = _V17_SYSTEM_MD.read_text(encoding="utf-8")
        assert (
            "## close framing 시 reference image 부재 가정" not in text
        ), (
            "v17 system.md still contains deleted Rule E section header — "
            "Wave 1-B prose-lift incomplete"
        )

    def test_v17_system_prompt_has_background_binding_section(self) -> None:
        """spec §3 — v17 system.md contains the new compact Background
        Binding section header exactly."""
        text = _V17_SYSTEM_MD.read_text(encoding="utf-8")
        assert "## Background Binding (Rules A / C / E)" in text, (
            "v17 system.md missing new 'Background Binding (Rules A / C / E)' "
            "section header — Wave 1-B prose-lift incomplete"
        )

    def test_v17_prompt_token_reduction_vs_v16(self) -> None:
        """spec §3 — v17 line count is significantly less than v16
        (prose moved to card.background_binding.constraints)."""
        v16_lines = _V16_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        v17_lines = _V17_SYSTEM_MD.read_text(
            encoding="utf-8"
        ).splitlines()
        assert len(v17_lines) < len(v16_lines) - _V17_MIN_LINE_REDUCTION, (
            f"v17 ({len(v17_lines)} lines) not sufficiently reduced vs "
            f"v16 ({len(v16_lines)} lines) — expected diff > "
            f"{_V17_MIN_LINE_REDUCTION}"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 2: TestShotCardModes — non-close / close / not_applicable shapes.
# spec §6.1 / §6.2 / §6.4 — card.background_binding mode transitions.
# ──────────────────────────────────────────────────────────────────────────


class TestShotCardModes:
    """Per-shot card.background_binding shape across the three primary
    modes used in production: ref_attached / skipped_close_framing /
    not_applicable.
    """

    def test_non_close_shot_card_carries_owned_and_camera(self) -> None:
        """spec §6.1 — non-close + bg_on + bg_id given → ref_attached
        with non-empty owned + non-None camera_reference."""
        ctx = _make_ctx_for_shot(close_framing=False, bg_on=True)
        card = build_render_prompt_card(**ctx)
        bb = card["background_binding"]
        assert bb["mode"] == BG_MODE_REF_ATTACHED
        assert bb["owned_objects"], (
            f"non-close ref_attached card must have non-empty "
            f"owned_objects (got {bb['owned_objects']!r})"
        )
        assert bb["camera_reference"] is not None, (
            "non-close ref_attached card must have non-None "
            "camera_reference"
        )

    def test_close_framing_shot_card_skips_owned_and_camera(self) -> None:
        """spec §6.2 — close framing → skipped_close_framing with empty
        owned_objects and None camera_reference (G3.2 R5-B4 carry)."""
        ctx = _make_ctx_for_shot(close_framing=True, bg_on=True)
        card = build_render_prompt_card(**ctx)
        bb = card["background_binding"]
        assert bb["mode"] == BG_MODE_SKIPPED_CLOSE
        assert bb["owned_objects"] == []
        assert bb["camera_reference"] is None

    def test_not_applicable_shot_card_carries_no_owned_no_camera_no_reference(
        self,
    ) -> None:
        """spec §6.4 R1-I7 — bg_on=True yet bg_id=None → not_applicable.
        Card has empty owned, None camera_reference, principle-only guard
        wording ('no chain_bg is bound'), and rule_e_lifted=False (close-skip
        prose is NOT applicable to non-close modes).

        Area #5 W3 (2026-05-18) — legacy "no reference image to refer to"
        closed-list 폐기. principle-only constraint."""
        ctx = _make_ctx_for_shot(
            close_framing=False, bg_on=True, bg_id=None,
            bg_owned=[], bg_camera_meta=None,
        )
        card = build_render_prompt_card(**ctx)
        bb = card["background_binding"]
        assert bb["mode"] == BG_MODE_NOT_APPLICABLE
        assert bb["owned_objects"] == []
        assert bb["camera_reference"] is None
        joined = "\n".join(bb["constraints"])
        assert "no chain_bg is bound" in joined, (
            f"not_applicable card missing R1-I7 guard wording — "
            f"constraints=\n{joined}"
        )
        assert (
            card["_card_metadata"]["lift_status"]["rule_e_lifted"] is False
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 3: TestCardHashIntegration — hash drift in verify-completion-like
# scenarios. spec §6.5 R1-B1 + plan-R1-I8.
# ──────────────────────────────────────────────────────────────────────────


class TestCardHashIntegration:
    """Hash drift for owned change (verify_completion would mark as
    partial). Sanity that two cards built with different owned lists
    produce different hashes."""

    def test_card_hash_drift_on_owned_change_in_verify(self) -> None:
        """spec §6.5 — two cards differing only in bg_owned produce
        different hashes (G3.2 sentinel-style binding at shot level)."""
        ctx_a = _make_ctx_for_shot(
            close_framing=False, bg_on=True,
            bg_owned=["door", "window"],
        )
        ctx_b = _make_ctx_for_shot(
            close_framing=False, bg_on=True,
            bg_owned=["door", "table"],
        )
        card_a = build_render_prompt_card(**ctx_a)
        card_b = build_render_prompt_card(**ctx_b)
        assert compute_card_hash(card_a) != compute_card_hash(card_b), (
            "card hash did not drift on owned change — "
            "verify_completion would falsely mark this clean"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 4: TestG3_2_G4_2_Coexistence — sentinel + card dual-shape on cp.
# spec §4.3 — card hash (shot-level) + G3.2 owned sentinel (variation-level)
# 둘 다 cp 에 공존.
# ──────────────────────────────────────────────────────────────────────────


class TestG3_2_G4_2_Coexistence:
    """G3.2 owned sentinel (variation-level t2i_prompt drift detector) and
    G4.1+G4.2 render_prompt_card (shot-level upstream-contract drift
    detector) coexist on the same scene/shot result. Mutating one does not
    affect the other's structure.
    """

    def test_g3_2_sentinel_coexists_with_g4_2_card(self) -> None:
        """spec §4.3 — synthetic CP-shape dict with both fields present.
        Independence assertion: changing one (sentinel) does not change
        the other's (card) keys/shape, and vice versa."""
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        card_hash = compute_card_hash(card)
        # synthetic CP-shape dict (G3.2 owned sentinel + G4.1+G4.2 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": [],
                },
            }],
        }
        # Card shape passes its own validator.
        assert_card_shape(cp_shape["render_prompt_card"])
        # Mutate sentinel only — card+hash unchanged.
        cp_shape["t2i_variations"][0]["owned_validation"]["t2i_prompt_hash"] = (
            "a" * 16
        )
        assert cp_shape["render_prompt_card"] == card, (
            "mutating sentinel altered the card structure — "
            "scope separation broken"
        )
        assert cp_shape["render_prompt_card_hash"] == card_hash, (
            "mutating sentinel altered the card hash — "
            "scope separation broken"
        )
        # Mutate card hash — sentinel unchanged.
        cp_shape["render_prompt_card_hash"] = "0" * 16
        sentinel = cp_shape["t2i_variations"][0]["owned_validation"]
        assert sentinel["validator"] == "scene_detail_owned_objects.v1"
        assert sentinel["t2i_prompt_hash"] == "a" * 16
