"""shot_director frame-visible SOT unification — 2026-05-13.

`visible_entity_ids` semantic 을 "scene-present" 가 아닌 "camera-frame-visible"
로 통일. character / location / prop 모두 동일 기준 — LLM SOT (open-world
semantic 판단).

이전 결함: ``_resolve_scene_no_variant`` (deterministic path) 가 L/P 를
``scene_ve`` 에서 무조건 keep 했음. ECU/CU/macro framing 에서 frame 밖 prop /
location 도 visible 에 포함 → Area B render_contracts ↔ LLM frame 정확 묘사
충돌 surface.

본 fix: deterministic path 제거 + 항상 LLM 호출. system.md v5 (5.202605131800)
는 L/P frame-visible 규칙 명시 강화.

Tests 모두 시나리오 의존 0 — synthetic names (Subject Alpha/Beta/Gamma,
C91/L91/P91/P92).
"""
from __future__ import annotations

import json as _json
from typing import Any, Dict, List

import pytest


# ---------------------------------------------------------------------------
# Invariant: direct_shots 는 variant 유무와 무관하게 항상 LLM path 호출
# ---------------------------------------------------------------------------


def _make_minimal_inputs(
    *,
    scene_ve: List[str],
    shots: List[Dict[str, Any]],
    entity_name_map: Dict[str, str],
    relations: List[Dict] = None,
) -> Dict[str, Any]:
    """direct_shots() 의 최소 input 묶음 (synthetic)."""
    relations = relations or []
    # entities 형식은 short_id + name + description 인 list 셋.
    characters: List[Dict] = []
    locations: List[Dict] = []
    props: List[Dict] = []
    for sid, name in entity_name_map.items():
        ent = {"short_id": sid, "name": name, "description": f"{sid} description"}
        if sid.startswith("C"):
            characters.append(ent)
        elif sid.startswith("L"):
            locations.append(ent)
        elif sid.startswith("P"):
            props.append(ent)
    entities = {"characters": characters, "locations": locations, "props": props}

    scene_index = shots[0]["scene_index"] if shots else 1
    scene_director_data = {
        "scenes": [{"scene_index": scene_index, "present_entity_ids": scene_ve}],
    }
    shot_extract_data = {
        "scenes": [{"scene_index": scene_index, "shots": shots}],
    }
    segments = [{"scene_index": scene_index, "text": "scene text placeholder"}]
    return {
        "segments": segments,
        "scene_director_data": scene_director_data,
        "shot_extract_data": shot_extract_data,
        "shot_selection_data": None,
        "entity_relation_data": {"relations": relations},
        "entities": entities,
    }


@pytest.fixture
def stub_llm_io(monkeypatch):
    """call_structured / load_prompt / load_schema 를 module level 에서 mock.

    리턴: list[dict] — 각 invocation 에 대한 call_structured kwargs 캡쳐.
    """
    import app.modules.pipeline.shot_director as sd_mod

    calls: List[Dict[str, Any]] = []

    # 기본 mock — 각 test 가 별도로 override 가능.
    def _default_call(**kwargs):
        calls.append(kwargs)
        return {"shots": []}

    monkeypatch.setattr(sd_mod, "call_structured", _default_call)
    monkeypatch.setattr(sd_mod, "load_prompt", lambda *a, **k: "stub")
    monkeypatch.setattr(
        sd_mod,
        "load_schema",
        lambda *a, **k: {
            "type": "object",
            "properties": {
                "shots": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "visible_entity_ids": {
                                "type": "array",
                                "items": {"type": "string"},
                            }
                        },
                    },
                }
            },
        },
    )
    return calls


