"""OutlookSyncService CharacterOutlook delta sync 단위 테스트 — Phase 4.5.

기존 "해당 에피소드 character 전체 DELETE 후 재INSERT" 경로를
(character_id, outlook_id) 키 기준 delta sync로 전환.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
from unittest.mock import MagicMock

import pytest

from app.services.checkpoint_sync import OutlookSyncService


@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")


class _CanonRow:
    def __init__(self, id_: str, short_id: str, name: str, entity_type: str, description: str = ""):
        self.id = id_
        self.short_id = short_id
        self.name = name
        self.entity_type = entity_type
        self.description = description
        self.updated_at = None


class _FakeResult:
    def __init__(self, rows: List[Tuple]):
        self._rows = rows

    def fetchall(self):
        return self._rows

    def fetchone(self):
        return self._rows[0] if self._rows else None


class _FakeDB:
    """OutlookSyncService용 fake DB.

    - `query(EntityCanon).filter(...).all()` → 고정된 canon_rows (entity_type으로 필터)
    - `execute(SELECT co.id, ...)` → existing_character_outlook
    - `execute(SELECT ec.id, ec.name, ec.short_id FROM entity_canon ec ...)` → orphan rows
    - `execute(DELETE/INSERT/UPDATE)` → log
    - `add(...)` → added_objects
    """

    def __init__(
        self,
        canon_rows: List[_CanonRow],
        existing_character_outlook: List[Tuple],
        orphan_rows: List[Tuple] | None = None,
        episode_link_canon_ids: List[str] | None = None,
        legacy_character_outlook: List[Tuple] | None = None,
    ):
        self._canon_rows = canon_rows
        self._existing_character_outlook = existing_character_outlook
        #: 화 칸이 비어 있는 legacy 배정 (alembic 014 이전에 쓰인 행).
        self._legacy_character_outlook = legacy_character_outlook or []
        #: (id, name, short_id, status) — ★네 칸이다. `_mark_orphan_outlooks`
        #:  가 status 를 보고 이미 표시된 행을 건너뛴다.
        self._orphan_rows = orphan_rows or []
        #: 이 에피소드에 이미 달린 link 의 canon_id — 없으면 「아직 없다」.
        #: (2026-08-29) OutlookSyncService 가 link 를 분기 밖에서 upsert 하도록
        #: 고치면서 `query(EntityEpisodeLink.canon_id)` 가 새로 생겼다. 그 호출을
        #: 흉내 내지 않으면 canon row 를 돌려주어 `row[0]` 에서 터진다.
        self._episode_link_canon_ids = episode_link_canon_ids or []
        self.add_calls: List[Any] = []
        self.execute_calls: List[Tuple[str, Dict[str, Any]]] = []

    def query(self, model):
        chain = MagicMock()
        chain.filter.return_value = chain
        # `query(EntityCanon)` 인가 `query(EntityEpisodeLink.canon_id)` 인가로 가른다
        # — 앞은 ORM class(`__tablename__` 보유), 뒤는 Column.
        if getattr(model, "__tablename__", None) == "entity_canon":
            # filter의 entity_type을 추론할 수 없으므로 전체 canon 행을 리턴.
            chain.all = self._filtered_all()
        else:
            rows = [(cid,) for cid in self._episode_link_canon_ids]
            chain.all = lambda: rows
        return chain

    def _filtered_all(self):
        # filter로 entity_type을 지정하는데 fake에서는 구분 없이 전부 반환.
        # OutlookSyncService는 각 호출마다 filter 체인을 새로 만들므로 리턴값이 매 호출마다 필요.
        # 단순화를 위해 항상 전체 리턴 — 호출자가 for loop으로 entity_type 체크하는 구조여서 허용됨.
        def _all():
            return self._canon_rows

        return _all

    def execute(self, statement, params: Dict[str, Any] | None = None):
        sql = str(statement)
        self.execute_calls.append((sql, params or {}))
        # ★★2026-09-04 — 라우팅을 새 SQL 에 맞춘다. 종전에는
        #  `entity_episode_link` 조인 여부로 갈랐는데, 그 조인이 **결함의 원인**
        #  이라 없앴다(그 인물의 프로젝트 전체 배정을 가져와 앞 화 것을 지웠다).
        #  이제 화 칸으로 가른다.
        if "FROM character_outlook co" in sql and "co.episode_id IS NULL" in sql:
            return _FakeResult(self._legacy_character_outlook)
        if "FROM character_outlook co" in sql and "co.episode_id = :eid" in sql:
            return _FakeResult(self._existing_character_outlook)
        if "FROM entity_canon ec" in sql and "entity_type = 'outlook'" in sql:
            return _FakeResult(self._orphan_rows)
        return _FakeResult([])

    def add(self, obj):
        self.add_calls.append(obj)

    def flush(self):
        pass


# ── CharacterOutlook delta: insert only ──


def test_character_outlook_delta_inserts_new_pairs(project_episode, tmp_path):
    """체크포인트에 있고 DB에 없는 (char, outlook) 쌍은 INSERT."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "outlook_phase3",
        {
            "status": "completed",
            "data": {
                "outlooks": [
                    {"outlook_id": "O01", "name": "검정정장", "description": "black suit"},
                ],
                "scene_assignments": [
                    {
                        "assignments": [
                            {
                                "character_id": "C01",
                                "character_name": "주인공",
                                "outlook_id": "O01",
                                "outlook_name": "검정정장",
                            },
                        ]
                    }
                ],
            },
        },
    )
    db = _FakeDB(
        canon_rows=[
            _CanonRow("outlook_O01_id", "O01", "검정정장", "outlook"),
            _CanonRow("char_C01_id", "C01", "주인공", "character"),
        ],
        existing_character_outlook=[],
    )

    result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()

    assert result["links"] == 1
    assert result["links_inserted"] == 1
    assert result["links_deleted"] == 0
    insert_calls = [e for e in db.execute_calls if "INSERT INTO character_outlook" in e[0]]
    assert len(insert_calls) == 1
    params = insert_calls[0][1]
    assert params["cid"] == "char_C01_id"
    assert params["oid"] == "outlook_O01_id"


