"""G4.1 Phase 5/6/7 detail_steps.py integration helper unit tests.

Covers (per docs/superpowers/plans/2026-05-04-g4-render-prompt-card-implementation.md):
  - Phase 5 Task 14: `_collect_card_inputs(...)` helper — single source for
    card builder inputs (R2-B3, called from `_analyze_one()`,
    `verify_completion()`, and `_user_edited` reuse path).
  - Phase 7 Task 19: `_user_edited_card_contract_violated(...)` helper —
    testable drift guard for `_user_edited` reuse path (R2-I6 / R3-I2).

These are **pure helpers** — no DB, no LLM, no FS. Integration with the
StepRunner (Phase 5 inject + Phase 6 verify_completion + Phase 7 reuse path)
is verified via Wave 3 integration tests.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

from app.core.errors import AppError
from app.core.steps.detail_steps import (
    _collect_card_inputs,
    _user_edited_card_contract_violated,
)
from app.core.steps.render_prompt_card import (
    build_render_prompt_card,
    compute_card_hash,
)


# =========================================================================
# Helpers — fixture builders shared across tests.
# =========================================================================
def _make_card_inputs_kwargs(
    *,
    visible_entities: List[str] | None = None,
    outlook_pairs: List[Dict[str, str]] | None = None,
    fixed_elements: List[Dict[str, Any]] | None = None,
    previous_shot_refs: List[Dict[str, Any]] | None = None,
    forward_zoom_targets: List[Dict[str, Any]] | None = None,
    bg_id: str | None = None,
    bg_owned: List[str] | None = None,
    bg_camera_meta: Dict[str, Any] | None = None,
    bg_guide: str | None = None,
    is_close_framing: bool = False,
    background_mode_on: bool = False,
    perception_mode: str | None = None,
    staging: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
    """Build kwargs dict for `_collect_card_inputs(...)`.

    Each list field uses an explicit default `[]` (NOT `or []` silent absorb).
    Tests that wish to verify None propagation pass None via direct kwargs
    override.
    """
    return {
        "ctx": object(),  # opaque — helper does not introspect
        "seg": {"scene_index": 7, "text": "scene text"},
        "shot_info": {
            "shot_index": 3,
            "description": "shot desc",
            # build_render_strategy() 가 staging 부재 시 fail-fast — 테스트
            # 시나리오에서는 staging_not_applicable 로 명시.
            "staging_not_applicable": True,
        },
        "visible_entities": [] if visible_entities is None else visible_entities,
        "outlook_pairs": [] if outlook_pairs is None else outlook_pairs,
        "staging_for_shot": staging,
        "bg_id_for_shot": bg_id,
        "bg_owned_for_shot": [] if bg_owned is None else bg_owned,
        "bg_camera_meta_for_shot": bg_camera_meta,
        "bg_guide_for_shot": bg_guide,
        "is_close_framing": is_close_framing,
        "background_mode_on": background_mode_on,
        "fixed_elements_for_shot": [] if fixed_elements is None else fixed_elements,
        "previous_shot_refs_for_shot": (
            [] if previous_shot_refs is None else previous_shot_refs
        ),
        "forward_zoom_targets_for_shot": (
            [] if forward_zoom_targets is None else forward_zoom_targets
        ),
    }


def _make_card_inputs(**overrides: Any) -> Dict[str, Any]:
    """Returns the resolved input dict (helper output) suitable for
    `build_render_prompt_card(**inputs)`.
    """
    kwargs = _make_card_inputs_kwargs()
    # Allow targeted overrides (perception_mode, etc.).
    for k, v in overrides.items():
        kwargs[k.replace("_for_shot", "_for_shot")] = v  # passthrough
        kwargs[k] = v
    return _collect_card_inputs(**kwargs)


# =========================================================================
# Phase 5 Task 14 — `_collect_card_inputs(...)` helper.
# =========================================================================
class TestCollectCardInputs:
    def test_returns_dict_with_all_required_keys(self) -> None:
        out = _collect_card_inputs(**_make_card_inputs_kwargs())
        # Required by build_render_prompt_card signature.
        for key in (
            "scene_index", "shot_index", "seg", "shot_info",
            "visible_entities", "outlook_pairs", "perception_mode",
            "staging", "bg_id", "bg_owned", "bg_camera_meta", "bg_guide",
            "is_close_framing", "background_mode_on",
            "fixed_elements", "previous_shot_refs", "forward_zoom_targets",
        ):
            assert key in out, f"missing key: {key}"

    def test_scene_index_extracted_from_seg(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["seg"] = {"scene_index": 42, "text": "x"}
        out = _collect_card_inputs(**kwargs)
        assert out["scene_index"] == 42

    def test_scene_index_falls_back_to_seg_index_key(self) -> None:
        # Some legacy paths use "index" instead of "scene_index".
        kwargs = _make_card_inputs_kwargs()
        kwargs["seg"] = {"index": 17}
        out = _collect_card_inputs(**kwargs)
        assert out["scene_index"] == 17

    def test_scene_index_zero_when_seg_none(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["seg"] = None
        out = _collect_card_inputs(**kwargs)
        assert out["scene_index"] == 0

    def test_shot_index_extracted_from_shot_info(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["shot_info"] = {"shot_index": 9}
        out = _collect_card_inputs(**kwargs)
        assert out["shot_index"] == 9

    def test_shot_index_zero_when_shot_info_none(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["shot_info"] = None
        out = _collect_card_inputs(**kwargs)
        assert out["shot_index"] == 0

    def test_perception_mode_derived_from_shot_info(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["shot_info"] = {"shot_index": 1, "perception_mode": "dream"}
        out = _collect_card_inputs(**kwargs)
        assert out["perception_mode"] == "dream"

    def test_perception_mode_none_when_absent(self) -> None:
        out = _collect_card_inputs(**_make_card_inputs_kwargs())
        assert out["perception_mode"] is None

    def test_lists_forwarded_as_is_no_silent_absorb(self) -> None:
        # R2-B4: list inputs must be forwarded raw (None passthrough so
        # builder can fail-fast). Helper must NOT do `or []`.
        kwargs = _make_card_inputs_kwargs()
        kwargs["visible_entities"] = None
        kwargs["outlook_pairs"] = None
        kwargs["fixed_elements_for_shot"] = None
        kwargs["previous_shot_refs_for_shot"] = None
        kwargs["forward_zoom_targets_for_shot"] = None
        out = _collect_card_inputs(**kwargs)
        assert out["visible_entities"] is None
        assert out["outlook_pairs"] is None
        assert out["fixed_elements"] is None
        assert out["previous_shot_refs"] is None
        assert out["forward_zoom_targets"] is None

    def test_bool_flags_normalized(self) -> None:
        kwargs = _make_card_inputs_kwargs()
        kwargs["is_close_framing"] = 1  # truthy non-bool
        kwargs["background_mode_on"] = "yes"  # truthy non-bool
        out = _collect_card_inputs(**kwargs)
        assert out["is_close_framing"] is True
        assert out["background_mode_on"] is True

    def test_output_consumable_by_build_render_prompt_card(self) -> None:
        # The whole purpose: helper output → builder direct call.
        out = _collect_card_inputs(
            **_make_card_inputs_kwargs(visible_entities=["C01"])
        )
        # Drop ctx — builder doesn't need it.
        out.pop("ctx", None)
        card = build_render_prompt_card(**out)
        assert card["schema_version"] == 1
        assert card["shot_key"]["scene_index"] == 7
        assert card["shot_key"]["shot_index"] == 3


# =========================================================================
# Phase 7 Task 19 — `_user_edited_card_contract_violated(...)` helper.
# =========================================================================
def _build_valid_card_and_inputs() -> tuple[Dict[str, Any], str, Dict[str, Any]]:
    """Returns (card, hash, card_inputs) for a stable valid scenario."""
    inputs = _collect_card_inputs(**_make_card_inputs_kwargs(visible_entities=["C01"]))
    inputs.pop("ctx", None)
    card = build_render_prompt_card(**inputs)
    h = compute_card_hash(card)
    return card, h, inputs


class TestUserEditedCardContractViolated:
    def test_returns_none_when_card_and_hash_match(self) -> None:
        card, h, inputs = _build_valid_card_and_inputs()
        result = _user_edited_card_contract_violated(
            stored_card=card, stored_hash=h, card_inputs=inputs,
        )
        assert result is None  # reuse OK

    def test_returns_string_when_card_missing(self) -> None:
        _, h, inputs = _build_valid_card_and_inputs()
        result = _user_edited_card_contract_violated(
            stored_card=None, stored_hash=h, card_inputs=inputs,
        )
        assert isinstance(result, str)
        assert "missing" in result.lower()

    def test_returns_string_when_hash_missing(self) -> None:
        card, _, inputs = _build_valid_card_and_inputs()
        result = _user_edited_card_contract_violated(
            stored_card=card, stored_hash=None, card_inputs=inputs,
        )
        assert isinstance(result, str)
        assert "missing" in result.lower()

    def test_returns_string_when_card_shape_invalid(self) -> None:
        card, h, inputs = _build_valid_card_and_inputs()
        # Corrupt — drop a required top-level field.
        bad = dict(card)
        del bad["render_strategy"]
        result = _user_edited_card_contract_violated(
            stored_card=bad, stored_hash=h, card_inputs=inputs,
        )
        assert isinstance(result, str)
        assert "shape" in result.lower()

    def test_returns_string_when_hash_drift(self) -> None:
        card, _h, inputs = _build_valid_card_and_inputs()
        # Stored hash is wrong but card itself is fine — simulates upstream
        # contract change after CP write (visible/outlook/etc. shifted).
        result = _user_edited_card_contract_violated(
            stored_card=card,
            stored_hash="0123456789abcdef",  # 16-hex but wrong value
            card_inputs=inputs,
        )
        assert isinstance(result, str)
        assert "drift" in result.lower() or "hash" in result.lower()

    def test_hash_drift_when_inputs_changed(self) -> None:
        # Real upstream change scenario: ctx visible_entities shifted between
        # CP write and reuse attempt.
        _orig_inputs_kwargs = _make_card_inputs_kwargs(visible_entities=["C01"])
        orig_inputs = _collect_card_inputs(**_orig_inputs_kwargs)
        orig_inputs.pop("ctx", None)
        orig_card = build_render_prompt_card(**orig_inputs)
        orig_hash = compute_card_hash(orig_card)

        # Upstream changed — visible_entities now ["C01", "C02"].
        new_inputs_kwargs = _make_card_inputs_kwargs(visible_entities=["C01", "C02"])
        new_inputs = _collect_card_inputs(**new_inputs_kwargs)
        new_inputs.pop("ctx", None)

        result = _user_edited_card_contract_violated(
            stored_card=orig_card,
            stored_hash=orig_hash,
            card_inputs=new_inputs,
        )
        assert isinstance(result, str)
        assert "drift" in result.lower() or "hash" in result.lower()

    def test_recompute_failure_returned_as_string(self) -> None:
        # Builder raises (None instead of [] for required list).
        card, h, _inputs = _build_valid_card_and_inputs()
        broken_inputs = _collect_card_inputs(
            **_make_card_inputs_kwargs(visible_entities=["C01"])
        )
        broken_inputs.pop("ctx", None)
        broken_inputs["outlook_pairs"] = None  # builder will raise
        result = _user_edited_card_contract_violated(
            stored_card=card, stored_hash=h, card_inputs=broken_inputs,
        )
        assert isinstance(result, str)
        # Either "recompute_failed" (fail-fast wraps in our reason) OR
        # the AppError surfaces — both are a violation signal.
        assert (
            "recompute" in result.lower()
            or "contract" in result.lower()
            or "outlook_pairs" in result.lower()
        )


# =========================================================================
# Wave 4 R4 B1 (cleanup): `_collect_card_inputs(ctx=real_ctx, ...)` 가
# 진짜 ctx 에서 7 input 모두 derive — 옛 hardcoded empty 결함 회귀 차단.
# =========================================================================
class TestCollectCardInputsCtxDerivation:
    """Wave 4 R4 B1 회귀 차단: ctx 가 SceneAnalysisContext 면 helper 가 7
    input 모두 ctx 에서 derive (visible / outlook_pairs / staging / bg_id /
    bg_owned / fixed_elements / previous_shot_refs / forward_zoom_targets).
    옛 path (hardcode empty 인풋) 회귀 시 본 test 가 catch.
    """

    def _make_real_ctx(self):
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(project_id="P1", episode_id="E1")
        ctx.scene_visible = {12: ["C01", "C02"]}
        ctx.shot_director_ve = {(12, 4): ["C01"]}
        ctx.staging_map = {
            "12_4": {"camera_direction": "medium shot", "framing_scale": "medium", "lighting_mood": "warm"},
        }
        ctx.chain_bg_owned_by_shot = {(12, 4): ["door", "TV"]}
        ctx.chain_bg_id_by_shot = {(12, 4): "cb_main_room"}
        ctx.chain_bg_camera_meta_by_shot = {
            (12, 4): {"camera_position": "south"},
        }
        ctx.chain_bg_guide_by_shot = {(12, 4): "guide text"}
        ctx.outlook_data = {
            "outlooks": [{"outlook_id": "O02", "name": "casual"}],
            "scene_assignments": [
                {"scene_index": 12, "assignments": [
                    {"character_id": "C01", "outlook_id": "O02"},
                ]},
            ],
        }
        ctx.fixed_elements_by_scene = {
            12: [{
                "element_id": "x", "element_type": "character_state",
                "character_name": "C01", "description": "rigid pose",
                "applies_to_shots": [4],
                "source_facts": ["s"], "visual_inferences": ["v"],
                "creative_decisions": ["c"], "confidence": "high",
            }],
        }
        ctx.dependencies = [
            {
                "scene_index": 12, "shot_index": 4,
                "location_refs": [{
                    "scene_index": 12, "shot_index": 3,
                    "ref_usage": "exact_background", "keep_elements": [],
                }],
                "character_refs": [{
                    "scene_index": 12, "shot_index": 3,
                    "ref_usage": "continuation",
                }],
            },
        ]
        ctx.shot_scenes_map = {12: [{"shot_index": 4, "description": "x"}]}
        return ctx

    def test_visible_entities_derived_from_shot_director_ve(self) -> None:
        # shot_director_ve 우선 (variant resolved) > scene_visible.
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        assert out["visible_entities"] == ["C01"], (
            f"B1: visible should derive from shot_director_ve (not "
            f"hardcode []). got {out['visible_entities']!r}"
        )

    def test_outlook_pairs_derived_from_outlook_data(self) -> None:
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        # B1 cleanup: outlook_pairs derived from ctx, not hardcode [].
        assert out["outlook_pairs"] != [], (
            "B1: outlook_pairs must be ctx-derived (was hardcode [] before fix)."
        )
        assert {"character_id": "C01", "outlook_id": "O02"} in out["outlook_pairs"]

    def test_bg_id_derived_from_chain_bg_id_by_shot(self) -> None:
        # B3: bg_id 도 ctx-derived. 옛 hardcode None → bg-on shot 도
        # not_applicable mode 만 생성하던 결함 회귀 차단.
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        assert out["bg_id"] == "cb_main_room", (
            f"B3: bg_id must derive from ctx.chain_bg_id_by_shot "
            f"(got {out['bg_id']!r})"
        )

    def test_fixed_elements_filtered_by_applies_to_shots(self) -> None:
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        assert len(out["fixed_elements"]) == 1
        assert out["fixed_elements"][0]["element_id"] == "x"

    def test_previous_shot_refs_includes_loc_and_char_refs(self) -> None:
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        kinds = [r.get("kind") for r in out["previous_shot_refs"]]
        assert "location" in kinds
        assert "character" in kinds

    def test_bg_owned_derived_from_chain_bg_owned_by_shot(self) -> None:
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        assert out["bg_owned"] == ["door", "TV"]

    def test_staging_derived_from_staging_map(self) -> None:
        ctx = self._make_real_ctx()
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        assert isinstance(out["staging"], dict)
        assert out["staging"]["camera_direction"] == "medium shot"

    def test_b3_bg_id_derives_when_present(self) -> None:
        # bg_id mapping 부재 시 None 반환 (silent 정상 — bg-on + cp 부재가
        # contract violation 인 케이스는 loader 가 fail-fast).
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(project_id="P1", episode_id="E1")
        ctx.scene_visible = {12: ["C01"]}
        ctx.staging_map = {
            "12_4": {"camera_direction": "medium shot", "framing_scale": "medium"},
        }
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        # 매핑 부재 → None.
        assert out["bg_id"] is None

    def test_b4_shot_path_missing_staging_does_not_set_marker(self) -> None:
        # B4: shot path (shot_info dict + staging 부재) → builder raise 가
        # 의도. helper 는 staging_not_applicable marker 자동 set 안 함.
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(project_id="P1", episode_id="E1")
        ctx.scene_visible = {12: ["C01"]}
        # staging_map 빈 — shot path 에서 staging 부재.
        ctx.staging_map = {}
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        # shot path 에서 staging None 그대로 — marker 자동 set X.
        assert out["staging"] is None
        # shot_info 도 그대로 (marker 부착 X).
        assert "staging_not_applicable" not in (out["shot_info"] or {})

    def test_b4_legacy_path_synthesizes_marker_when_shot_info_none(self) -> None:
        # B4: scene-level / legacy (shot_info=None) → marker synthesize OK.
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(project_id="P1", episode_id="E1")
        ctx.scene_visible = {12: ["C01"]}
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info=None,
        )
        # legacy path → marker synthesize.
        assert (out["shot_info"] or {}).get("staging_not_applicable") is True

    def test_b4_shot_path_missing_staging_builder_raises(self) -> None:
        # End-to-end B4 contract: shot path + missing staging → builder
        # AppError(step.contract_violation). silent fallback 차단.
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(project_id="P1", episode_id="E1")
        ctx.scene_visible = {12: ["C01"]}
        ctx.staging_map = {}
        out = _collect_card_inputs(
            ctx=ctx, seg={"scene_index": 12},
            shot_info={"shot_index": 4},
        )
        builder_inputs = {k: v for k, v in out.items() if k != "ctx"}
        with pytest.raises(AppError) as ei:
            build_render_prompt_card(**builder_inputs)
        assert ei.value.code == "step.contract_violation"
        assert "staging" in ei.value.message.lower()


# =========================================================================
# Area B (2026-05-13, Task 6): caller wiring — visible_entity_details +
# metadata_json.visual_identity.reference_required → render_contracts →
# required_refs propagate. synthetic fixtures only — P##/C##/L## with
# ^P[0-9]+$ pattern enforced.
# =========================================================================
class TestPatchACallerWiring:
    def test_build_rpc_propagates_visible_entity_details(self):
        """build_render_prompt_card 가 visible_entity_details 를
        build_render_contracts → required_refs_from_render_contracts 까지
        propagate — prop required_refs 도달 (Area B render_contracts path)."""
        card = build_render_prompt_card(
            scene_index=92, shot_index=5,
            seg={"scene_index": 92},
            # legacy / scene-level path marker — staging not applicable.
            shot_info={"staging_not_applicable": True},
            visible_entities=["C91", "P91", "L93"],
            visible_entity_details=[
                {"short_id": "C91", "name": "character_a",
                 "entity_type": "character", "t2i_prompt": "...",
                 "metadata_json": {
                     "location": None, "visual_identity": None,
                 }},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...black and white photograph...",
                 "metadata_json": {
                     "location": None,
                     "visual_identity": {"reference_required": True},
                 }},
                {"short_id": "L93", "name": "indoor_space_b",
                 "entity_type": "location", "t2i_prompt": "...",
                 "metadata_json": {
                     "location": {"space_profile": {
                         "kind": "single_space",
                         "allowed_space_keys": ["main"],
                         "default_space_key": None,
                     }},
                     "visual_identity": None,
                 }},
            ],
            outlook_pairs=[], perception_mode=None, staging=None,
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=True, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        required = card["asset_requirements"]["required_refs"]
        assert {"kind": "prop", "id": "P91", "policy": "required"} in required

    def test_resolver_wiring_with_required_refs(self):
        """resolver 호출자가 RPC.asset_requirements.required_refs 를 resolver 에
        전달 + Tier 2 강제 attach 결과를 attached_meta 에 포함."""
        from app.services.scene_reference_service import SceneReferenceService
        from unittest.mock import MagicMock
        instance = SceneReferenceService.__new__(SceneReferenceService)
        instance._db = MagicMock()
        instance._db.query.return_value.filter.return_value.all.return_value = []
        instance._project_id = "proj_synthetic_a"
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        # Area #11 v1 W3: resolve_refs_for_prompt 2-tuple → LabeledRefPayload (W2 cascade).
        payload = instance.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        assert ("prop", "P91") in payload.attached_meta
        assert any("P91" in label and "(required)" in label
                   for label, _ in payload.labeled_refs)