def test_direct_shots_always_calls_llm_even_when_no_variants(monkeypatch, stub_llm_io):
    """Invariant lock: variant pair 가 없어도 LLM path 호출 (deterministic
    no-variant path 영구 폐기 — frame-visible SOT 는 LLM 만 판정).
    """
    from app.modules.pipeline.shot_director import direct_shots

    # variant pair 없음 — relations=[] 이라 _scene_has_variants → False 였던 경로.
    scene_ve = ["C91", "L91", "P91"]
    shots = [
        {"shot_index": 1, "scene_index": 1, "description": "wide shot of two subjects in a courtyard."},
    ]
    entity_name_map = {
        "C91": "Subject Alpha", "L91": "Courtyard", "P91": "Red Vehicle",
    }

    # LLM mock 이 합리적 응답 (synthetic frame-visible).
    def _fake_call(**kwargs):
        stub_llm_io.append(kwargs)
        return {
            "shots": [
                {"shot_index": 1, "visible_entity_ids": ["C91", "L91", "P91"], "variant_resolved": None},
            ],
        }

    import app.modules.pipeline.shot_director as sd_mod
    monkeypatch.setattr(sd_mod, "call_structured", _fake_call)

    inputs = _make_minimal_inputs(
        scene_ve=scene_ve, shots=shots, entity_name_map=entity_name_map, relations=[],
    )
    result = direct_shots(**inputs)

    # invariant: LLM 가 호출됨 (no-variant 라도).
    assert len(stub_llm_io) == 1, f"LLM 호출 횟수 정확히 1: {len(stub_llm_io)}"
    # invariant: skipped_scenes counter 가 더 이상 deterministic skip 카운트가
    # 아닌 0 (모든 scene LLM 경로). counter field 자체는 호환 위해 남겨도
    # 의미만 변경 — 0 보장.
    assert result["skipped_scenes"] == 0, result
    assert result["llm_called_scenes"] == 1, result


# ---------------------------------------------------------------------------
# Frame-visible closed assertions — synthetic names
# ---------------------------------------------------------------------------


def test_direct_shots_ecu_lp_excluded_when_llm_says_so(monkeypatch, stub_llm_io):
    """ECU shot — scene 에 L91/P91/P92 있어도 LLM 가 frame 안 entity 만 visible
    로 emit 하면 그대로 보존. deterministic path 의 "L/P always keep" 회귀 차단.

    closed assertion: 정확히 [C91].
    """
    from app.modules.pipeline.shot_director import direct_shots

    scene_ve = ["C91", "L91", "P91", "P92"]
    shots = [
        {
            "shot_index": 1, "scene_index": 1,
            "description": "Tight ECU on Subject Alpha's face, background fully out of focus and no object visible.",
        },
    ]
    entity_name_map = {
        "C91": "Subject Alpha", "L91": "Office",
        "P91": "Phone", "P92": "Photograph",
    }

    def _fake_call(**kwargs):
        stub_llm_io.append(kwargs)
        return {
            "shots": [
                {"shot_index": 1, "visible_entity_ids": ["C91"], "variant_resolved": None},
            ],
        }

    import app.modules.pipeline.shot_director as sd_mod
    monkeypatch.setattr(sd_mod, "call_structured", _fake_call)

    inputs = _make_minimal_inputs(
        scene_ve=scene_ve, shots=shots, entity_name_map=entity_name_map, relations=[],
    )
    result = direct_shots(**inputs)

    scene_out = result["scenes"][0]
    shot_out = scene_out["shots"][0]
    assert shot_out["visible_entity_ids"] == ["C91"], shot_out
    # audit field 보존 (현 path 에선 gaze 패턴 무관 — 빈 list).
    assert shot_out["excluded_offscreen_entity_ids"] == [], shot_out


def test_direct_shots_wide_shot_keeps_lp_when_llm_says_so(monkeypatch, stub_llm_io):
    """Wide shot — LLM 가 frame 에 보이는 L/P 모두 emit → 그대로 보존.

    closed assertion: 정확히 [C91, L91, P91].
    """
    from app.modules.pipeline.shot_director import direct_shots

    scene_ve = ["C91", "L91", "P91", "P92"]
    shots = [
        {
            "shot_index": 1, "scene_index": 1,
            "description": "Wide shot of Subject Alpha standing beside the red vehicle in the courtyard.",
        },
    ]
    entity_name_map = {
        "C91": "Subject Alpha", "L91": "Courtyard",
        "P91": "Red Vehicle", "P92": "Mailbox",
    }

    def _fake_call(**kwargs):
        stub_llm_io.append(kwargs)
        return {
            "shots": [
                {"shot_index": 1, "visible_entity_ids": ["C91", "L91", "P91"], "variant_resolved": None},
            ],
        }

    import app.modules.pipeline.shot_director as sd_mod
    monkeypatch.setattr(sd_mod, "call_structured", _fake_call)

    inputs = _make_minimal_inputs(
        scene_ve=scene_ve, shots=shots, entity_name_map=entity_name_map, relations=[],
    )
    result = direct_shots(**inputs)

    shot_out = result["scenes"][0]["shots"][0]
    assert shot_out["visible_entity_ids"] == ["C91", "L91", "P91"], shot_out


# ---------------------------------------------------------------------------
# Defense-in-depth — LLM 가 gaze target 을 visible 에 잘못 포함해도 post-process
# 가 deterministic 제외. (synthetic Korean gaze pattern.)
# ---------------------------------------------------------------------------