# ── CharacterOutlook delta: delete only ──


def test_character_outlook_delta_deletes_gone_pairs(project_episode, tmp_path):
    """체크포인트 scene_assignments가 비면 기존 쌍 전부 DELETE."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "outlook_phase3",
        {
            "status": "completed",
            "data": {
                "outlooks": [
                    {"outlook_id": "O01", "name": "검정정장", "description": ""},
                ],
                "scene_assignments": [],  # 아무 할당 없음
            },
        },
    )
    db = _FakeDB(
        canon_rows=[
            _CanonRow("outlook_O01_id", "O01", "검정정장", "outlook"),
            _CanonRow("char_C01_id", "C01", "주인공", "character"),
        ],
        existing_character_outlook=[
            ("co_1", "char_C01_id", "outlook_O01_id"),
        ],
    )

    result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()

    assert result["links"] == 0
    assert result["links_inserted"] == 0
    assert result["links_deleted"] == 1
    delete_calls = [
        e for e in db.execute_calls
        if "DELETE FROM character_outlook WHERE id" in e[0]
    ]
    # 1회 DELETE
    assert any(e[1].get("id") == "co_1" for e in delete_calls)


# ── CharacterOutlook delta: noop on identical state ──


def test_character_outlook_delta_noop_when_identical(project_episode, tmp_path):
    """체크포인트와 DB가 동일한 쌍을 가지면 INSERT/DELETE 모두 0."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "outlook_phase3",
        {
            "status": "completed",
            "data": {
                "outlooks": [
                    {"outlook_id": "O01", "name": "검정정장", "description": ""},
                ],
                "scene_assignments": [
                    {
                        "assignments": [
                            {
                                "character_id": "C01",
                                "character_name": "주인공",
                                "outlook_id": "O01",
                                "outlook_name": "검정정장",
                            },
                        ]
                    }
                ],
            },
        },
    )
    db = _FakeDB(
        canon_rows=[
            _CanonRow("outlook_O01_id", "O01", "검정정장", "outlook"),
            _CanonRow("char_C01_id", "C01", "주인공", "character"),
        ],
        existing_character_outlook=[
            ("co_1", "char_C01_id", "outlook_O01_id"),  # 이미 존재
        ],
    )

    result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()

    assert result["links"] == 1
    assert result["links_inserted"] == 0
    assert result["links_deleted"] == 0

    # INSERT / DELETE 호출 없음
    insert_calls = [e for e in db.execute_calls if "INSERT INTO character_outlook" in e[0]]
    assert insert_calls == []
    # co_1 DELETE도 없어야 함
    delete_calls = [
        e for e in db.execute_calls
        if "DELETE FROM character_outlook WHERE id" in e[0]
        and e[1].get("id") == "co_1"
    ]
    assert delete_calls == []


# ── CharacterOutlook dedup: duplicate (cid, oid) rows cleaned ──


def test_character_outlook_delta_dedups_duplicate_rows(project_episode, tmp_path):
    """동일 (character, outlook) 키를 가진 중복 행이 DB에 있으면 최신 1개만 유지."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "outlook_phase3",
        {
            "status": "completed",
            "data": {
                "outlooks": [
                    {"outlook_id": "O01", "name": "검정정장", "description": ""},
                ],
                "scene_assignments": [
                    {
                        "assignments": [
                            {
                                "character_id": "C01",
                                "character_name": "주인공",
                                "outlook_id": "O01",
                                "outlook_name": "검정정장",
                            },
                        ]
                    }
                ],
            },
        },
    )
    db = _FakeDB(
        canon_rows=[
            _CanonRow("outlook_O01_id", "O01", "검정정장", "outlook"),
            _CanonRow("char_C01_id", "C01", "주인공", "character"),
        ],
        existing_character_outlook=[
            ("co_1", "char_C01_id", "outlook_O01_id"),
            ("co_dup", "char_C01_id", "outlook_O01_id"),  # 중복
        ],
    )

    result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()

    # existing_map에서 첫 번째(co_1)만 유지, co_dup은 DELETE됨
    dup_deletes = [
        e for e in db.execute_calls
        if "DELETE FROM character_outlook WHERE id" in e[0]
        and e[1].get("id") == "co_dup"
    ]
    assert len(dup_deletes) == 1
    # 결과 계약은 dedup 후 상태 기준: 1 쌍 유지, delta insert=0/delete=0
    assert result["links"] == 1
