"""체크포인트 → DB 도메인 Service 오케스트레이터.

기존 `steps.py:_sync_checkpoints_to_db`의 5-way 호출 순서를 보존.
단일 commit로 원자성 유지. 단일 now timestamp로 Service간 일관성 보장.

예외 처리 계약 (Claude Phase 2 M7):
- 이 함수는 try/except를 내부에 두지 않음 — 예외는 호출자에게 전파.
- baseline `_sync_checkpoints_to_db`와 동일 (steps.py/image_steps.py에서
  rollback 책임을 담당).

W4 P3-2: `step_id` 주입 시 sync_status를 step_run 테이블에 기록.
- 성공: sync_status='synced' (주 세션, db.commit과 함께)
- 실패: sync_status='failed' + sync_error (별도 세션 — 호출자 rollback과 독립)
"""
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Dict, Iterable, Optional

from sqlalchemy import text as sql_text
from sqlalchemy.orm import Session as OrmSession

logger = logging.getLogger(__name__)


_SYNC_SUCCESS_UPDATE = sql_text(
    "UPDATE step_run SET sync_status = 'synced', sync_error = NULL, synced_at = :now "
    "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid"
)

# Codex P3-2 Medium: synced_at은 "마지막 성공 sync 시각"을 의미.
# 실패 시에는 sync_status/sync_error만 갱신하고 synced_at은 건드리지 않음
# (직전 성공 타임스탬프 보존 — "언제부터 stale이 됐나" 추적 가능).
_SYNC_FAILURE_UPDATE = sql_text(
    "UPDATE step_run SET sync_status = 'failed', sync_error = :err "
    "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid"
)

# W4 P3-3 (Codex High): repair에서 failed→synced 전환 시 "sync_error가 우리가 본
# 그 에러 메시지와 일치할 때만" 전환. 중간에 concurrent 프로세스가 같은 step을
# 다른 이유로 failed로 재기록했다면 sync_error가 달라지므로 건드리지 않음.
# prev_err가 NULL인 경우를 위해 (sync_error = :prev OR (sync_error IS NULL AND :prev IS NULL)) 사용.
_SYNC_REPAIR_GUARDED = sql_text(
    "UPDATE step_run SET sync_status = 'synced', sync_error = NULL, synced_at = :now "
    "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid "
    "AND sync_status = 'failed' "
    "AND (sync_error = :prev_err OR (sync_error IS NULL AND :prev_err IS NULL))"
)


