"""ShotValidatorStep 단위 테스트.

shot_validator가 shot_extract 결과를 '한 찰나' 원칙으로 검증·재작성하고,
원본은 original_description으로 백업하며, 실패 시 원본을 유지하는지 검증.
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from app.core.steps.shot_validator_step import ShotValidatorStep


# ──────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────


@pytest.fixture
def project_episode(tmp_path, monkeypatch) -> tuple[str, str, Path]:
    """projects_dir을 tmp로 격리 + 체크포인트 디렉토리 준비."""
    pid = "p1"
    eid = "e1"
    ckpt_dir = tmp_path / pid / "checkpoints" / "episodes" / eid
    ckpt_dir.mkdir(parents=True, exist_ok=True)

    from app.core import config as _cfg
    monkeypatch.setattr(_cfg.settings, "projects_dir", str(tmp_path))
    return pid, eid, tmp_path


def _write_cp(base: Path, pid: str, eid: str, step: str, data: dict) -> None:
    target = base / pid / "checkpoints" / "episodes" / eid / step
    target.mkdir(parents=True, exist_ok=True)
    (target / "manifest.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")


def _make_step(pid: str, eid: str) -> ShotValidatorStep:
    """최소 mock된 ShotValidatorStep."""
    instance = ShotValidatorStep.__new__(ShotValidatorStep)
    instance.step_id = "shot_validator"
    instance.project_id = pid
    instance.episode_id = eid
    instance.db = MagicMock()
    instance.project_config = {}
    instance.manifest = {}
    instance.run_id = "r1"
    instance.opik_context = {}
    return instance


def _prompt_patches():
    """load_prompt / load_schema mocking."""
    return patch.multiple(
        "app.core.steps.shot_validator_step",
        load_prompt=MagicMock(return_value="SYSTEM PROMPT"),
        load_schema=MagicMock(return_value={"type": "object"}),
    )


# ──────────────────────────────────────────────────────────────────────
# 1) 기본: 변경 없는 shot은 원본 유지
# ──────────────────────────────────────────────────────────────────────


def test_validator_preserves_unchanged_shot(project_episode):
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {
        "data": {"segments": [{"scene_index": 1, "text": "씬 텍스트"}]},
    })

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.return_value = {
            "scene_index": 1,
            "shots": [{"shot_index": 1, "changed": False, "revised_description": "원본", "reason": ""}],
        }
        out = step._execute()

    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "원본"
    assert "original_description" not in shot
    assert "validator_reason" not in shot
    # 다른 필드 보존
    assert shot["based_on_beat"] == 1
    assert shot["characters"] == []


# ──────────────────────────────────────────────────────────────────────
# 2) 변경: description 재작성 + 원본 백업
# ──────────────────────────────────────────────────────────────────────


def test_validator_rewrites_and_backs_up_original(project_episode):
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "문을 열고 들어서며 총을 겨누는", "based_on_beat": 2, "characters": ["민숙"]},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.return_value = {
            "scene_index": 1,
            "shots": [{
                "shot_index": 1,
                "changed": True,
                "revised_description": "총을 겨눈 채 멈춘 순간",
                "reason": "시간 연결어 '~하며' 제거",
            }],
        }
        out = step._execute()

    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "총을 겨눈 채 멈춘 순간"
    assert shot["original_description"] == "문을 열고 들어서며 총을 겨누는"
    assert "시간 연결어" in shot["validator_reason"]
    # 기타 필드 보존
    assert shot["based_on_beat"] == 2
    assert shot["characters"] == ["민숙"]


# ──────────────────────────────────────────────────────────────────────
# 3) 변경=true지만 revised_description이 빈/동일 → 변경 안 함
# ──────────────────────────────────────────────────────────────────────


def test_validator_skips_empty_revision(project_episode):
    """changed=true라도 revised_description이 빈 문자열이면 원본 유지 (LLM 오동작 방지)."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.return_value = {
            "scene_index": 1,
            "shots": [{"shot_index": 1, "changed": True, "revised_description": "   ", "reason": "x"}],
        }
        out = step._execute()

    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "원본"
    assert "original_description" not in shot