def test_direct_shots_llm_path_emits_gaze_diagnostic_without_mutation(monkeypatch, stub_llm_io):
    """Area #3 W2 — LLM 가 gaze target 을 visible 에 포함했더라도 mutation 없이
    그대로 유지 (LLM emit SOT). detect_gaze_pattern_exclusions 결과는
    excluded_offscreen_entity_ids audit field 로 emit, mismatch 시
    logger.warning only.
    """
    from app.modules.pipeline.shot_director import direct_shots

    # synthetic Korean canonical names — gaze pattern 매치용.
    scene_ve = ["C91", "C92", "L91"]
    shots = [
        {
            "shot_index": 1, "scene_index": 1,
            # gaze 패턴: "캐릭터알파를 바라보는 캐릭터베타의 얼굴 클로즈업"
            "description": "캐릭터알파를 바라보는 캐릭터베타의 얼굴 클로즈업",
        },
    ]
    entity_name_map = {
        "C91": "캐릭터알파", "C92": "캐릭터베타", "L91": "Office",
    }

    def _fake_call(**kwargs):
        stub_llm_io.append(kwargs)
        # LLM 가 C91 (gaze target) 도 포함 → post-W2: mutation 없음, audit 만.
        return {
            "shots": [
                {"shot_index": 1, "visible_entity_ids": ["C91", "C92"], "variant_resolved": None},
            ],
        }

    import app.modules.pipeline.shot_director as sd_mod
    monkeypatch.setattr(sd_mod, "call_structured", _fake_call)

    inputs = _make_minimal_inputs(
        scene_ve=scene_ve, shots=shots, entity_name_map=entity_name_map, relations=[],
    )
    result = direct_shots(**inputs)

    shot_out = result["scenes"][0]["shots"][0]
    # Area #3 W2: LLM emit SOT — C91 유지 (mutation 없음).
    assert "C91" in shot_out["visible_entity_ids"], shot_out
    assert "C92" in shot_out["visible_entity_ids"], shot_out
    # audit field 에 lexicon diagnostic candidates 그대로 emit.
    assert "C91" in shot_out["excluded_offscreen_entity_ids"], shot_out


# ---------------------------------------------------------------------------
# Deterministic path 영구 폐기 — `_resolve_scene_no_variant` 부재 lock
# ---------------------------------------------------------------------------


def test_deterministic_no_variant_path_function_removed():
    """`_resolve_scene_no_variant` 함수가 module 에서 제거되었는지 확인.

    재도입 시도 회귀 차단 — frame-visible SOT 는 LLM 만 판정 (open-world
    semantic 은 code heuristic 으로 결정 불가).
    """
    import app.modules.pipeline.shot_director as sd_mod
    assert not hasattr(sd_mod, "_resolve_scene_no_variant"), (
        "_resolve_scene_no_variant 가 재도입됨 — frame-visible SOT 결함 회귀. "
        "L/P frame visibility 는 LLM SOT 만으로 판정. "
        "ref: session_20260513_shot_director_frame_visible_sot_fix.md"
    )


def test_resolve_scene_llm_function_renamed():
    """`_resolve_scene_with_variants_llm` → `_resolve_scene_llm` 으로 통합되어
    variant 유무와 무관하게 같은 함수가 LLM path 를 책임진다.
    """
    import app.modules.pipeline.shot_director as sd_mod
    assert hasattr(sd_mod, "_resolve_scene_llm"), (
        "_resolve_scene_llm 부재 — frame-visible SOT unification 시 "
        "_resolve_scene_with_variants_llm 의 이름을 통합 형태로 변경 의무. "
        "함수 자체 부재 시 import 실패."
    )
    # legacy 이름은 alias 형태로도 잔존 금지 (call-site cleanup 일관성).
    assert not hasattr(sd_mod, "_resolve_scene_with_variants_llm"), (
        "_resolve_scene_with_variants_llm 잔존 — 이름 통합 후 alias 도 폐기 의무."
    )


# ---------------------------------------------------------------------------
# LLM contract — missing shot 시 fail-fast (Codex review Issue 1 fix-up)
#
# 이전 _resolve_scene_with_variants_llm 의 fallback path 가 누락 shot 에
# ``list(scene_ve)`` 를 synthesize 했음. 그건 scene-present L/P 의미
# 그대로 — 본 patch 가 영구 폐기한 결함이라 단일 shot 누락만으로도
# Area B PRO-13 재발 가능. fail-fast 로 contract 위반 surface.
# ---------------------------------------------------------------------------


