"""RelationSyncService delta sync 단위 테스트 — Phase 4.4.

visual_variant 관계를 DELETE→INSERT 전부 재생성하던 기존 경로를
`(base_canon_id, variant_canon_id)` 키 기준 delta sync(UPSERT)로 전환.
"""
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 RelationSyncService


@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, short_id: str, canon_id: str):
        self.short_id = short_id
        self.id = canon_id


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:
    """EntityCanon.query + execute(JOIN SQL) 두 경로 mock.

    - `query(EntityCanon)...all()` → canon_rows
    - `execute(SELECT rf.id ...)` → existing_variants
    - `execute(DELETE/UPDATE/INSERT ...)` → execution log
    - `add(...)` → added_objects
    """

    def __init__(self, canon_rows: List[_CanonRow], existing_variants: List[Tuple]):
        self._canon_rows = canon_rows
        self._existing_variants = existing_variants
        self.add_calls: List[Any] = []
        self.execute_calls: List[Tuple[str, Dict[str, Any]]] = []

    def query(self, *args, **kwargs):
        chain = MagicMock()
        chain.filter.return_value = chain
        chain.all.return_value = self._canon_rows
        return chain

    def execute(self, statement, params: Dict[str, Any] | None = None):
        """★★★**묻는 것을 보고 답한다** (2026-09-01).

        §2-6.5 로 조회가 둘이 됐다 — `visual_variant` 와 `part_of`. 하나의
        가짜가 **모든 조회에 같은 행**을 주면 이 시험이 재려는 것(전자)과
        다른 것(후자)이 섞여, 있지도 않은 결함을 본다.
        """
        sql = str(statement)
        self.execute_calls.append((sql, params or {}))
        rt = str((params or {}).get("rt") or "")
        if rt and rt != "visual_variant":
            return _FakeResult([])          # ★이 시험은 그 타입을 안 다룬다
        if "SELECT rf.id" in sql or "FROM relation_fact" in sql:
            return _FakeResult(self._existing_variants)
        return _FakeResult([])

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

    def flush(self):
        pass


# ── desired-only: 신규 INSERT만 발생 ──


def test_delta_inserts_new_when_empty_existing(project_episode, tmp_path):
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "전투복",
                    },
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[],
    )

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

    assert result == {
        "relations": 1,
        "inserted": 1,
        "updated": 0,
        "deleted": 0,
        "skipped": 0,
    }
    # RelationFact 1 + 2 RelationParticipant
    assert len(db.add_calls) == 3
    # DELETE 발생 없음
    delete_calls = [e for e in db.execute_calls if "DELETE" in e[0]]
    assert delete_calls == []


# ── existing-only: 사라진 관계 DELETE ──


def test_delta_deletes_gone_relations(project_episode, tmp_path):
    """체크포인트가 빈 visual_variant면 기존 관계는 모두 DELETE."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    # visual_similarity=False → 무시됨
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": False,
                    }
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[
            ("rel_1", "오래된 이유", "canon_C01", "canon_C02"),
        ],
    )

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

    assert result["inserted"] == 0
    assert result["deleted"] == 1
    # DELETE FROM relation_participant + DELETE FROM relation_fact
    delete_sqls = [sql for sql, _ in db.execute_calls if "DELETE" in sql]
    assert any("relation_participant" in s for s in delete_sqls)
    assert any("relation_fact" in s for s in delete_sqls)


# ── intersection: UPDATE만 발생 (reason 변경) ──


def test_delta_updates_reason_only_when_identity_preserved(project_episode, tmp_path):
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "새로운 이유",
                    }
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[
            ("rel_1", "기존 이유", "canon_C01", "canon_C02"),
        ],
    )

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

    assert result["inserted"] == 0
    assert result["deleted"] == 0
    assert result["updated"] == 1
    update_calls = [e for e in db.execute_calls if "UPDATE relation_fact" in e[0]]
    assert len(update_calls) == 1
    assert update_calls[0][1]["reason"] == "새로운 이유"
    assert update_calls[0][1]["rid"] == "rel_1"
    # add(RelationFact) 없음 — 기존 id 유지
    assert len(db.add_calls) == 0


def test_delta_noop_when_reason_matches(project_episode, tmp_path):
    """reason이 동일하면 UPDATE도 발생하지 않아야 한다."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "같은 이유",
                    }
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[
            ("rel_1", "같은 이유", "canon_C01", "canon_C02"),
        ],
    )

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

    assert result == {
        "relations": 1,
        "inserted": 0,
        "updated": 0,
        "deleted": 0,
        "skipped": 0,
    }
    assert len(db.add_calls) == 0
    update_calls = [e for e in db.execute_calls if "UPDATE relation_fact" in e[0]]
    assert update_calls == []


