"""SceneStillSyncService — scene_detail + shot 체크포인트 → SceneStill UPSERT.

W4 P3-1: thin orchestrator. 실제 로직은 3개 도우미에 위임.
  - SceneStillCheckpointLoader  — 8개 체크포인트 → CheckpointBundle
  - SceneStillNormalizer        — bundle + EntityMaps → list[PlannedStill]
  - SceneStillWriter            — planned + bundle → DB UPSERT + summary/shot_type

외부 계약(sync_from_checkpoint 반환 `{"stills": n}`)은 변경 없음.
UPSERT 정책 (baseline): DELETE → INSERT 금지. 이미지 still_id 참조 보존.
"""
from __future__ import annotations

import logging
from typing import Dict

from app.services.checkpoint_sync._base import BaseSyncService
from app.services.checkpoint_sync._scene_still_contracts import EntityMaps
from app.services.checkpoint_sync.scene_still_checkpoint_loader import (
    SceneStillCheckpointLoader,
)
from app.services.checkpoint_sync.scene_still_normalizer import SceneStillNormalizer
from app.services.checkpoint_sync.scene_still_writer import SceneStillWriter


logger = logging.getLogger(__name__)


class SceneStillSyncService(BaseSyncService):
    def sync_from_checkpoint(self) -> Dict[str, int]:
        """scene_detail + shot 체크포인트 → SceneStill UPSERT.

        Returns: {"stills": n}

        scene_detail이 sync 가능하지 않은 상태(상류 force cascade로 invalidate되어 빈
        scenes 또는 running/error 상태)에서는 scene_still을 건드리지 않고 일찍 반환한다.
        그렇지 않으면 Normalizer가 빈 planned 리스트를 반환하고 Writer가 "계획에 없는
        모든 row"를 stale로 마킹해 기존 active scene_still 전체가 손상된다
        (validate_episode_ready가 거부).

        M2 Fix 2 (2026-05-01): partial 상태(일부 씬 fail, 나머지 완료)는 sync 허용 —
        loader의 `is_cp_syncable("scenes")`이 partial+데이터있음을 syncable로 판단.
        scene_detail 재완료 시 다음 sync가 정확한 plan key로 정상화한다.
        """
        bundle = SceneStillCheckpointLoader(self._load_cp).load()
        if not bundle.sd_completed:
            logger.info(
                "Skipping scene_still sync: scene_detail 데이터 없음 "
                "(cascade or pre-analysis). Existing rows preserved."
            )
            return {"stills": 0}
        entity_maps = self._load_entity_maps()
        planned = SceneStillNormalizer(entity_maps).normalize(bundle)
        stills = SceneStillWriter(
            self.db, self.project_id, self.episode_id, self.now
        ).write(planned, bundle)

        # Orphan 정리 (feedback_never_delete_images 규칙):
        # 분석 step 재실행으로 scene_still id 가 바뀌어 image_asset.still_id 가
        # 존재하지 않는 still 을 가리키는 경우만 삭제. 평상 UPSERT 흐름에서는
        # parent row 가 stale 로 표시될 뿐 삭제되지 않으므로 0 건이 정상.
        # 외부 개입 / 프로젝트 재import 등 예외 케이스 대비 방어 net.
        from app.services.scene_persistence_service import ScenePersistenceService
        try:
            sps = ScenePersistenceService(self.db, self.project_id)
            removed = sps.delete_orphan_scene_assets(self.episode_id)
            if removed:
                logger.warning(
                    "scene_still sync: cleaned %d orphan image_asset row(s) "
                    "(unexpected — investigate parent still removal source)",
                    removed,
                )
        except Exception as exc:
            logger.error(
                "scene_still sync: orphan cleanup failed (non-fatal): %s", exc
            )

        return {"stills": stills}

    def _load_entity_maps(self) -> EntityMaps:
        """EntityCanon에서 name↔id↔short_id 매핑 조회.

        Normalizer가 visible_entities 해석 시 사용. sync_from_checkpoint의
        sd_completed 가드 이후에만 호출된다 (가드에서 조기 반환).
        """
        from app.models.project import EntityCanon

        rows = self.db.query(EntityCanon).filter(
            EntityCanon.project_id == self.project_id
        ).all()
        maps = EntityMaps()
        for e in rows:
            maps.name_to_id[e.name] = e.id
            if e.short_id:
                maps.name_to_short[e.name] = e.short_id
                maps.short_to_id[e.short_id] = e.id
                maps.short_to_name[e.short_id] = e.name
        return maps
