"""shot_ref_classify — bgonly·prev v3·time_of_day 분류 결정론 로직 테스트.

LLM 은 fake call_structured — 판정 품질은 여기서 주장하지 않는다.
검증 대상: bgonly 의 prev 강제 무효, prev 유효성(앞 샷·목록 내), 누락
fail-fast, payload 형태, 스텝 등록/적용성.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

from app.core.errors import AppError
from app.modules.pipeline.shot_ref_classify import (
    parse_tag,
    run_shot_ref_classify,
    tag_of,
)


SHOTS = [
    {"scene_index": 1, "shot_index": 1, "description": "빈 방, 벽의 표식"},
    {"scene_index": 1, "shot_index": 2, "description": "남자가 문을 연다"},
    {"scene_index": 2, "shot_index": 1, "description": "같은 방, 남자 클로즈업"},
]
SCENE_TEXTS = {1: "씬1 원문 전문...", 2: "씬2 원문 전문..."}
SCENE_HEADINGS = {1: "S#1. 방 (밤)", 2: "S#2. 방 (밤)"}
LOC_BY_SCENE = {1: "허름한 방", 2: "허름한 방"}


class FakeLLM:
    """schema_name 별 canned 응답 + 호출 기록."""

    def __init__(self, bgonly=None, prev=None, tod=None):
        self.calls: List[Dict[str, Any]] = []
        self._resp = {
            "shot_ref_classify_bgonly": bgonly
            or {
                "items": [
                    {"shot": "S1sh1", "person_visible": False, "reason_ko": "공간만"},
                    {"shot": "S1sh2", "person_visible": True, "reason_ko": "남자"},
                    {"shot": "S2sh1", "person_visible": True, "reason_ko": "클로즈업"},
                ]
            },
            "shot_ref_classify_prev": prev
            or {
                "items": [
                    {"shot": "S1sh1", "prev": None, "reason_ko": "첫 샷", "usage_en": ""},
                    {"shot": "S1sh2", "prev": "S1sh1", "reason_ko": "같은 방", "usage_en": "TAKE the room look. EXCLUDE nothing."},
                    {"shot": "S2sh1", "prev": "S1sh2", "reason_ko": "같은 방", "usage_en": "TAKE the room. EXCLUDE the door."},
                ]
            },
            "shot_ref_classify_time_of_day": tod
            or {
                "items": [
                    {"scene_index": 1, "time_of_day_en": "night", "basis_ko": "헤딩"},
                    {"scene_index": 2, "time_of_day_en": "night", "basis_ko": "헤딩"},
                ]
            },
        }

    def __call__(self, step_tag, system, user, schema, **kw):
        self.calls.append(
            {"step_tag": step_tag, "system": system, "user": user, "kw": kw}
        )
        return self._resp[step_tag]


def run(llm: FakeLLM):
    return run_shot_ref_classify(
        shots=SHOTS,
        scene_texts=SCENE_TEXTS,
        scene_headings=SCENE_HEADINGS,
        location_by_scene=LOC_BY_SCENE,
        call_structured_fn=llm,
    )


def test_tag_roundtrip():
    assert tag_of(3, 12) == "S3sh12"
    assert parse_tag("S3sh12") == (3, 12)


def test_payload_shape_and_values():
    llm = FakeLLM()
    out = run(llm)
    assert set(out["shots"]) == {"S1sh1", "S1sh2", "S2sh1"}
    s12 = out["shots"]["S1sh2"]
    assert s12["person_visible"] is True
    assert s12["prev"] == "S1sh1"
    assert "TAKE" in s12["usage_en"]
    assert out["scenes"]["1"]["time_of_day_en"] == "night"
    assert out["scenes"]["2"]["basis_ko"] == "헤딩"


def test_bgonly_forces_prev_null():
    """배경 전용 샷은 prev 판정이 있어도 무시 (플레이트 강제) — s39 규칙."""
    llm = FakeLLM(
        prev={
            "items": [
                {"shot": "S1sh1", "prev": None, "reason_ko": "", "usage_en": ""},
                {"shot": "S1sh2", "prev": "S1sh1", "reason_ko": "", "usage_en": "TAKE x. EXCLUDE y."},
                {"shot": "S2sh1", "prev": "S1sh2", "reason_ko": "", "usage_en": "TAKE x. EXCLUDE y."},
            ]
        },
        bgonly={
            "items": [
                {"shot": "S1sh1", "person_visible": False, "reason_ko": ""},
                {"shot": "S1sh2", "person_visible": False, "reason_ko": "그림자만"},
                {"shot": "S2sh1", "person_visible": True, "reason_ko": ""},
            ]
        },
    )
    out = run(llm)
    assert out["shots"]["S1sh2"]["prev"] is None  # bgonly → prev 무효
    assert out["shots"]["S1sh2"]["usage_en"] == ""
    assert out["shots"]["S2sh1"]["prev"] == "S1sh2"  # 인물 샷은 유지


def test_prev_must_be_earlier_shot():
    """뒤 샷/자기 자신 지정 → null + 위반 기록 (production 은 격리, 실험은 abort)."""
    llm = FakeLLM(
        prev={
            "items": [
                {"shot": "S1sh1", "prev": "S2sh1", "reason_ko": "", "usage_en": "x"},
                {"shot": "S1sh2", "prev": "S1sh2", "reason_ko": "", "usage_en": "x"},
                {"shot": "S2sh1", "prev": None, "reason_ko": "", "usage_en": ""},
            ]
        }
    )
    out = run(llm)
    assert out["shots"]["S1sh1"]["prev"] is None
    assert out["shots"]["S1sh1"]["prev_violation"]
    assert out["shots"]["S1sh2"]["prev"] is None
    assert out["shots"]["S1sh2"]["prev_violation"]


def test_prev_not_in_list_rejected():
    llm = FakeLLM(
        prev={
            "items": [
                {"shot": "S1sh1", "prev": None, "reason_ko": "", "usage_en": ""},
                {"shot": "S1sh2", "prev": "S0sh9", "reason_ko": "", "usage_en": "x"},
                {"shot": "S2sh1", "prev": None, "reason_ko": "", "usage_en": ""},
            ]
        }
    )
    out = run(llm)
    assert out["shots"]["S1sh2"]["prev"] is None
    assert out["shots"]["S1sh2"]["prev_violation"]


def test_missing_shot_fails():
    llm = FakeLLM(
        bgonly={
            "items": [
                {"shot": "S1sh1", "person_visible": False, "reason_ko": ""},
                # S1sh2 / S2sh1 누락
            ]
        }
    )
    with pytest.raises(AppError):
        run(llm)


def test_user_prompt_carries_location_and_full_text():
    """prev 판정 입력에 씬 원문 전문 + 샷별 LOCATION 병기 (자르기 금지)."""
    llm = FakeLLM()
    run(llm)
    prev_call = next(
        c for c in llm.calls if c["step_tag"] == "shot_ref_classify_prev"
    )
    assert "씬1 원문 전문..." in prev_call["user"]
    assert "씬2 원문 전문..." in prev_call["user"]
    assert "[LOCATION: 허름한 방]" in prev_call["user"]
    assert "S1sh2" in prev_call["user"]


def test_step_registered():
    from app.core.step_catalog import STEP_CATALOG
    from app.core.steps import STEP_CLASSES

    assert "shot_ref_classify" in STEP_CLASSES
    entry = STEP_CATALOG["shot_ref_classify"]
    assert entry.applicability == "if_still_recipe"
    assert "shot_validator" in entry.depends_on


def test_applicability_gate(monkeypatch):
    from app.core import applicability as ap
    from app.core.config import settings

    fn = ap.APPLICABILITY_VALIDATORS["if_still_recipe"]
    monkeypatch.setattr(settings, "still_recipe_mode", "off")
    assert fn(None) is False
    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    assert fn(None) is True


# ── v3 (2026-07-19 fix2): 샷별 place 저작 — 서브공간 특정 ─────────────


class FakeLLMv3(FakeLLM):
    def __init__(self, shot_place=None, **kw):
        super().__init__(**kw)
        self._resp["shot_ref_classify_world_anchor"] = {
            "world_anchor_en": "SAMPLE anchor"
        }
        self._resp["shot_ref_classify_shot_place"] = shot_place or {
            "items": [
                {"shot": "S1sh1",
                 "place_en": "Outside the SAMPLE building, by its wall.",
                 "environment": "exterior", "basis_ko": "원문"},
                {"shot": "S1sh2",
                 "place_en": "Inside the SAMPLE room, at the door.",
                 "environment": "interior", "basis_ko": "원문"},
                {"shot": "S2sh1",
                 "place_en": "Inside the SAMPLE room.",
                 "environment": "interior", "basis_ko": "원문"},
            ]
        }
        # v3 time_of_day 스키마는 place_en 동반
        for it in self._resp["shot_ref_classify_time_of_day"]["items"]:
            it.setdefault("place_en", "SAMPLE scene place.")


def test_v3_shot_place_authored_and_merged():
    llm = FakeLLMv3()
    out = run_shot_ref_classify(
        shots=SHOTS,
        scene_texts=SCENE_TEXTS,
        scene_headings=SCENE_HEADINGS,
        location_by_scene=LOC_BY_SCENE,
        prompt_version="3",
        call_structured_fn=llm,
    )
    s11 = out["shots"]["S1sh1"]
    assert s11["place_en"].startswith("Outside")
    assert s11["environment"] == "exterior"
    assert out["shots"]["S1sh2"]["environment"] == "interior"
    assert any(
        c["step_tag"] == "shot_ref_classify_shot_place" for c in llm.calls
    )


def test_v3_shot_place_missing_shot_fails():
    llm = FakeLLMv3(shot_place={"items": [
        {"shot": "S1sh1", "place_en": "X", "environment": "exterior",
         "basis_ko": ""},
    ]})
    with pytest.raises(AppError):
        run_shot_ref_classify(
            shots=SHOTS,
            scene_texts=SCENE_TEXTS,
            scene_headings=SCENE_HEADINGS,
            location_by_scene=LOC_BY_SCENE,
            prompt_version="3",
            call_structured_fn=llm,
        )


def test_v1_pack_no_shot_place_call_and_no_fields():
    """팩 1 하위호환 — shot_place 콜 없음·필드 미주입 (기존 CP shape 유지)."""
    llm = FakeLLM()
    out = run(llm)
    assert not any(
        c["step_tag"] == "shot_ref_classify_shot_place" for c in llm.calls
    )
    assert "place_en" not in out["shots"]["S1sh1"]
