"""C10 Phase 1 — screen-presence reconciliation tests.

Detector (Task 1) + downgrade helper (Task 2) + card wiring (Task 3).
순수 함수 — DB/LLM/FS 없음.
"""
from __future__ import annotations

from app.modules.pipeline.shot_visibility import (
    detect_offscreen_referenced_subjects,
)
from app.core.subject_reference_policy import apply_screen_presence_downgrade
from app.core.steps.render_prompt_card import (
    build_render_prompt_card,
    compute_card_hash,
)
from app.core.steps.detail_steps import _collect_card_inputs

# S15_Shot5 실데이터 기반 fixture (project 8d56bc5d). C01=수리영, C02=혜수.
_S15_CAM = (
    "From the bus stop at chest height, the camera holds in a static medium "
    "frame after the pan back, keeping 혜수 fixed on the left third while the "
    "empty coastal road stretches away behind her into fog. Her cupped hands "
    "and open mouth point toward the off-screen direction where 수리영 has run, "
    "making the absence feel watched rather than resolved."
)
_S15_ANGLES = [
    {"character": "혜수", "angle": "profile_right", "body_pose": "hands cupped",
     "gaze_direction_kind": "off_screen", "gaze_target_id": None,
     "subject_state": "alive"},
]
_ID_TO_NAME = {"C01": "수리영", "C02": "혜수"}


class TestDetector:
    def test_d1_structural_offscreen_positive(self) -> None:
        """D1: visible C01, character_angles 부재, non-POV, OFFSCREEN_RE 매치 → emit."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "C01" in out
        assert "C02" not in out  # 혜수는 character_angles 에 있음 (in-frame)

    def test_d2_gaze_offscreen_not_a_signal(self) -> None:
        """D2: character_angles 에 있는 인물은 gaze 가 off_screen 이어도 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,  # 혜수 gaze=off_screen 이지만 in-frame
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d3_pov_guard(self) -> None:
        """D3: character_angles 부재라도 POV 인물이면 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="수리영",
        )
        assert "C01" not in out

    def test_d4_empty_character_angles_noop(self) -> None:
        """D4: character_angles=[] → 아무도 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=[],
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d5_no_offscreen_phrase_noop(self) -> None:
        """D5: camera_direction 에 OFFSCREEN_RE 매치 없음 → emit 없음."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction="A calm medium shot of 혜수 on the road.",
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d6_in_frame_member_not_emitted(self) -> None:
        """D6: character_angles 에 back_to_camera 로 존재하면 emit 안 함."""
        angles = _S15_ANGLES + [
            {"character": "수리영", "angle": "back_to_camera",
             "body_pose": "walking away", "gaze_direction_kind": "distant",
             "gaze_target_id": None, "subject_state": "alive"},
        ]
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=angles,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "C01" not in out

    def test_d7_confidence_named_vs_structural(self) -> None:
        """D7: camera_direction 에 이름 있으면 confidence=named, 없으면 structural."""
        named = detect_offscreen_referenced_subjects(
            visible_ids=["C01"], camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES, id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "confidence=named" in named["C01"]
        structural = detect_offscreen_referenced_subjects(
            visible_ids=["C01"],
            camera_direction="Her hands point toward the off-screen road.",
            character_angles=_S15_ANGLES, id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "confidence=structural" in structural["C01"]


class TestDowngradeHelper:
    _DETECTED = {"C01": "screen_presence_reconciliation: ... (confidence=named)"}

    def test_t2_1_inject_when_no_entry(self) -> None:
        """T2-1: 기존 entry 없음 → generic_descriptor_allowed inject."""
        out = apply_screen_presence_downgrade([], self._DETECTED, where="t")
        assert len(out) == 1
        assert out[0]["subject_id"] == "C01"
        assert out[0]["policy"] == "generic_descriptor_allowed"
        assert out[0]["policy_type"] == "identity_reference"
        assert out[0]["reason"].strip()

    def test_t2_2_downgrade_id_and_outlook_required(self) -> None:
        """T2-2: explicit id_and_outlook_required → generic_descriptor_allowed."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "id_and_outlook_required", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "generic_descriptor_allowed"
        assert "id_and_outlook_required" in c01["reason"]  # provenance 보존

    def test_t2_3_keep_explicit_base_id_required(self) -> None:
        """T2-3 (G5): explicit base_id_required 는 보존."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "base_id_required", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "base_id_required"

    def test_t2_4_keep_explicit_generic(self) -> None:
        """T2-4: 이미 generic_descriptor_allowed 면 그대로."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "generic_descriptor_allowed", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "generic_descriptor_allowed"
        assert c01["reason"] == "producer"  # 미변경

    def test_t2_5_non_list_passthrough(self) -> None:
        """T2-5: raw_items 가 list 아니면 그대로 반환 (normalize 가 fail-fast)."""
        assert apply_screen_presence_downgrade(None, self._DETECTED, where="t") is None

    def test_t2_6_no_detection_noop(self) -> None:
        """T2-6: detected 비어있으면 입력 그대로."""
        raw = [{"subject_id": "C02", "policy_type": "identity_reference",
                "policy": "id_and_outlook_required", "reason": "p"}]
        out = apply_screen_presence_downgrade(raw, {}, where="t")
        assert out == raw


