"""outdoor_frame_mode — 레인2 샷별 구도 판정 결정론 테스트 (E2E6 ③).

판정 완성도=E2E/육안 — 여기는 검증(인용·parity·low 반려)·재시도·
fail-closed·팩 중립성·등록만 잠근다.
"""
from typing import Any, Dict, List

import pytest

from app.core.errors import AppError
from app.modules.pipeline.outdoor_frame_mode import (
    FRAME_MODES,
    build_frame_mode_schema,
    resolve_prompt_version,
    run_outdoor_frame_mode,
    validate_frame_modes,
)

SCENE_TEXTS = {4: "옥탑방 전경. 철문이 반쯤 열려 있다.",
               17: "고양이가 담을 넘는다."}
HEADINGS = {4: "S#4. 옥상 (낮)", 17: "S#17. 옥상 (밤)"}
SHOTS = [
    {"scene_index": 4, "shot_index": 1,
     "description": "부감 전경", "camera_direction": "높은 부감"},
    {"scene_index": 17, "shot_index": 1,
     "description": "담 위 고양이", "camera_direction": ""},
]
TAGS = ["S4sh1", "S17sh1"]


def _item(tag, mode="structure_dominant", conf="high",
          si=4, quote="철문이 반쯤 열려 있다."):
    return {"shot": tag, "frame_mode": mode, "confidence": conf,
            "evidence": {"scene_index": si, "quote_ko": quote},
            "rationale_ko": "근거"}


def test_validate_parity_and_evidence():
    ok = [_item("S4sh1"), _item("S17sh1", mode="layout_dominant",
                                si=17, quote="담을 넘는다")]
    assert validate_frame_modes(ok, TAGS, SCENE_TEXTS) == []
    # 누락
    v = validate_frame_modes([_item("S4sh1")], TAGS, SCENE_TEXTS)
    assert any("판정 누락" in x for x in v)
    # 중복 + 대상 밖
    v = validate_frame_modes(
        [_item("S4sh1"), _item("S4sh1"), _item("S99sh1")],
        TAGS, SCENE_TEXTS)
    assert any("중복" in x for x in v)
    assert any("대상 밖" in x for x in v)
    # 인용 원문 부재
    v = validate_frame_modes(
        [_item("S4sh1", quote="원문에 없는 문장")], ["S4sh1"], SCENE_TEXTS)
    assert any("원문에 없음" in x for x in v)
    # low confidence = 위반 (mixed 임의 라우팅 금지)
    v = validate_frame_modes([_item("S4sh1", conf="low")],
                             ["S4sh1"], SCENE_TEXTS)
    assert any("confidence=low" in x for x in v)


def test_run_retry_then_success_and_fail_closed():
    calls: List[str] = []

    def flaky(step_tag, system, user, schema, **kw):
        calls.append(user)
        if len(calls) == 1:
            return {"items": [_item("S4sh1")]}  # S17sh1 누락 → 재시도
        return {"items": [
            _item("S4sh1"),
            _item("S17sh1", mode="layout_dominant", si=17,
                  quote="담을 넘는다"),
        ]}

    out = run_outdoor_frame_mode(
        shots=SHOTS, scene_texts=SCENE_TEXTS, scene_headings=HEADINGS,
        call_structured_fn=flaky,
    )
    assert set(out["shots"]) == set(TAGS)
    assert out["shots"]["S4sh1"]["frame_mode"] == "structure_dominant"
    assert len(calls) == 2
    # 재시도 프롬프트에 위반 피드백 포함 + 씬 원문 전문 유지(자르지 않음)
    assert "계약 위반" in calls[1]
    for si, txt in SCENE_TEXTS.items():
        assert txt in calls[1]

    def always_bad(step_tag, system, user, schema, **kw):
        return {"items": []}

    with pytest.raises(AppError):
        run_outdoor_frame_mode(
            shots=SHOTS, scene_texts=SCENE_TEXTS,
            scene_headings=HEADINGS, call_structured_fn=always_bad,
        )


def test_validate_cross_scene_evidence_rejected():
    """Codex f7c81336 B2: 다른 씬의 실제 인용도 반려 — 해당 샷의 씬만."""
    it = _item("S4sh1", si=17, quote="고양이가 담을 넘는다.")
    v = validate_frame_modes([it], ["S4sh1"], SCENE_TEXTS)
    assert any("해당 샷의 씬" in x for x in v)


