"""ShotSelectionService 단위 테스트 — Phase 2.2."""
from __future__ import annotations

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

import pytest

from app.core.errors import AppError
from app.services.shot_selection_service import ShotSelectionService


@pytest.fixture
def project_episode(tmp_path: Path, monkeypatch):
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    return "p1", "e1"


def _write_cp(tmp_path: Path, project_id: str, episode_id: str, step_id: str, payload: dict):
    cp_dir = tmp_path / project_id / "checkpoints" / "episodes" / episode_id / step_id
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text(json.dumps(payload), encoding="utf-8")


def test_toggle_missing_checkpoint_raises(project_episode, tmp_path):
    pid, eid = project_episode
    db = MagicMock()
    with pytest.raises(AppError) as exc_info:
        ShotSelectionService(db, pid, eid).toggle(1, 1)
    assert exc_info.value.code == "step.no_checkpoint"


def test_toggle_missing_scene_raises(project_episode, tmp_path):
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "shot_selection", {
        "data": {"scenes": [{"scene_index": 5, "selected_shot_indices": [1]}]},
    })
    db = MagicMock()
    with pytest.raises(AppError) as exc_info:
        ShotSelectionService(db, pid, eid).toggle(99, 1)
    assert exc_info.value.code == "step.scene_not_found"


def test_toggle_corrupt_manifest_propagates_decode_error(project_episode, tmp_path):
    """Codex Phase 2 Item 6: 손상 manifest는 raw JSONDecodeError (baseline 동작)."""
    pid, eid = project_episode
    cp_dir = tmp_path / pid / "checkpoints" / "episodes" / eid / "shot_selection"
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text("{corrupt}", encoding="utf-8")

    db = MagicMock()
    with pytest.raises(json.JSONDecodeError):
        ShotSelectionService(db, pid, eid).toggle(1, 1)


def test_toggle_acquires_advisory_lock(project_episode, tmp_path):
    """v4: 동시 toggle 직렬화를 위해 pg_advisory_xact_lock 호출."""
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "shot_selection", {
        "data": {"scenes": [{"scene_index": 5, "selected_shot_indices": [1]}]},
    })
    db = MagicMock()
    # scene_not_found로 빠르게 종료되어도 락 획득은 이미 수행됨
    with pytest.raises(AppError):
        ShotSelectionService(db, pid, eid).toggle(99, 1)

    # SELECT pg_advisory_xact_lock 호출 확인
    sql_calls = [str(c.args[0]) for c in db.execute.call_args_list if c.args]
    assert any("pg_advisory_xact_lock" in s for s in sql_calls), (
        f"advisory lock 호출 없음: {sql_calls}"
    )


def test_toggle_add_rejected_when_episode_max_exceeded(project_episode, tmp_path, monkeypatch):
    """v4: EPISODE_MAX_SHOTS 초과 시 400 거절 (add 시에만)."""
    pid, eid = project_episode
    # 3개 이미 선택 + EPISODE_MAX_SHOTS=3 → 새 add 거절되어야 함
    _write_cp(tmp_path, pid, eid, "shot_selection", {
        "data": {"scenes": [
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
            {"scene_index": 2, "selected_shot_indices": [1]},
        ]},
    })
    _write_cp(tmp_path, pid, eid, "shot_extract", {
        "data": {"scenes": [
            {"scene_index": 1, "shots": [{"shot_index": 3}]},
        ]},
    })
    monkeypatch.setattr("app.core.config.settings.episode_max_shots", 3)

    db = MagicMock()
    with pytest.raises(AppError) as exc_info:
        # scene 1에 shot 3 새로 add 시도 → total 4 > cap 3
        ShotSelectionService(db, pid, eid).toggle(1, 3)
    assert exc_info.value.code == "step.episode_max_shots_exceeded"
    assert exc_info.value.status_code == 400


def test_toggle_deselect_always_allowed_even_over_cap(project_episode, tmp_path, monkeypatch):
    """v4: deselect는 어떤 경우에도 cap 검증을 우회 (상한 초과 상태에서도 해제 가능)."""
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "shot_selection", {
        "data": {"scenes": [
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
            {"scene_index": 2, "selected_shot_indices": [1, 2]},
        ]},
    })
    monkeypatch.setattr("app.core.config.settings.episode_max_shots", 2)

    db = MagicMock()
    db.execute.return_value.rowcount = 1
    # total=4, cap=2지만 deselect이므로 통과
    result = ShotSelectionService(db, pid, eid).toggle(1, 2)
    assert result["action"] == "deselected"


def test_toggle_backfills_selected_shots_from_v3_checkpoint(project_episode, tmp_path):
    """v4: v3 체크포인트(selected_shots 없음)에서 add 시 기존 선택이 유실되지 않음."""
    pid, eid = project_episode
    # v3 legacy: selected_shot_indices만 있고 selected_shots 없음
    _write_cp(tmp_path, pid, eid, "shot_selection", {
        "data": {"scenes": [
            {"scene_index": 1, "selected_shot_indices": [1, 2]},
        ], "total_selected": 2},
    })
    _write_cp(tmp_path, pid, eid, "shot_extract", {
        "data": {"scenes": [
            {"scene_index": 1, "shots": [{"shot_index": 1}, {"shot_index": 2}, {"shot_index": 3}]},
        ]},
    })

    db = MagicMock()
    db.execute.return_value.rowcount = 1
    result = ShotSelectionService(db, pid, eid).toggle(1, 3)

    # 파일에 backfill된 selected_shots 확인
    cp_data = json.loads(
        (tmp_path / pid / "checkpoints" / "episodes" / eid / "shot_selection" / "manifest.json")
        .read_text(encoding="utf-8")
    )
    scene = cp_data["data"]["scenes"][0]
    assert scene["selected_shot_indices"] == [1, 2, 3]
    # 기존 1, 2는 "migrated from v3" reason으로 backfill + 3은 "user toggled"
    assert len(scene["selected_shots"]) == 3
    reasons_by_idx = {s["shot_index"]: s["reason"] for s in scene["selected_shots"]}
    assert reasons_by_idx[1] == "migrated from v3"
    assert reasons_by_idx[2] == "migrated from v3"
    assert reasons_by_idx[3] == "user toggled"
