"""shot_essence_extraction step 단위 테스트 (Phase 1b)."""
from __future__ import annotations

import json
from unittest.mock import patch

import pytest

from app.core.step_manifest import STEP_MANIFEST
from app.core.steps import STEP_CLASSES


def test_step_registered_in_manifest():
    """manifest에 신설 step 등록 확인."""
    assert "shot_essence_extraction" in STEP_MANIFEST
    m = STEP_MANIFEST["shot_essence_extraction"]
    assert m["order"] == 19.7
    assert m["depends_on"] == ["shot_validator", "shot_selection"]
    assert m["default_model"] == "gpt"
    assert m["category"] == "analysis"
    # Codex H3 fix: validator 기반 applicability
    assert m["applicability"] == "if_shot_essence_enabled"


def test_step_registered_in_classes():
    """STEP_CLASSES에 등록 확인."""
    assert "shot_essence_extraction" in STEP_CLASSES
    cls = STEP_CLASSES["shot_essence_extraction"]
    assert cls.__name__ == "ShotEssenceExtractionStep"


def test_pipeline_steps_label():
    """llm_client.PIPELINE_STEPS에 등록 확인."""
    from app.modules.llm.llm_client import PIPELINE_STEPS
    assert "shot_essence_extraction" in PIPELINE_STEPS
    assert PIPELINE_STEPS["shot_essence_extraction"]["default"] == "gpt"


def test_prompt_v2_loaded_with_strengthened_guidance():
    """v2 prompt 자동 선택 (가장 높은 버전) + Claude H-1/H-2/M-4 fix 반영."""
    from app.modules.prompt_loader import load_prompt, load_schema
    sys = load_prompt("shot_essence_extraction", "system")
    assert "essence" in sys.lower()
    assert "peripheral" in sys.lower()
    assert "atmospheric" in sys.lower()
    # H-1: 영구 흔적 → essence 명시
    assert "사건의 영구 흔적" in sys
    # H-2: peripheral은 보강 풀 (drop 후보 X)
    assert "보강 풀" in sys
    # M-4: 위반 예시 명시 (엔티티 ID, 원문 복사)
    assert "case A" in sys or "잘못된 예시" in sys
    assert "C##" in sys

    sch = load_schema("shot_essence_extraction", "schema")
    assert sch["required"] == ["shots"]
    item_props = sch["properties"]["shots"]["items"]["properties"]
    assert set(item_props.keys()) == {
        "scene_index", "shot_index", "essence", "peripheral", "atmospheric",
    }


def test_settings_toggle_default_false():
    """default off 회귀 0건 보장."""
    from app.core.config import settings
    assert settings.shot_essence_enabled is False


def test_applicability_validator_off(monkeypatch):
    """validator: settings off → False (run-all 정적 필터에서 제외)."""
    from app.core.config import settings
    from app.core.applicability import APPLICABILITY_VALIDATORS
    monkeypatch.setattr(settings, "shot_essence_enabled", False)
    validator = APPLICABILITY_VALIDATORS["if_shot_essence_enabled"]
    # validator는 runner를 받지만 settings만 보므로 None 전달 가능
    assert validator(None) is False


def test_applicability_validator_on(monkeypatch):
    """validator: settings on → True."""
    from app.core.config import settings
    from app.core.applicability import APPLICABILITY_VALIDATORS
    monkeypatch.setattr(settings, "shot_essence_enabled", True)
    validator = APPLICABILITY_VALIDATORS["if_shot_essence_enabled"]
    assert validator(None) is True


def test_resolve_applicability_off(monkeypatch):
    """resolve_applicability(runner): manifest의 if_shot_essence_enabled → validator."""
    from app.core.applicability import resolve_applicability
    from app.core.config import settings
    monkeypatch.setattr(settings, "shot_essence_enabled", False)

    # mock runner with manifest
    class FakeRunner:
        manifest = STEP_MANIFEST["shot_essence_extraction"]
        step_id = "shot_essence_extraction"

    assert resolve_applicability(FakeRunner()) is False