# ── mixed: insert + update + delete ──


def test_delta_mixed_scenario(project_episode, tmp_path):
    """하나는 신규, 하나는 업데이트, 하나는 삭제."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    # update
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "새 이유",
                    },
                    # insert
                    {
                        "base_short_id": "C03",
                        "variant_short_id": "C04",
                        "visual_similarity": True,
                        "reason": "신규",
                    },
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[
            _CanonRow("C01", "canon_C01"),
            _CanonRow("C02", "canon_C02"),
            _CanonRow("C03", "canon_C03"),
            _CanonRow("C04", "canon_C04"),
            _CanonRow("C05", "canon_C05"),
            _CanonRow("C06", "canon_C06"),
        ],
        existing_variants=[
            ("rel_1", "기존 이유", "canon_C01", "canon_C02"),  # update 대상
            ("rel_gone", "버릴 이유", "canon_C05", "canon_C06"),  # delete 대상
        ],
    )

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

    assert result == {
        "relations": 2,
        "inserted": 1,
        "updated": 1,
        "deleted": 1,
        "skipped": 0,
    }
    # insert: 1 RelationFact + 2 RelationParticipant = 3 add
    assert len(db.add_calls) == 3
    # delete: rel_gone에 대한 participant + fact
    delete_params = [e[1] for e in db.execute_calls if "DELETE" in e[0]]
    assert any(p.get("rid") == "rel_gone" for p in delete_params)
    # update: rel_1에 대한 UPDATE
    update_params = [e[1] for e in db.execute_calls if "UPDATE relation_fact" in e[0]]
    assert len(update_params) == 1
    assert update_params[0]["rid"] == "rel_1"


# ── unknown short_id skip ──


def test_delta_skips_unknown_short_id(project_episode, tmp_path):
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    {
                        "base_short_id": "C99",  # canon에 없음
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "unknown base",
                    }
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[],
    )

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

    assert result["inserted"] == 0
    assert result["relations"] == 0
    assert len(db.add_calls) == 0


# ── existing relation with missing participant: skipped ──


def test_delta_skips_existing_with_missing_participant(project_episode, tmp_path):
    """기존 visual_variant의 participant가 누락된 경우 delta에서 skip."""
    pid, eid = project_episode
    _write_cp(
        tmp_path, pid, eid, "entity_relation",
        {
            "status": "completed",
            "data": {
                "relations": [
                    {
                        "base_short_id": "C01",
                        "variant_short_id": "C02",
                        "visual_similarity": True,
                        "reason": "ok",
                    }
                ]
            },
        },
    )
    db = _FakeDB(
        canon_rows=[_CanonRow("C01", "canon_C01"), _CanonRow("C02", "canon_C02")],
        existing_variants=[
            ("rel_broken", "broken", None, "canon_C02"),  # base 누락
        ],
    )

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

    # broken 관계는 existing 맵에서 제외됨 → C01-C02를 신규 insert로 처리
    assert result["inserted"] == 1
    assert result["updated"] == 0
    assert result["deleted"] == 0
