"""Checkpoint Sync Service 단위 테스트 — Phase 2.1.

로드맵 §Phase 2 완료 검증 항목: "5개 Service 단위 테스트 통과".

실제 DB 쿼리는 MagicMock으로 대체. 목적: 체크포인트 로딩 경로 + 반환값 계약.
깊은 쿼리 경로 검증은 기존 E2E (`test_sync_v3.py`, `test_pipeline_v3_e2e.py`)에 위임.
"""
from __future__ import annotations

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

import pytest

from app.services.checkpoint_sync import (
    EntitySyncService,
    RelationSyncService,
    SceneStillSyncService,
    OutlookSyncService,
    EpisodeProjectionService,
)


@pytest.fixture
def project_episode(tmp_path: Path, monkeypatch):
    """settings.projects_dir을 tmp_path로 가리키고 기본 project/episode id 반환."""
    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):
    """체크포인트 manifest.json을 tmp_path 아래에 기록."""
    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")


# ── EntitySyncService ──────────────────────────────────────────────────


def test_entity_sync_no_checkpoint_returns_skipped(project_episode, tmp_path):
    pid, eid = project_episode
    db = MagicMock()
    result = EntitySyncService(db, pid, eid).sync_from_checkpoint()
    assert result == {"synced": 0, "removed": 0, "skipped": 1}
    # 쿼리 실행 안 되어야 함
    db.query.assert_not_called()


def test_entity_sync_not_completed_returns_skipped(project_episode, tmp_path):
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "entity_t2i", {"status": "partial", "data": {}})
    db = MagicMock()
    result = EntitySyncService(db, pid, eid).sync_from_checkpoint()
    assert result["skipped"] == 1


# ── RelationSyncService ────────────────────────────────────────────────


def test_relation_sync_no_checkpoint_returns_skipped(project_episode, tmp_path):
    """Phase 4.4: delta sync 반환 계약 — relations/inserted/updated/deleted/skipped."""
    pid, eid = project_episode
    db = MagicMock()
    result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
    assert result == {
        "relations": 0,
        "inserted": 0,
        "updated": 0,
        "deleted": 0,
        "skipped": 1,
    }


def test_relation_sync_completed_empty_relations_proceeds_authoritatively(project_episode, tmp_path):
    """Codex P2-2 (2026-05-01): completed + relations=[]는 sync 진입 (authoritative).

    이전 정책: empty data → skipped=1 (sync skip).
    새 정책: completed → 항상 syncable. empty data는 정당한 zero rows로 처리.
    """
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "entity_relation", {"status": "completed", "data": {"relations": []}})
    db = MagicMock()
    db.query.return_value.filter.return_value.all.return_value = []
    db.execute.return_value.fetchall.return_value = []
    result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
    # P2-2: completed → 진입 (skipped=0)
    assert result["skipped"] == 0
    # 0 inserts/deletes (existing도 0이므로 no-op)
    assert result["inserted"] == 0 and result["deleted"] == 0


def test_relation_sync_partial_empty_relations_returns_skipped(project_episode, tmp_path):
    """partial + relations=[]는 cascade 가드로 skip 유지 (P2-2 보존)."""
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "entity_relation", {"status": "partial", "data": {"relations": []}})
    db = MagicMock()
    result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
    assert result["skipped"] == 1
    assert result["inserted"] == 0 and result["deleted"] == 0


# ── SceneStillSyncService ──────────────────────────────────────────────


def test_scene_still_sync_no_checkpoint_returns_zero(project_episode, tmp_path):
    """체크포인트 부재도 sd_completed=False(default)로 가드에 걸려 조기 반환.
    Codex/Claude review: DB 쿼리 자체가 일어나지 않아야 regression guard로 의미 있음.
    """
    pid, eid = project_episode
    db = MagicMock()
    result = SceneStillSyncService(db, pid, eid).sync_from_checkpoint()
    assert result == {"stills": 0}
    db.query.assert_not_called()