def test_merge_with_input_keys_handles_missing_extra_duplicate():
    """Codex H2 / Claude M-3: LLM 응답 키 무결성 검증."""
    cls = STEP_CLASSES["shot_essence_extraction"]
    bundle = [
        {"scene_index": 1, "shot_index": 1, "description": "a"},
        {"scene_index": 1, "shot_index": 2, "description": "b"},
        {"scene_index": 2, "shot_index": 1, "description": "c"},
    ]
    input_keys = {(1, 1), (1, 2), (2, 1)}

    raw_shots = [
        # 정상
        {"scene_index": 1, "shot_index": 1, "essence": ["x"], "peripheral": [], "atmospheric": []},
        # extra (입력에 없음) — 무시
        {"scene_index": 99, "shot_index": 99, "essence": ["?"], "peripheral": [], "atmospheric": []},
        # 중복 (1,1 두번째) — 첫 번째만 사용
        {"scene_index": 1, "shot_index": 1, "essence": ["dup"], "peripheral": [], "atmospheric": []},
        # 정상 (1,2)
        {"scene_index": 1, "shot_index": 2, "essence": ["y"], "peripheral": [], "atmospheric": []},
        # 누락: (2,1)은 응답에 없음 → stub failed
    ]

    with patch.object(cls, "__init__", lambda self, *a, **k: None):
        runner = cls()
        merged = runner._merge_with_input_keys(bundle, input_keys, raw_shots)

    assert len(merged) == 3
    assert merged[0]["essence"] == ["x"]  # (1,1) 첫 번째만
    assert merged[0]["status"] == "ok"
    assert merged[1]["essence"] == ["y"]  # (1,2)
    assert merged[1]["status"] == "ok"
    assert merged[2]["status"] == "failed"  # (2,1) 누락 → stub
    assert merged[2]["essence"] == []


def test_execute_skips_scenes_missing_from_shot_selection(tmp_path, monkeypatch):
    """Codex H1: shot_selection에 누락된 씬은 'no selection'으로 처리 (전체 처리 X)."""
    from app.core.config import settings
    cls = STEP_CLASSES["shot_essence_extraction"]

    # 가짜 체크포인트 디렉토리 구성
    pid = "pid"
    eid = "eid"
    cp_root = tmp_path / pid / "checkpoints" / "episodes" / eid
    (cp_root / "shot_validator").mkdir(parents=True)
    (cp_root / "shot_selection").mkdir(parents=True)

    (cp_root / "shot_validator" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "selected"},
                {"shot_index": 2, "description": "not selected"},
            ]},
            # scene 2: shot_selection에 누락 → 전체 skip 되어야 함
            {"scene_index": 2, "shots": [
                {"shot_index": 1, "description": "should be skipped"},
            ]},
        ]},
    }))
    (cp_root / "shot_selection" / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [
            {"scene_index": 1, "selected_shot_indices": [1]},
            # scene 2 entry missing
        ]},
    }))

    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))

    # mock LLM call
    captured_shots: list = []

    def fake_call_structured(**kwargs):
        # bundle 입력 추출용 dummy — input bundle은 step._call_bundle에서 구성
        return {"shots": []}  # 빈 응답 → merge가 stub 채움

    with patch("app.core.steps.shot_essence_extraction_step.call_structured", side_effect=fake_call_structured):
        with patch.object(cls, "__init__", lambda self, *a, **k: None):
            runner = cls()
            runner.project_id = pid
            runner.episode_id = eid
            runner.project_config = {}
            runner.db = None  # load_prompt(db=None)도 file fallback
            # build_opik_metadata stub
            runner.build_opik_metadata = lambda *a, **k: {}

            result = runner._execute(mode="resume")

    # scene 2의 shot은 skip되어야 하므로 applicable_count는 1 (S1_Shot1만)
    assert result["applicable_count"] == 1
    assert result["data"]["applicable_count"] == 1
    # scene 1 shot 2는 selected 아님 → 제외
    shots_in_result = result["data"]["shots"]
    keys = {(s["scene_index"], s["shot_index"]) for s in shots_in_result}
    assert keys == {(1, 1)}
