"""Partial sync 정책 단일 표준 단위 테스트 — M2 Fix 2 (2026-05-01).

배경:
    오늘 사고 — scene_detail 1 shot fail → status="partial" → loader가 거부 →
    scene_still 0 row sync → scene_image_pipeline `gate.no_scenes` 차단.
    1 shot fail이 64 shot 차단 (10x fan-out).

해소:
    `is_cp_syncable(cp, data_keys)` 공통 함수로 4 sync_service 통일.
    - partial + 데이터 있음 → syncable (오늘 사고 핵심)
    - partial + 빈 데이터(cascade 직후) → not syncable
    - completed + 데이터 있음 → syncable
    - running/error/None → not syncable
"""
from __future__ import annotations

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

import pytest

from app.services.checkpoint_sync._base import is_cp_syncable


# ── is_cp_syncable 단위 테스트 ──────────────────────────────────────────


class TestIsCpSyncable:
    """단일 표준 가드 함수의 모든 분기를 검증."""

    def test_completed_with_data(self):
        """정상: status=completed + 데이터 있음 → syncable."""
        cp = {"status": "completed", "data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_partial_with_data(self):
        """M2 Fix 2 핵심: partial이라도 데이터 있으면 sync 허용 (오늘 사고 시나리오)."""
        cp = {"status": "partial", "data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_partial_with_empty_data(self):
        """cascade 가드: partial + scenes=[] (cascade 직후)는 skip."""
        cp = {"status": "partial", "data": {"scenes": []}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_completed_with_empty_data_authoritative(self):
        """Codex P2-2: completed + 빈 데이터는 authoritative zero rows로 처리되어야 한다.

        이전 동작은 False 반환 → fan-out 0 결과가 sync skip → 옛 row 잔존.
        새 정책 (status별 분기):
          - completed → True (authoritative)
          - partial + empty → False (cascade 가드)
        """
        cp = {"status": "completed", "data": {"scenes": []}}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_completed_with_no_data_keys_authoritative(self):
        """completed인데 data에 핵심 키 자체 없음 → True (zero rows)."""
        cp = {"status": "completed", "data": {"other": [{"id": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_completed_with_no_data_field_authoritative(self):
        """completed인데 data 필드 자체 없음 → True (zero rows)."""
        cp = {"status": "completed"}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_completed_with_data_not_dict_still_authoritative(self):
        """completed면 data가 malformed라도 True (cleanup 진행 가능)."""
        cp = {"status": "completed", "data": "malformed"}
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_running(self):
        """running 상태는 sync 금지 (데이터 있어도)."""
        cp = {"status": "running", "data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_error(self):
        """error 상태는 sync 금지."""
        cp = {"status": "error", "data": {}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_failed_status(self):
        """failed 상태도 sync 금지."""
        cp = {"status": "failed", "data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_stale_status(self):
        """stale 상태(invalidate_downstream 직후)는 sync 금지."""
        cp = {"status": "stale", "data": {"scenes": []}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_no_cp(self):
        """체크포인트 없음(pre-analysis) → skip."""
        assert is_cp_syncable(None, ["scenes"]) is False

    def test_empty_cp(self):
        """빈 dict → skip (status 없음)."""
        assert is_cp_syncable({}, ["scenes"]) is False

    def test_no_status_field(self):
        """status 필드 자체 없음 → skip."""
        cp = {"data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_status_none(self):
        """status=None (malformed) → skip."""
        cp = {"status": None, "data": {"scenes": [{"i": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_partial_no_data_field(self):
        """partial + data 필드 자체 없음 → skip (cascade 가드)."""
        cp = {"status": "partial"}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_partial_data_not_dict(self):
        """partial + data가 dict 아님 (malformed) → skip."""
        cp = {"status": "partial", "data": "not a dict"}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_partial_non_list_data_value(self):
        """partial + 핵심 키가 list가 아님 (malformed) → skip."""
        cp = {"status": "partial", "data": {"scenes": "not a list"}}
        assert is_cp_syncable(cp, ["scenes"]) is False

    def test_multiple_keys_any_match(self):
        """entity_t2i 패턴 — characters/locations/props 중 하나라도 비어있지 않으면 syncable."""
        cp = {
            "status": "partial",
            "data": {
                "characters": [],
                "locations": [{"id": 1}],
                "props": [],
            },
        }
        assert is_cp_syncable(cp, ["characters", "locations", "props"]) is True

    def test_multiple_keys_all_empty(self):
        """모든 핵심 list가 비어있으면 not syncable."""
        cp = {
            "status": "partial",
            "data": {"characters": [], "locations": [], "props": []},
        }
        assert is_cp_syncable(cp, ["characters", "locations", "props"]) is False

    def test_outlooks_key(self):
        """outlook_phase3 패턴 — outlooks 키."""
        cp = {"status": "partial", "data": {"outlooks": [{"id": "O01"}]}}
        assert is_cp_syncable(cp, ["outlooks"]) is True

    def test_relations_key(self):
        """entity_relation 패턴 — relations 키."""
        cp = {"status": "completed", "data": {"relations": [{"base": "C01", "variant": "C01_v"}]}}
        assert is_cp_syncable(cp, ["relations"]) is True

    def test_partial_keys_not_in_data(self):
        """partial + data에 핵심 키 자체가 없음 → skip (cascade 가드)."""
        cp = {"status": "partial", "data": {"other_key": [{"id": 1}]}}
        assert is_cp_syncable(cp, ["scenes"]) is False


# ── sync_service 통합 동작 (실제 cp 디스크 + 단일 표준 통일 검증) ────────


@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 TestSceneStillLoaderPartialPolicy:
    """SceneStillCheckpointLoader가 단일 표준을 사용하는지 통합 검증."""

    def test_partial_with_scenes_sd_completed_true(self, project_episode, tmp_path):
        """M2 Fix 2: partial + scenes 있으면 sd_completed=True."""
        from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
            SceneStillCheckpointLoader,
        )

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "scene_detail",
            {"status": "partial", "data": {"scenes": [{"scene_index": 1}, {"scene_index": 2}]}},
        )

        # 디스크에서 직접 로드
        manifest_path = tmp_path / pid / "checkpoints" / "episodes" / eid / "scene_detail" / "manifest.json"
        cp = json.loads(manifest_path.read_text())

        def loader_fn(step_id: str):
            return cp if step_id == "scene_detail" else None

        bundle = SceneStillCheckpointLoader(loader_fn).load()
        assert bundle.sd_completed is True
        assert len(bundle.scenes) == 2

    def test_partial_empty_scenes_sd_completed_false(self, project_episode, tmp_path):
        """cascade 가드: partial + scenes=[]는 sd_completed=False."""
        from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
            SceneStillCheckpointLoader,
        )

        def loader_fn(step_id: str):
            return {"status": "partial", "data": {"scenes": []}} if step_id == "scene_detail" else None

        bundle = SceneStillCheckpointLoader(loader_fn).load()
        assert bundle.sd_completed is False
        assert bundle.scenes == []


class TestEntitySyncPartialPolicy:
    """EntitySyncService가 partial+데이터 있음에서 sync 진행하는지 검증."""

    def test_partial_with_data_proceeds(self, project_episode, tmp_path, monkeypatch):
        """partial이라도 characters/locations/props 중 하나가 있으면 sync 진행."""
        from app.services.checkpoint_sync import EntitySyncService

        pid, eid = project_episode
        # Area B (Task 4 / C4, 2026-05-13): metadata_json normalized shape 의무.
        _loc_md = {
            "location": {"space_profile": {
                "kind": "single_space",
                "allowed_space_keys": ["main"],
                "default_space_key": None,
            }},
            "visual_identity": None,
        }
        _write_cp(
            tmp_path, pid, eid, "entity_t2i",
            {
                "schema_version": 3,
                "status": "partial",
                "data": {
                    "characters": [],
                    "locations": [{
                        "name": "옥탑방", "short_id": "L01",
                        "metadata_json": _loc_md,
                    }],
                    "props": [],
                },
            },
        )

        # DB는 mock — sync 진행은 됐지만 실제 row 변경은 검증하지 않음
        db = MagicMock()
        # query().filter().all() 체인 모두 빈 list 반환
        db.query.return_value.filter.return_value.all.return_value = []

        result = EntitySyncService(db, pid, eid).sync_from_checkpoint()
        # skipped=0이면 sync 진행됐다는 의미
        assert result["skipped"] == 0, "partial+데이터 있음에서는 sync 진행해야 함 (M2 Fix 2)"

    def test_partial_empty_data_skips(self, project_episode, tmp_path):
        """partial + 모든 list 비었음 → skip (cascade 가드)."""
        from app.services.checkpoint_sync import EntitySyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "entity_t2i",
            {"status": "partial", "data": {"characters": [], "locations": [], "props": []}},
        )

        db = MagicMock()
        result = EntitySyncService(db, pid, eid).sync_from_checkpoint()
        assert result == {"synced": 0, "removed": 0, "skipped": 1}
        # query 자체가 일어나지 않아야 (조기 반환)
        db.query.assert_not_called()


class TestRelationSyncPartialPolicy:
    """RelationSyncService가 partial+relations 있음에서 sync 진행하는지 검증."""

    def test_partial_with_relations_proceeds(self, project_episode, tmp_path):
        """partial이라도 relations 있으면 sync 진행 (skipped=0)."""
        from app.services.checkpoint_sync import RelationSyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "entity_relation",
            {
                "status": "partial",
                "data": {
                    "relations": [
                        {
                            "base_short_id": "C01",
                            "variant_short_id": "C01_v",
                            "visual_similarity": False,  # filter out → desired 비음
                            "reason": "test",
                        },
                    ],
                },
            },
        )

        db = MagicMock()
        db.query.return_value.filter.return_value.all.return_value = []
        # _load_existing_visual_variants의 execute().fetchall()
        db.execute.return_value.fetchall.return_value = []

        result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
        # skipped=0이면 sync 진행됨 (relations 자체가 비어있지 않으므로)
        assert result["skipped"] == 0

    def test_partial_empty_relations_skips(self, project_episode, tmp_path):
        """partial + relations=[] → skip (cascade 가드)."""
        from app.services.checkpoint_sync import RelationSyncService

        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


class TestOutlookSyncPartialPolicy:
    """OutlookSyncService가 partial+outlooks 있음에서 sync 진행하는지 검증."""

    def test_partial_with_outlooks_proceeds(self, project_episode, tmp_path):
        """partial이라도 outlooks 있으면 phase3 채택 (legacy fallback X)."""
        from app.services.checkpoint_sync import OutlookSyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "outlook_phase3",
            {
                "status": "partial",
                "data": {
                    "outlooks": [{"name": "교복", "outlook_id": "O01", "description": "test"}],
                    "scene_assignments": [],
                },
            },
        )

        db = MagicMock()
        db.query.return_value.filter.return_value.all.return_value = []
        db.execute.return_value.fetchall.return_value = []

        result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()
        # outlooks 1건 처리 → outlook_count >= 1
        assert result["outlooks"] >= 1, "partial+outlooks 있음에서는 sync 진행해야 함"

    def test_partial_empty_outlooks_falls_back_to_legacy(self, project_episode, tmp_path):
        """partial + outlooks=[] → phase3 skip → legacy outlook_extraction 시도.

        legacy도 없으면 outlooks=0 반환.
        """
        from app.services.checkpoint_sync import OutlookSyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "outlook_phase3",
            {"status": "partial", "data": {"outlooks": []}},
        )

        db = MagicMock()
        db.execute.return_value.fetchall.return_value = []

        result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()
        # phase3가 not syncable → legacy 시도 (없음) → outlooks=0
        assert result["outlooks"] == 0


# ── Codex P1-2: partial 시 stale 제거 skip 정책 ─────────────────────────


class TestEntitySyncPartialCleanupGuard:
    """EntitySyncService P1-2: partial 시 stale link 제거 skip."""

    def _build_entity_mocks(self):
        """A, B canon + 각 link mock 생성 (sentinel으로 자동 attribute 부작용 차단)."""
        canon_a = MagicMock()
        canon_a.id = "cid_A"
        canon_a.name = "A"
        canon_a.short_id = "C01"
        canon_a.entity_type = "character"
        canon_a.description = ""
        canon_a.t2i_prompt = ""

        canon_b = MagicMock()
        canon_b.id = "cid_B"
        canon_b.name = "B"
        canon_b.short_id = "C02"
        canon_b.entity_type = "character"
        canon_b.description = ""
        canon_b.t2i_prompt = ""

        link_a = MagicMock()
        link_a.canon_id = "cid_A"
        link_b = MagicMock()
        link_b.canon_id = "cid_B"

        return canon_a, canon_b, link_a, link_b

    def _setup_query_chain(self, db, all_call_results):
        """query().filter().all()의 호출 순서대로 결과 반환."""
        call_idx = {"i": 0}

        def query_all(*args, **kwargs):
            idx = call_idx["i"]
            call_idx["i"] += 1
            return all_call_results[idx] if idx < len(all_call_results) else []

        db.query.return_value.filter.return_value.all.side_effect = query_all

    def test_partial_skips_stale_link_cleanup(self, project_episode, tmp_path):
        """partial cp는 미포함 canon의 link를 삭제하지 않는다.

        시나리오: cp에 1 entity만 있고 DB에 다른 link 1개 더 있을 때, partial이면
        그 link는 보존돼야 함 (다음 completed에서 정리).
        """
        from app.services.checkpoint_sync import EntitySyncService

        pid, eid = project_episode
        # Area B (Task 4 / C4): metadata_json normalized shape 의무.
        _char_md = {"location": None, "visual_identity": None}
        _write_cp(
            tmp_path, pid, eid, "entity_t2i",
            {
                "schema_version": 3,
                "status": "partial",
                "data": {
                    "characters": [{
                        "name": "A", "short_id": "C01",
                        "metadata_json": _char_md,
                    }],
                    "locations": [],
                    "props": [],
                },
            },
        )

        db = MagicMock()
        canon_a, canon_b, link_a, link_b = self._build_entity_mocks()
        # 호출 순서: existing_canons / _existing_links / conflicting / still_null
        self._setup_query_chain(db, [
            [canon_a, canon_b],
            [link_a, link_b],
            [],
            [],
        ])

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

        # link_b는 cp에 없지만 partial이므로 삭제 안 됨
        db.delete.assert_not_called()
        assert result["removed"] == 0, (
            "partial cp에서는 stale link 제거 X — 데이터 손상 방지"
        )

    def test_completed_runs_stale_link_cleanup(self, project_episode, tmp_path):
        """completed cp는 미포함 canon의 link를 정상적으로 제거한다."""
        from app.services.checkpoint_sync import EntitySyncService

        pid, eid = project_episode
        # Area B (Task 4 / C4): metadata_json normalized shape 의무.
        _char_md = {"location": None, "visual_identity": None}
        _write_cp(
            tmp_path, pid, eid, "entity_t2i",
            {
                "schema_version": 3,
                "status": "completed",
                "data": {
                    "characters": [{
                        "name": "A", "short_id": "C01",
                        "metadata_json": _char_md,
                    }],
                    "locations": [],
                    "props": [],
                },
            },
        )

        db = MagicMock()
        canon_a, canon_b, link_a, link_b = self._build_entity_mocks()
        self._setup_query_chain(db, [
            [canon_a, canon_b],
            [link_a, link_b],
            [],
            [],
        ])

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

        # link_b는 cp에 없으므로 completed에서는 삭제됨
        # delete가 link_b로 1번 호출됐는지 확인 (다른 호출이 없어야)
        delete_calls = db.delete.call_args_list
        link_b_deletes = [c for c in delete_calls if c.args == (link_b,)]
        assert len(link_b_deletes) == 1, (
            f"link_b should be deleted once, got delete calls: {delete_calls}"
        )
        assert result["removed"] == 1


class TestOutlookSyncPartialCleanupGuard:
    """OutlookSyncService P1-2: partial 시 stale outlook canon + character_outlook 보존."""

    def test_partial_skips_orphan_cleanup(self, project_episode, tmp_path):
        """partial 시 orphan outlook 표시 skip (orphans_marked=0)."""
        from app.services.checkpoint_sync import OutlookSyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "outlook_phase3",
            {
                "status": "partial",
                "data": {
                    "outlooks": [{"name": "X", "outlook_id": "O01", "description": ""}],
                    "scene_assignments": [],
                },
            },
        )

        db = MagicMock()
        db.query.return_value.filter.return_value.all.return_value = []
        # _mark_orphan_outlooks가 호출됐다면 이 fetchall이 자동 mock으로 채워짐.
        # partial 가드가 작동하면 호출 자체가 일어나지 않아야.
        db.execute.return_value.fetchall.return_value = []

        result = OutlookSyncService(db, pid, eid).sync_from_checkpoint()
        assert result["orphans_marked"] == 0, (
            "partial cp에서는 orphan outlook 표시 X"
        )


class TestRelationSyncPartialCleanupGuard:
    """RelationSyncService P1-2: partial 시 stale relation 보존."""

    def test_partial_skips_stale_relation_delete(self, project_episode, tmp_path):
        """partial cp는 missing relation을 삭제하지 않는다."""
        from app.services.checkpoint_sync import RelationSyncService

        pid, eid = project_episode
        _write_cp(
            tmp_path, pid, eid, "entity_relation",
            {
                "status": "partial",
                "data": {
                    "relations": [
                        {
                            "base_short_id": "C01",
                            "variant_short_id": "C01_v",
                            "visual_similarity": True,
                            "reason": "test",
                        },
                    ],
                },
            },
        )

        db = MagicMock()

        # _existing_visual_variants에서 (X, Y) 1건 — cp에 없으니 normal에선 삭제 대상.
        # canon 매핑은 cp short_id를 X_id, Y_id로 잡지 못하게 → 빈 desired
        # 그래서 desired={}, existing={(X_id, Y_id): (rid, reason)}.
        # partial → to_delete가 set()이므로 DELETE 쿼리 발생 X.
        canon_rows = []  # short_id → canon_id 매핑 비음 → desired={}
        db.query.return_value.filter.return_value.all.return_value = canon_rows

        existing_row = MagicMock()
        existing_row.__getitem__ = lambda self, idx: [
            "rel_id_1", "old reason", "X_id", "Y_id",
        ][idx]
        db.execute.return_value.fetchall.return_value = [existing_row]

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

        # partial이므로 to_delete=set() → deleted=0
        assert result["deleted"] == 0, (
            "partial cp에서는 stale relation DELETE X"
        )

    def test_completed_empty_relations_proceeds_to_cleanup(self, project_episode, tmp_path):
        """P2-2 + P1-2 상호작용: completed + relations=[]는 sync 진입(authoritative).

        desired={} + 기존 visual_variant 1건이 있으면 to_delete=1로 정리됨.
        partial이었으면 to_delete=0(보호) — 이 차이를 명시.
        """
        from app.services.checkpoint_sync import RelationSyncService

        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 = []

        # 기존 visual_variant 1건 시뮬레이트
        existing_row = MagicMock()
        existing_row.__getitem__ = lambda self, idx: [
            "rel_id_1", "old reason", "X_id", "Y_id",
        ][idx]
        # ★★가짜 db 가 **모든 조회에 같은 행**을 주면, `part_of` 조회까지 그
        #  행을 받아 두 번 세어진다 (§2-6.5 로 조회가 둘이 됐다, 2026-09-01).
        #  묻는 것을 보고 답하게 해야 이 시험이 **visual_variant 만** 잰다.
        def _fetch(sql, params=None):
            got = MagicMock()
            text = str(getattr(sql, "text", sql))
            got.fetchall.return_value = (
                [] if "part_of" in text or ":rt" in text else [existing_row])
            return got

        db.execute.side_effect = _fetch

        result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
        # P2-2: completed → sync 진입 (skipped=0)
        assert result["skipped"] == 0
        # cleanup 진행 — desired={}이므로 existing 1건이 to_delete=1
        assert result["deleted"] == 1, (
            "completed + relations=[]: 모든 기존 relation 삭제 (authoritative zero)"
        )


# ── P2-2 + P1-2 상호작용 매트릭스 ─────────────────────────────────────


class TestCompletedEmptyAuthoritative:
    """P2-2: completed+empty가 authoritative zero rows로 처리되는지 (sync 진입 + cleanup)."""

    def test_entity_completed_empty_runs_cleanup(self, project_episode, tmp_path):
        """completed + characters=[]/locations=[]/props=[]: is_cp_syncable에서
        이 경우 핵심 list 검사로 막혀 sync 진입 X. P2-2의 의도는 status=completed면
        '데이터 키가 아예 없거나 이상한' 케이스도 authoritative로 보는 것.

        실제 entity_t2i가 completed로 모든 캐논 0개 작성하는 일은 거의 없어
        해당 진입 후 cleanup은 보장되지 않지만, completed+'관련없는 키'는 통과한다.
        """
        from app.services.checkpoint_sync._base import is_cp_syncable

        # P2-2: completed면 핵심 키 검사 우회하고 True 반환.
        # 실제 entity_sync는 data.get("characters", []) 등 빈 list로 loop → 0건 처리.
        cp = {"status": "completed", "data": {}}
        # entity_sync가 받는 keys
        assert is_cp_syncable(cp, ["characters", "locations", "props"]) is True
        # outlook_sync
        assert is_cp_syncable(cp, ["outlooks"]) is True
        # relation_sync
        assert is_cp_syncable(cp, ["relations"]) is True
        # scene_still loader
        assert is_cp_syncable(cp, ["scenes"]) is True

    def test_relation_completed_no_data_no_keyerror(self, project_episode, tmp_path):
        """P2-2 + 가드: completed + data 키 자체 없음 → sync 진입하되 KeyError 없음."""
        from app.services.checkpoint_sync import RelationSyncService

        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 = []
        # KeyError 없이 정상 반환
        result = RelationSyncService(db, pid, eid).sync_from_checkpoint()
        assert result["skipped"] == 0
        assert result["deleted"] == 0  # existing 없음

    def test_scene_still_loader_completed_no_data_no_keyerror(self, project_episode, tmp_path):
        """P2-2 + 가드: completed + data 키 없음 → loader가 KeyError 없이 빈 scenes."""
        from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
            SceneStillCheckpointLoader,
        )

        def loader_fn(step_id: str):
            if step_id == "scene_detail":
                return {"status": "completed"}  # data 필드 자체 없음
            return None

        bundle = SceneStillCheckpointLoader(loader_fn).load()
        assert bundle.sd_completed is True  # P2-2 authoritative
        assert bundle.scenes == []
        assert bundle.sd_partial is False


class TestSceneStillSyncPartialCleanupGuard:
    """SceneStillSyncService P1-2: partial scene_detail 시 stale 마킹 skip."""

    def test_partial_scene_detail_skips_stale_marking(self, project_episode, tmp_path):
        """partial scene_detail은 미포함 씬의 row를 stale 마킹하지 않는다.

        bundle.sd_partial=True이면 Writer가 stale 루프 우회.
        """
        from app.services.checkpoint_sync._scene_still_contracts import (
            CheckpointBundle, PlannedStill,
        )
        from app.services.checkpoint_sync.scene_still_writer import SceneStillWriter

        pid, eid = project_episode
        # bundle: scenes=[1] (planned 1건), sd_partial=True
        bundle = CheckpointBundle()
        bundle.sd_completed = True
        bundle.sd_partial = True

        planned = [
            PlannedStill(
                key=(1, 0),
                still_index=0,
                scene_index=1,
                t2i_composer_version="v1",
                columns={"shot_index": 0, "status": "ready"},
            ),
        ]

        # 기존 scene_still 2개: (1, 0)는 plan에 있음, (2, 0)는 없음.
        # partial이면 (2, 0)는 stale 마킹 X.
        existing_row_in_plan = MagicMock(id="ss_1", scene_index=1, shot_index=0, still_index=0)
        existing_row_missing = MagicMock(id="ss_2", scene_index=2, shot_index=0, still_index=99)

        db = MagicMock()
        db.query.return_value.filter.return_value.all.return_value = [
            existing_row_in_plan, existing_row_missing,
        ]

        writer = SceneStillWriter(db, pid, eid, "2026-05-01T00:00:00")
        writer.write(planned, bundle)

        # partial: missing row의 status가 stale로 바뀌면 안 됨
        # MagicMock.status는 set 호출 추적 가능
        # (set 호출 없이 attribute access만 수행됨)
        # 단정적: status가 직접 'stale'로 set되는 코드가 호출되지 않았는지.
        # MagicMock spec가 없으면 attribute set은 항상 가능 → setattr 추적
        # 더 명확하게는: partial 시 stale 루프가 우회됐는지 로그 메시지로 확인할 수 있음.
        # 여기서는 still_index가 -1로 바뀌지 않은지 확인.
        # MagicMock은 set 후에도 새 값이 attribute로 저장됨.
        # existing_row_missing.still_index가 99(원래)에서 -1로 바뀌면 안 됨.
        # 그런데 MagicMock의 자식 attribute는 set 가능 + 값 추적.
        # → assert existing_row_missing.still_index != -1
        assert existing_row_missing.still_index != -1, (
            "partial scene_detail에서는 missing 씬의 still_index를 -1로 마킹하면 안 됨"
        )

    def test_completed_scene_detail_runs_stale_marking(self, project_episode, tmp_path):
        """completed scene_detail은 미포함 씬의 row를 stale 마킹한다 (baseline 유지)."""
        from app.services.checkpoint_sync._scene_still_contracts import (
            CheckpointBundle, PlannedStill,
        )
        from app.services.checkpoint_sync.scene_still_writer import SceneStillWriter

        pid, eid = project_episode
        bundle = CheckpointBundle()
        bundle.sd_completed = True
        bundle.sd_partial = False  # completed

        planned = [
            PlannedStill(
                key=(1, 0),
                still_index=0,
                scene_index=1,
                t2i_composer_version="v1",
                columns={"shot_index": 0, "status": "ready"},
            ),
        ]

        existing_row_in_plan = MagicMock(id="ss_1", scene_index=1, shot_index=0, still_index=0)
        existing_row_missing = MagicMock(id="ss_2", scene_index=2, shot_index=0, still_index=99)

        db = MagicMock()
        db.query.return_value.filter.return_value.all.return_value = [
            existing_row_in_plan, existing_row_missing,
        ]

        writer = SceneStillWriter(db, pid, eid, "2026-05-01T00:00:00")
        writer.write(planned, bundle)

        # completed: missing row의 still_index가 -1로 마킹됨 (baseline)
        assert existing_row_missing.still_index == -1
        assert existing_row_missing.status == "stale"


class TestSceneStillLoaderPartialFlag:
    """SceneStillCheckpointLoader가 sd_partial 플래그를 정확히 설정하는지."""

    def test_partial_sets_sd_partial_true(self, project_episode, tmp_path):
        from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
            SceneStillCheckpointLoader,
        )

        def loader_fn(step_id: str):
            if step_id == "scene_detail":
                return {"status": "partial", "data": {"scenes": [{"scene_index": 1}]}}
            return None

        bundle = SceneStillCheckpointLoader(loader_fn).load()
        assert bundle.sd_completed is True
        assert bundle.sd_partial is True

    def test_completed_sets_sd_partial_false(self, project_episode, tmp_path):
        from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
            SceneStillCheckpointLoader,
        )

        def loader_fn(step_id: str):
            if step_id == "scene_detail":
                return {"status": "completed", "data": {"scenes": [{"scene_index": 1}]}}
            return None

        bundle = SceneStillCheckpointLoader(loader_fn).load()
        assert bundle.sd_completed is True
        assert bundle.sd_partial is False