@pytest.mark.parametrize(
    "manifest",
    [
        {"status": "partial", "data": {"scenes": []}},   # cascade 직후 일반 케이스
        {"status": "stale", "data": {"scenes": []}},     # invalidate_downstream 직후
        {"status": "failed", "data": {"scenes": []}},    # step 실행 실패
        {"status": None, "data": {"scenes": []}},        # malformed status=None
        {"data": {"scenes": []}},                        # status 필드 자체 없음
    ],
    ids=["partial", "stale", "failed", "status_none", "missing_status"],
)
def test_scene_still_sync_skips_when_scene_detail_incomplete(
    project_episode, tmp_path, monkeypatch, manifest
):
    """상류 force cascade로 scene_detail이 비완료 상태일 때 기존 scene_still을
    건드리지 않아야 한다 (regression guard).

    이전 동작: Normalizer가 빈 planned 반환 → Writer가 모든 기존 row를 stale
    마킹 → validate_episode_ready 거부 → 수동 SQL 복구 필요.
    수정 동작: sd_completed=False면 Writer 호출 자체를 skip.

    sd_completed는 status == "completed" 한 가지 외에는 모두 False가 되므로
    partial/stale/failed/None/missing 모든 분기를 동일하게 다뤄야 함을 명시 검증.
    """
    pid, eid = project_episode
    _write_cp(tmp_path, pid, eid, "scene_detail", manifest)
    db = MagicMock()

    writer_called = {"count": 0}

    def _fail_write(*args, **kwargs):
        writer_called["count"] += 1
        return 0

    monkeypatch.setattr(
        "app.services.checkpoint_sync.scene_still_sync_service.SceneStillWriter.write",
        _fail_write,
    )
    result = SceneStillSyncService(db, pid, eid).sync_from_checkpoint()
    assert result == {"stills": 0}
    assert writer_called["count"] == 0, "Writer must not run when scene_detail is not completed"


# ── OutlookSyncService ─────────────────────────────────────────────────


def test_outlook_sync_no_checkpoint_returns_zero(project_episode, tmp_path):
    """Phase 4.5 delta sync — 반환 계약: outlooks/links/links_inserted/links_deleted/orphans_marked."""
    pid, eid = project_episode
    db = MagicMock()
    # _remove_orphan_outlooks: execute().fetchall() → []
    db.execute.return_value.fetchall.return_value = []
    result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()
    assert result == {
        "outlooks": 0,
        "links": 0,
        "links_inserted": 0,
        "links_deleted": 0,
        "orphans_marked": 0,
    }


# ── EpisodeProjectionService ───────────────────────────────────────────


def test_episode_projection_returns_appearance_updated(project_episode, tmp_path):
    pid, eid = project_episode
    db = MagicMock()
    # sync_t2i_appearance_counts 내부: query().filter().all() 리스트 경로
    db.query.return_value.filter.return_value.all.return_value = []
    # Episode 조회 → None
    db.query.return_value.filter.return_value.first.return_value = None
    result = EpisodeProjectionService(db, pid, eid).sync_from_checkpoint()
    assert "appearance_updated" in result
    # 업데이트 대상 links 없음 → 0
    assert result["appearance_updated"] == 0


def test_episode_projection_uses_shared_now(project_episode):
    """Codex Phase 2 Item 1: now 인자 전달 시 공유됨."""
    pid, eid = project_episode
    db = MagicMock()
    db.query.return_value.filter.return_value.all.return_value = []
    db.query.return_value.filter.return_value.first.return_value = None

    shared_now = "2026-04-17T00:00:00+00:00"
    svc = EpisodeProjectionService(db, pid, eid, now=shared_now)
    assert svc.now == shared_now


# ── BaseSyncService contract ────────────────────────────────────────────


def test_base_sync_propagates_corrupt_manifest(project_episode, tmp_path):
    """Codex Phase 2 Item 2: 손상 manifest는 JSONDecodeError 전파 (baseline)."""
    pid, eid = project_episode
    cp_dir = tmp_path / pid / "checkpoints" / "episodes" / eid / "entity_t2i"
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text("{invalid json}", encoding="utf-8")

    db = MagicMock()
    with pytest.raises(json.JSONDecodeError):
        EntitySyncService(db, pid, eid).sync_from_checkpoint()