def test_validate_frame_cp_entries_status_and_contract():
    """Codex f7c81336 H3: persisted CP 소비 재검증 — status·enum·parity."""
    from app.modules.pipeline.outdoor_frame_mode import (
        validate_frame_cp_entries,
    )

    ok_entry = {
        "status": "ok", "frame_mode": "structure_dominant",
        "confidence": "high",
        "evidence": {"scene_index": 4, "quote_ko": "철문이 반쯤 열려 있다."},
        "rationale_ko": "근거",
    }
    assert validate_frame_cp_entries(
        {"S4sh1": ok_entry}, ["S4sh1"], SCENE_TEXTS) == []
    # status!=ok
    bad = dict(ok_entry, status="failed")
    v = validate_frame_cp_entries({"S4sh1": bad}, ["S4sh1"], SCENE_TEXTS)
    assert any("ok 아님" in x for x in v)
    # enum 밖 frame_mode = layout 하강 아님, 위반
    weird = dict(ok_entry, frame_mode="SAMPLE_mode")
    v = validate_frame_cp_entries({"S4sh1": weird}, ["S4sh1"], SCENE_TEXTS)
    assert any("frame_mode 값 위반" in x for x in v)
    # 대상 샷 누락 = parity 위반
    v = validate_frame_cp_entries({}, ["S4sh1"], SCENE_TEXTS)
    assert any("판정 누락" in x for x in v)


def test_step_config_hash_sensitive_to_physical_model(monkeypatch):
    """Codex f7c81336 H4: alias 아닌 물리 모델 스탬프 — 교체=드리프트."""
    from app.core.config import settings
    from app.core.steps.outdoor_frame_mode_step import OutdoorFrameModeStep

    step = object.__new__(OutdoorFrameModeStep)
    step.project_config = {}
    monkeypatch.setattr(settings, "openai_model", "SAMPLE-model-a")
    h1 = OutdoorFrameModeStep._config_hash(step)
    monkeypatch.setattr(settings, "openai_model", "SAMPLE-model-b")
    h2 = OutdoorFrameModeStep._config_hash(step)
    assert h1 != h2


def test_decide_direct_seed_location_ref_mode_contract():
    """Codex f7c81336 B1: prev=classify SOT 고정 — asset 결손=fail-closed."""
    from app.modules.pipeline.still_recipe import (
        decide_direct_seed_location_ref_mode,
    )

    assert decide_direct_seed_location_ref_mode(
        prev_tag=None, prev_sel_exists=False, bg_only=False) == "seed_only"
    assert decide_direct_seed_location_ref_mode(
        prev_tag="S1sh1", prev_sel_exists=True, bg_only=False) == "prev_only"
    # bgonly 는 classify 가 prev 무효화 — seed_only
    assert decide_direct_seed_location_ref_mode(
        prev_tag="S1sh1", prev_sel_exists=False, bg_only=True) == "seed_only"
    with pytest.raises(ValueError):
        decide_direct_seed_location_ref_mode(
            prev_tag="S1sh1", prev_sel_exists=False, bg_only=False)


def test_schema_enum_locked():
    schema = build_frame_mode_schema()
    enum = (schema["properties"]["items"]["items"]["properties"]
            ["frame_mode"]["enum"])
    assert tuple(enum) == FRAME_MODES


def test_pack_scenario_neutral():
    from pathlib import Path

    repo = Path(__file__).resolve().parents[3]
    ver = resolve_prompt_version("1")
    text = (repo / "prompts" / "_base" / "outdoor_frame_mode" / ver
            / "judge_system.md").read_text(encoding="utf-8")
    for word in ("옥탑", "정류장", "rooftop", "bus stop", "항구"):
        assert word not in text
    assert "structure_dominant" in text and "layout_dominant" in text


def test_step_registered_and_gated():
    from app.core.step_manifest import get_manifest_dict
    from app.core.steps import STEP_CLASSES

    assert "outdoor_frame_mode" in STEP_CLASSES
    meta = get_manifest_dict("outdoor_frame_mode")
    assert meta["applicability"] == "if_outdoor_frame_mode"
    assert meta["allow_partial_downstream"] is False
    assert meta["order"] == 21.78
    assert "outdoor_lane_plan" in meta["depends_on"]


def test_unknown_version_raises():
    with pytest.raises(ValueError):
        resolve_prompt_version("99")