def orchestrate_full_sync(
    project_id: str,
    episode_id: str,
    db: OrmSession,
    *,
    step_id: Optional[str] = None,
    repair_step_ids: Optional[Dict[str, Optional[str]]] = None,
) -> Dict[str, Dict[str, int]]:
    """5개 도메인 Service를 순차 호출 후 단일 commit.

    순서:
        1. EntitySyncService   (C/L/P canon + link)
        2. RelationSyncService (visual_variant)
        3. SceneStillSyncService (scene_still + scene_summary + shot_type)
        4. OutlookSyncService  (outlook canon + character_outlook link)
        5. ShelfSyncService    (저빈도 보류 canon + shelved link)
        6. EpisodeProjectionService (episode.status='analyzed' + appearance_count)

    Args:
        step_id: W4 P3-2 — 제공되면 step_run.sync_status를 갱신.
                 Post-step sync에서 전달 (per-step 관측성).
                 Pre-sync / snapshot restore / global sync에선 None.
        repair_step_ids: W4 P3-3 — {step_id: original_sync_error} 형태.
                 sync 성공 시 동일 트랜잭션 내에서 각 step의 sync_status를 'failed'→'synced'
                 로 전환. sync_error가 우리가 캡처한 original_sync_error와 일치할 때만
                 전환 — concurrent 프로세스가 같은 step을 다른 이유로 failed 재기록한
                 경우를 덮어쓰지 않기 위함 (Codex P3-3 High).

    Returns: 각 Service 결과 dict {"entity": {...}, "relation": {...}, ...}
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.services.checkpoint_sync.relation_sync_service import RelationSyncService
    from app.services.checkpoint_sync.scene_still_sync_service import SceneStillSyncService
    from app.services.checkpoint_sync.outlook_sync_service import OutlookSyncService
    from app.services.checkpoint_sync.shelf_sync_service import ShelfSyncService
    from app.services.checkpoint_sync.episode_projection_service import EpisodeProjectionService

    # Codex Phase 2 Item 1: baseline과 동일하게 전체 sync에서 하나의 now 공유.
    now = datetime.now(timezone.utc).isoformat()

    result: Dict[str, Dict[str, int]] = {}
    try:
        result["entity"] = EntitySyncService(db, project_id, episode_id, now=now).sync_from_checkpoint()
        result["relation"] = RelationSyncService(db, project_id, episode_id, now=now).sync_from_checkpoint()
        result["scene_still"] = SceneStillSyncService(db, project_id, episode_id, now=now).sync_from_checkpoint()
        result["outlook"] = OutlookSyncService(db, project_id, episode_id, now=now).sync_from_checkpoint()
        # ★★저빈도로 **보류한** 요소 — 지우는 대신 명부에 남긴다. 순서가
        #  entity sync **뒤**여야 한다: 살아남은 대상의 링크가 먼저 서야
        #  「되살리기」가 그 링크를 볼 수 있다.
        result["shelf"] = ShelfSyncService(db, project_id, episode_id, now=now).sync_from_checkpoint()
        result["episode"] = EpisodeProjectionService(db, project_id, episode_id, now=now).sync_from_checkpoint()
    except Exception as exc:
        # 실패 기록은 별도 세션 — 호출자가 주 세션 rollback하더라도 보존.
        if step_id:
            _record_sync_failure(project_id, episode_id, step_id, str(exc))
        raise

    # 성공 기록은 주 세션 (같이 commit). synced_at = now.
    if step_id:
        db.execute(_SYNC_SUCCESS_UPDATE, {
            "pid": project_id, "eid": episode_id, "sid": step_id, "now": now,
        })

    # W4 P3-3: repair 모드 — 같은 트랜잭션에서 failed→synced 전환.
    # Split commit 회피 (Codex Medium) + sync_error guard로 concurrent 덮어쓰기 방지 (Codex High).
    repaired_count = 0
    if repair_step_ids:
        for sid, prev_err in repair_step_ids.items():
            res = db.execute(_SYNC_REPAIR_GUARDED, {
                "pid": project_id, "eid": episode_id, "sid": sid,
                "now": now, "prev_err": prev_err,
            })
            repaired_count += res.rowcount or 0

    db.commit()
    logger.info(
        "orchestrate_full_sync: all changes committed atomically "
        "(episode %s, step=%s, repaired=%d)",
        episode_id[:8], step_id or "-", repaired_count,
    )
    return result


def _record_sync_failure(project_id: str, episode_id: str, step_id: str, error_msg: str) -> None:
    """주 세션과 독립된 세션에서 sync_status='failed' + sync_error를 UPDATE + commit.

    호출자가 주 세션을 rollback해도 실패 기록은 보존됨.
    synced_at은 건드리지 않음 (직전 성공 sync 시각 보존).
    SessionLocal은 orchestrator 임포트 시점에 바인딩된 전역 engine을 사용.
    recorder 자체가 실패하면 로깅만 하고 propagate하지 않음 — 주 실패 예외를 덮지 않기 위함.
    """
    try:
        from app.core.database import SessionLocal

        db2 = SessionLocal()
        try:
            db2.execute(_SYNC_FAILURE_UPDATE, {
                "pid": project_id, "eid": episode_id, "sid": step_id,
                "err": error_msg[:1000],
            })
            db2.commit()
        finally:
            db2.close()
    except Exception as rec_exc:
        logger.warning(
            "Failed to record sync_status=failed for step %s (%s): %s",
            step_id, episode_id[:8], rec_exc,
        )