def test_resolve_scene_llm_raises_on_missing_shot_no_silent_fallback(monkeypatch):
    """LLM 가 selected shot 중 일부를 응답에서 누락하면 fail-fast.

    이전 fallback 동작 (scene_ve 전체 synthesize) 은 frame-visible SOT 와
    충돌 — 재도입 회귀 차단.
    """
    import pytest
    from app.core.errors import AppError
    import app.modules.pipeline.shot_director as sd_mod

    def _fake_call_structured(**kwargs):
        # shot_index=2 만 emit (1 누락).
        return {
            "shots": [
                {"shot_index": 2, "visible_entity_ids": ["C92"], "variant_resolved": {"C91": "C92"}},
            ],
        }

    monkeypatch.setattr(sd_mod, "call_structured", _fake_call_structured)
    monkeypatch.setattr(sd_mod, "load_prompt", lambda *a, **k: "stub")
    monkeypatch.setattr(
        sd_mod, "load_schema",
        lambda *a, **k: {
            "type": "object",
            "properties": {
                "shots": {"type": "array", "items": {
                    "type": "object",
                    "properties": {"visible_entity_ids": {"items": {}}},
                }},
            },
        },
    )

    shots = [
        {"shot_index": 1, "description": "shot 1 description"},
        {"shot_index": 2, "description": "shot 2 description"},
    ]
    with pytest.raises(AppError) as exc:
        sd_mod._resolve_scene_llm(
            scene_index=99,
            scene_ve=["C91", "C92"],
            shots=shots,
            scene_text="scene text",
            variant_map={"C91": "C92"},
            entity_name_map={"C91": "Subject Alpha", "C92": "Subject Alpha (variant)"},
            entity_desc_map={"C91": "base form", "C92": "variant form"},
        )
    # 에러 코드 + 메시지 lock — silent fallback 재도입 시 grep 으로 검출.
    assert exc.value.code == "shot_director.llm_missed_shots", exc.value.code
    assert "shot index [1]" in exc.value.message, exc.value.message


def test_resolve_scene_llm_no_synthesized_scene_ve_in_visible(monkeypatch):
    """패치된 path 는 어떤 shot 의 visible_entity_ids 도 ``list(scene_ve)`` 그대로
    synthesize 하지 않는다. LLM 응답 그대로 emit (+ defense-in-depth gaze 제외).

    이는 Issue 1 fix-up 의 정밀 invariant — LLM 가 모든 shot emit 시 어떤 shot
    의 visible 도 scene_ve 전체와 같지 않다는 negative test (특정 shot 의 LLM
    응답이 scene_ve 와 다르면 결과도 다르다).
    """
    import app.modules.pipeline.shot_director as sd_mod

    def _fake_call_structured(**kwargs):
        return {
            "shots": [
                {"shot_index": 1, "visible_entity_ids": ["C91"], "variant_resolved": None},
                {"shot_index": 2, "visible_entity_ids": ["C92"], "variant_resolved": {"C91": "C92"}},
            ],
        }

    monkeypatch.setattr(sd_mod, "call_structured", _fake_call_structured)
    monkeypatch.setattr(sd_mod, "load_prompt", lambda *a, **k: "stub")
    monkeypatch.setattr(
        sd_mod, "load_schema",
        lambda *a, **k: {
            "type": "object",
            "properties": {
                "shots": {"type": "array", "items": {
                    "type": "object",
                    "properties": {"visible_entity_ids": {"items": {}}},
                }},
            },
        },
    )

    shots = [
        {"shot_index": 1, "description": "ECU on Subject Alpha"},
        {"shot_index": 2, "description": "ECU on Subject Alpha variant"},
    ]
    scene_ve = ["C91", "C92"]
    result = sd_mod._resolve_scene_llm(
        scene_index=1,
        scene_ve=scene_ve,
        shots=shots,
        scene_text="scene text",
        variant_map={"C91": "C92"},
        entity_name_map={"C91": "Subject Alpha", "C92": "Subject Alpha (variant)"},
        entity_desc_map={"C91": "base", "C92": "variant"},
    )

    # closed: 각 shot 의 visible 은 LLM 응답대로, scene_ve 합성 아님.
    by_idx = {s["shot_index"]: s for s in result}
    assert by_idx[1]["visible_entity_ids"] == ["C91"], by_idx[1]
    assert by_idx[2]["visible_entity_ids"] == ["C92"], by_idx[2]