def test_validator_skips_when_revised_equals_original(project_episode):
    """changed=true인데 revised가 원본과 같으면 변경 없음으로 처리."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "동일한 텍스트", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.return_value = {
            "scene_index": 1,
            "shots": [{"shot_index": 1, "changed": True, "revised_description": "동일한 텍스트", "reason": "x"}],
        }
        out = step._execute()

    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "동일한 텍스트"
    assert "original_description" not in shot


# ──────────────────────────────────────────────────────────────────────
# 4) LLM 실패 시 원본 씬 유지 (전체 실패 방지)
# ──────────────────────────────────────────────────────────────────────


def test_validator_preserves_original_on_llm_failure(project_episode):
    """G4.6 — LLM 실패 시 원본 description 은 유지하되 validator_status='failed' 마킹.

    다운스트림이 partial state 를 인지하고 fail-fast / bypass 정책을 결정할 수 있도록.
    """
    pid, eid, base = project_episode
    original = {"scene_index": 1, "shots": [
        {"shot_index": 1, "description": "원본a", "based_on_beat": 1, "characters": []},
        {"shot_index": 2, "description": "원본b", "based_on_beat": 1, "characters": []},
    ]}
    _write_cp(base, pid, eid, "shot_extract", {"data": {"scenes": [original]}})
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.side_effect = RuntimeError("llm down")
        out = step._execute()

    scenes = out["data"]["scenes"]
    assert len(scenes) == 1
    sc = scenes[0]
    # description 은 원본 그대로
    assert sc["scene_index"] == 1
    assert sc["shots"][0]["description"] == "원본a"
    assert sc["shots"][1]["description"] == "원본b"
    # G4.6 마킹
    assert sc["validator_status"] == "failed"
    assert "llm down" in sc["validator_failure_reason"]
    for shot in sc["shots"]:
        assert shot["validator_status"] == "failed_carry_original"


# ──────────────────────────────────────────────────────────────────────
# 5) shot_extract 체크포인트 없음 → AppError
# ──────────────────────────────────────────────────────────────────────


def test_validator_raises_when_no_shot_extract(project_episode):
    pid, eid, _ = project_episode
    step = _make_step(pid, eid)
    with _prompt_patches(), pytest.raises(Exception):
        step._execute()


# ──────────────────────────────────────────────────────────────────────
# 6) 빈 shots 씬은 통과
# ──────────────────────────────────────────────────────────────────────


def test_validator_passes_through_empty_scene(project_episode):
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": []}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        out = step._execute()

    # LLM 호출 없이 빈 씬 그대로 반환
    llm.assert_not_called()
    assert out["data"]["scenes"][0]["shots"] == []


# ──────────────────────────────────────────────────────────────────────
# 7) LLM이 shot_index 일부만 반환해도 누락 shot은 원본 유지
# ──────────────────────────────────────────────────────────────────────


def test_validator_keeps_original_for_missing_shot_index_in_llm_response(project_episode):
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "A", "based_on_beat": 1, "characters": []},
            {"shot_index": 2, "description": "B", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        # LLM이 shot_index=1만 반환 (shot_index=2는 누락)
        llm.return_value = {
            "scene_index": 1,
            "shots": [{"shot_index": 1, "changed": True, "revised_description": "A2", "reason": "x"}],
        }
        out = step._execute()

    shots = out["data"]["scenes"][0]["shots"]
    # shot 1은 수정, shot 2는 원본 유지
    assert shots[0]["description"] == "A2"
    assert shots[0]["original_description"] == "A"
    assert shots[1]["description"] == "B"
    assert "original_description" not in shots[1]


# ──────────────────────────────────────────────────────────────────────
# 8) 결과는 scene_index 기준 정렬
# ──────────────────────────────────────────────────────────────────────


def test_validator_returns_fan_out_counts(project_episode):
    """fan_out=True step 관례대로 completed/applicable/failed count 반환."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [
            {"scene_index": 1, "shots": [{"shot_index": 1, "description": "a", "based_on_beat": 1, "characters": []}]},
            {"scene_index": 2, "shots": [{"shot_index": 1, "description": "b", "based_on_beat": 1, "characters": []}]},
        ]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": []}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        # scene 1 성공, scene 2 실패 (3번 다 실패)
        import re as _re
        def _side(**kw):
            # schema_name 형식: shot_validator_s{si}_{attempt_label}
            m = _re.match(r"shot_validator_s(\d+)_", kw["schema_name"])
            si = int(m.group(1))
            if si == 2:
                raise RuntimeError("llm down")
            return {"scene_index": si, "shots": [{"shot_index": 1, "changed": False, "revised_description": "", "reason": ""}]}
        llm.side_effect = _side
        out = step._execute()

    assert out["applicable_count"] == 2
    assert out["failed_count"] == 1
    assert out["completed_count"] == 1
    assert out["data"]["total_shots"] >= 0  # 실패한 씬은 shots_total에 미포함
    assert out["data"]["changed_shots"] == 0