def _build_card(*, visible_entities, staging, name_by_short_id, outlook_pairs):
    """staging 경로 build_render_prompt_card 호출 — Phase 1 카드 테스트용."""
    return build_render_prompt_card(
        scene_index=15, shot_index=5,
        seg={}, shot_info={"shot_index": 5, "camera_direction": "x"},
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=None,
        staging=staging,
        bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=False, background_mode_on=False,
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        name_by_short_id=name_by_short_id,
    )


def _s15_staging(*, subject_reference_policy=None, character_angles=None,
                 pov_character=""):
    return {
        "camera_direction": _S15_CAM,
        "framing_scale": "medium",
        "lighting_mood": "cold fog",
        "key_bg_elements": [],
        "pov_character": pov_character,
        "character_angles": _S15_ANGLES if character_angles is None
        else character_angles,
        "subject_reference_policy": [] if subject_reference_policy is None
        else subject_reference_policy,
    }


def _srp_array(card):
    return card["id_policy"]["subject_reference_policy"]


class TestCardReconciliation:
    def test_g1_s15_reproduction(self) -> None:
        """G1: S15 — C01 다운그레이드, C02(in-frame, gaze off_screen) 불변."""
        card = _build_card(
            visible_entities=["C01", "C02"],
            staging=_s15_staging(),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        srp = {e["subject_id"]: e["policy"] for e in _srp_array(card)}
        assert srp.get("C01") == "generic_descriptor_allowed"
        assert srp.get("C02") != "generic_descriptor_allowed"  # 미다운그레이드
        # G8: asset_requirements 에 C01 required ref 없음.
        req_ids = [r.get("id", "") for r
                   in card["asset_requirements"]["required_refs"]]
        assert not any(rid.startswith("C01") for rid in req_ids)

    def test_g4_in_frame_back_view_not_downgraded(self) -> None:
        """G4: C01 이 character_angles 에 back_to_camera 로 존재 → 미다운그레이드."""
        angles = _S15_ANGLES + [
            {"character": "수리영", "angle": "back_to_camera",
             "body_pose": "walking away", "gaze_direction_kind": "distant",
             "gaze_target_id": None, "subject_state": "alive"},
        ]
        card = _build_card(
            visible_entities=["C01", "C02"],
            staging=_s15_staging(character_angles=angles),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        srp = {e["subject_id"]: e["policy"] for e in _srp_array(card)}
        assert srp.get("C01") != "generic_descriptor_allowed"

    def test_g7_hash_stable(self) -> None:
        """G7: 같은 입력 → 같은 카드 hash (producer/verify 경로 정합)."""
        kw = dict(
            visible_entities=["C01", "C02"], staging=_s15_staging(),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        assert compute_card_hash(_build_card(**kw)) == compute_card_hash(
            _build_card(**kw))

    def test_g10_staging_none_noop(self) -> None:
        """G10: staging=None (not-applicable) → crash 없음, 다운그레이드 없음."""
        card = build_render_prompt_card(
            scene_index=1, shot_index=1, seg={},
            shot_info={"staging_not_applicable": True},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
            perception_mode=None, staging=None,
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
            name_by_short_id=_ID_TO_NAME,
        )
        assert _srp_array(card) == []

    def test_g11_name_map_threading_ctx_path(self) -> None:
        """G11: ctx-derived 경로(_derive_card_inputs_from_ctx)가 name_by_short_id
        를 빌더 입력에 전달 — production / verify recompute 의 실경로.
        verify hash drift 방지를 직접 검증 (legacy fallback 검증으로 불충분).

        SceneAnalysisContext 는 모든 필드 default → name_by_short_id 만 지정해
        구성 가능 (backend/app/core/dto/scene_analysis.py).
        """
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(name_by_short_id=_ID_TO_NAME)
        inputs = _collect_card_inputs(
            ctx=ctx,  # 실 SceneAnalysisContext → _derive_card_inputs_from_ctx 경로
            seg={"scene_index": 15}, shot_info={"shot_index": 5},
        )
        assert inputs["name_by_short_id"] == _ID_TO_NAME

    def test_g11b_name_map_threading_legacy_path(self) -> None:
        """G11b: legacy fallback 경로(ctx 가 SceneAnalysisContext 아님)도 명시
        name_by_short_id kwarg 를 빌더 입력에 전달."""
        inputs = _collect_card_inputs(
            ctx=object(),  # opaque → legacy fallback 경로
            seg={"scene_index": 15}, shot_info={"shot_index": 5},
            name_by_short_id=_ID_TO_NAME,
        )
        assert inputs["name_by_short_id"] == _ID_TO_NAME