def test_validator_raises_when_scene_index_is_none(project_episode):
    """shot_extract에 scene_index 누락 씬이 있으면 AppError (silent 데이터 손실 방지)."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [
            {"scene_index": 1, "shots": []},
            {"shots": []},  # scene_index 누락
        ]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": []}})

    step = _make_step(pid, eid)
    from app.core.errors import AppError
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured"):
        with pytest.raises(AppError) as exc_info:
            step._execute()
    assert exc_info.value.code == "step.invalid_data"
    assert "scene_index" in exc_info.value.message


def test_validator_loads_scene_text_from_segments(project_episode):
    """scene_save.data.segments에서 씬 텍스트를 로드해 user_prompt에 포함."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "a", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {
        "data": {"segments": [{"scene_index": 1, "text": "실제 씬 내용 텍스트"}]},
    })

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.return_value = {
            "scene_index": 1,
            "shots": [{"shot_index": 1, "changed": False, "revised_description": "", "reason": ""}],
        }
        step._execute()

    user_prompt = llm.call_args.kwargs["user_prompt"]
    assert "실제 씬 내용 텍스트" in user_prompt


def test_validator_skips_retry_on_empty_response_jumps_to_gpt(project_episode):
    """빈 응답(deterministic) → retry_same 스킵 → fallback_gpt로 바로 점프.

    content_filter 같은 결정적 차단은 동일 모델 재시도가 무의미하므로 스킵.
    """
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.side_effect = [
            {"scene_index": 1, "shots": []},  # primary 빈 응답
            {"scene_index": 1, "shots": [{"shot_index": 1, "changed": True, "revised_description": "수정", "reason": "gpt"}]},  # fallback_gpt
        ]
        out = step._execute()

    assert llm.call_count == 2  # retry_same 스킵 확인
    assert llm.call_args_list[1].kwargs["schema_name"].endswith("_fallback_gpt")
    assert llm.call_args_list[1].kwargs["project_config"]["shot_validator"]["model"] == "gpt"
    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "수정"


def test_validator_retries_same_on_transient_exception(project_episode):
    """일시 네트워크/API 오류 → retry_same 수행 (content_filter 키워드 없음)."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        # primary: 일시 오류, retry_same: 정상
        llm.side_effect = [
            RuntimeError("connection timeout"),
            {"scene_index": 1, "shots": [{"shot_index": 1, "changed": True, "revised_description": "수정", "reason": "retry"}]},
        ]
        out = step._execute()

    assert llm.call_count == 2
    assert llm.call_args_list[1].kwargs["schema_name"].endswith("_retry_same")
    shot = out["data"]["scenes"][0]["shots"][0]
    assert shot["description"] == "수정"


def test_validator_skips_retry_same_when_safety_keyword_in_exception(project_episode):
    """예외 메시지에 'content_filter'/'safety' 포함 → retry_same 스킵 → fallback_gpt 바로."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.side_effect = [
            RuntimeError("blocked by content_filter policy"),  # primary
            {"scene_index": 1, "shots": [{"shot_index": 1, "changed": True, "revised_description": "fallback", "reason": "gpt"}]},  # fallback_gpt
        ]
        out = step._execute()

    assert llm.call_count == 2  # retry_same 스킵
    assert llm.call_args_list[1].kwargs["schema_name"].endswith("_fallback_gpt")


def test_validator_preserves_shot_validator_config_keys_in_fallback(project_episode):
    """fallback_gpt 시 project_config['shot_validator']의 다른 키(temperature 등) 보존."""
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
        ]}]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    # 초기 config에 다른 옵션 주입
    step.project_config = {"shot_validator": {"temperature": 0.5, "custom_flag": True}}
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.side_effect = [
            {"scene_index": 1, "shots": []},  # primary 빈
            {"scene_index": 1, "shots": [{"shot_index": 1, "changed": True, "revised_description": "ok", "reason": "gpt"}]},
        ]
        step._execute()

    # fallback_gpt 호출 config 검증
    fallback_cfg = llm.call_args_list[1].kwargs["project_config"]
    sv = fallback_cfg["shot_validator"]
    assert sv["model"] == "gpt"
    assert sv["temperature"] == 0.5  # 기존 키 보존
    assert sv["custom_flag"] is True


def test_validator_preserves_original_when_all_three_fail(project_episode):
    """3번 시도 모두 실패 → 원본 description 유지 + G4.6 failed 마킹."""
    pid, eid, base = project_episode
    original = {"scene_index": 1, "shots": [
        {"shot_index": 1, "description": "원본", "based_on_beat": 1, "characters": []},
    ]}
    _write_cp(base, pid, eid, "shot_extract", {"data": {"scenes": [original]}})
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": [{"scene_index": 1, "text": ""}]}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        llm.side_effect = [
            RuntimeError("gemini blocked 1"),
            RuntimeError("gemini blocked 2"),
            RuntimeError("gpt also failed"),
        ]
        out = step._execute()

    assert llm.call_count == 3  # primary + retry_same + fallback_gpt
    sc = out["data"]["scenes"][0]
    assert sc["shots"][0]["description"] == "원본"
    assert sc["validator_status"] == "failed"
    assert sc["shots"][0]["validator_status"] == "failed_carry_original"


def test_validator_sorts_scenes_by_index(project_episode):
    pid, eid, base = project_episode
    _write_cp(base, pid, eid, "shot_extract", {
        "data": {"scenes": [
            {"scene_index": 3, "shots": [{"shot_index": 1, "description": "c", "based_on_beat": 1, "characters": []}]},
            {"scene_index": 1, "shots": [{"shot_index": 1, "description": "a", "based_on_beat": 1, "characters": []}]},
            {"scene_index": 2, "shots": [{"shot_index": 1, "description": "b", "based_on_beat": 1, "characters": []}]},
        ]},
    })
    _write_cp(base, pid, eid, "scene_save", {"data": {"segments": []}})

    step = _make_step(pid, eid)
    with _prompt_patches(), patch("app.core.steps.shot_validator_step.call_structured") as llm:
        import re as _re
        def _side(**kw):
            m = _re.match(r"shot_validator_s(\d+)_", kw["schema_name"])
            return {
                "scene_index": int(m.group(1)),
                "shots": [{"shot_index": 1, "changed": False, "revised_description": "", "reason": ""}],
            }
        llm.side_effect = _side
        out = step._execute()

    indices = [sc["scene_index"] for sc in out["data"]["scenes"]]
    assert indices == [1, 2, 3]
