"""SnapshotService — 체크포인트 스냅샷 저장/목록/복원.

Phase 2.3 (architecture-refactor-final/02-final-roadmap.md §Phase 2.3).
기존 `steps.py:list_snapshots/create_snapshot/restore_snapshot`에서 이관.
"""
from __future__ import annotations

import json
import logging
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

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

from app.core.config import settings
from app.core.errors import AppError
from app.core.step_catalog import get_all_downstream_recursive


logger = logging.getLogger(__name__)


_SNAP_TS_RE = re.compile(
    r"^manifest_(\d{8}_\d{6})(?:_[a-zA-Z0-9_-]+)?\.json$"
)

_VALID_STATUSES = {
    "completed", "failed", "partial", "stale", "pending", "running",
    "not_applicable", "skipped", "error",
}


class SnapshotService:
    def __init__(self, db: OrmSession, project_id: str, episode_id: str):
        self.db = db
        self.project_id = project_id
        self.episode_id = episode_id

    @property
    def _base(self) -> Path:
        return Path(settings.projects_dir) / self.project_id / "checkpoints" / "episodes" / self.episode_id

    # ── list ──────────────────────────────────────────────

    def list_versions(self, step_id: Optional[str] = None) -> Dict[str, Any]:
        base = self._base
        if not base.exists():
            return {"snapshots": []}

        step_dirs = [base / step_id] if step_id else sorted(base.iterdir())

        ts_map: Dict[str, List[Dict]] = {}
        for step_dir in step_dirs:
            if not step_dir.is_dir():
                continue
            sid = step_dir.name
            for f in sorted(step_dir.glob("manifest_*.json")):
                if "prerestore" in f.name:
                    continue
                m = _SNAP_TS_RE.match(f.name)
                if not m:
                    continue
                ts = m.group(1)
                ts_map.setdefault(ts, []).append({
                    "step_id": sid,
                    "file": f.name,
                    "size": f.stat().st_size,
                })

        snapshots = []
        for ts in sorted(ts_map.keys(), reverse=True):
            steps = ts_map[ts]
            snapshots.append({
                "version": ts,
                "timestamp": f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:15]}",
                "step_count": len(steps),
                "steps": [s["step_id"] for s in steps],
            })

        return {"snapshots": snapshots}

    # ── create ────────────────────────────────────────────

    def save_snapshot(
        self,
        step_id: Optional[str] = None,
        label: Optional[str] = None,
    ) -> Dict[str, Any]:
        base = self._base
        if not base.exists():
            raise AppError(code="snapshot.no_checkpoints", message="체크포인트 디렉토리 없음", status_code=404)

        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        step_dirs = [base / step_id] if step_id else sorted(base.iterdir())
        saved: List[str] = []

        for step_dir in step_dirs:
            if not step_dir.is_dir():
                continue
            manifest = step_dir / "manifest.json"
            if not manifest.exists():
                continue
            suffix = f"_{label}" if label else ""
            archive = step_dir / f"manifest_{ts}{suffix}.json"
            shutil.copy2(str(manifest), str(archive))
            saved.append(step_dir.name)

        return {"version": ts, "label": label, "saved_steps": saved, "count": len(saved)}

    # ── restore ───────────────────────────────────────────

    def restore(self, version: str, step_id: Optional[str] = None) -> Dict[str, Any]:
        base = self._base
        if not base.exists():
            raise AppError(code="snapshot.not_found", message="체크포인트 디렉토리 없음", status_code=404)

        pattern = re.compile(
            rf"^manifest_{re.escape(version)}(?:_[a-zA-Z0-9_-]+)?\.json$"
        )
        step_dirs = [base / step_id] if step_id else sorted(base.iterdir())

        # ★도는 중인 스텝은 복원하지 않는다 (2026-08-26 Codex 재리뷰 BLOCK-4).
        #
        #  복원은 manifest 를 **먼저 덮고** step_run.status 를 주인이 누구든
        #  통째로 갈아 끼운다. 그 스텝이 지금 돌고 있으면 worker 가 읽는 바닥이
        #  발밑에서 바뀌고, 그 worker 가 결과를 적을 때 복원한 상태를 덮는다.
        #
        #  ★그냥 「파일 건드리기 전에 한 번 본다」로는 안 닫힌다 (2026-08-26
        #   Codex 2차 재리뷰 BLOCK-3). 보고 나서 파일을 덮는 사이에 worker 가
        #   자리를 잡으면 똑같은 일이 벌어진다. **대상 행을 `FOR UPDATE` 로
        #   잡고** 파일 변경과 DB 반영이 끝날 때까지 트랜잭션을 놓지 않는다 —
        #   그동안 worker 의 claim 은 그 행에서 기다린다.
        #  ★잠글 대상에 **하류까지** 넣는다 (2026-08-26 Codex 3차 재리뷰
        #   BLOCK-2). 종전에는 복원 대상만 잡고 첫 commit 에서 놓은 뒤 하류를
        #   따로 손봤다. 그 틈에 하류 worker 가 자리를 잡으면, 복원 **전**
        #   상류를 보고 시작한 그 주행이 그대로 계속된다.
        # ★「그 버전의 파일이 실제로 있는」 디렉토리만 대상이다. 디렉토리
        #  존재만 보면, 없는 버전을 찾았을 때도 아래 확인들이 걸려 엉뚱한
        #  사유로 막힌다(2026-08-26 실제로 그렇게 깨뜨렸다).
        def _복원할_파일이_있나(d: Path) -> bool:
            return d.is_dir() and any(
                pattern.match(f.name) and "prerestore" not in f.name
                for f in d.iterdir())

        target_ids = sorted({d.name for d in step_dirs
                             if _복원할_파일이_있나(d)})
        downstream_ids: List[str] = (
            sorted(get_all_downstream_recursive(step_id)) if step_id else [])
        lock_ids = sorted(set(target_ids) | set(downstream_ids))
        if lock_ids:
            stmt = sql_text(
                "SELECT step_id, status FROM step_run "
                "WHERE project_id = :pid AND episode_id = :eid "
                "  AND step_id IN :sids "
                "FOR UPDATE"
            ).bindparams(bindparam("sids", expanding=True))
            rows = self.db.execute(stmt, {
                "pid": self.project_id, "eid": self.episode_id,
                "sids": lock_ids,
            }).fetchall()
            busy = sorted(r[0] for r in rows if r[1] == "running")
            if busy:
                # 잡은 행을 놓고 나간다 — 여기서 안 놓으면 그 행이 이 세션의
                # 트랜잭션이 끝날 때까지 묶인다.
                self.db.rollback()
                raise AppError(
                    code="snapshot.step_running",
                    message=(
                        f"실행 중인 스텝은 복원할 수 없습니다: {', '.join(busy)}. "
                        "먼저 정지시킨 뒤 다시 시도하세요."
                    ),
                    status_code=409,
                )
            # ★`FOR UPDATE` 는 **이미 있는 행만** 잠근다. 체크포인트 파일은
            #  있는데 step_run 행이 없는 대상은 아무것도 안 잠겨서, 그 틈에
            #  worker 가 새로 INSERT 하며 자리를 잡을 수 있다. 그러면 복원의
            #  UPDATE 는 0줄인데 「복원했다」고 세어 버린다. 하나라도 비면
            #  막는다.
            locked = {r[0] for r in rows}
            missing = sorted(set(target_ids) - locked)
            if missing:
                self.db.rollback()
                raise AppError(
                    code="snapshot.step_run_missing",
                    message=(
                        f"복원할 스텝의 실행 기록이 없습니다: {', '.join(missing)}. "
                        "먼저 그 스텝을 한 번 실행해 기록을 만든 뒤 시도하세요."
                    ),
                    status_code=409,
                )

        now_iso = datetime.now(timezone.utc).isoformat()
        restored: List[str] = []
        # 되돌리기용 — (덮어쓴 manifest, 그 직전 백업). 파일 교체가 중간에
        # 실패하면 앞서 덮은 것들을 이것으로 되돌린다.
        swapped: List[tuple] = []

        try:
            for step_dir in step_dirs:
                if not step_dir.is_dir():
                    continue
                sid = step_dir.name
                candidates = [
                    f for f in step_dir.iterdir()
                    if pattern.match(f.name) and "prerestore" not in f.name
                ]
                if not candidates:
                    continue
                archive = sorted(candidates)[-1]

                manifest = step_dir / "manifest.json"
                backup = None
                if manifest.exists():
                    bak_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
                    backup = step_dir / f"manifest_{bak_ts}_prerestore.json"
                    shutil.copy2(str(manifest), str(backup))

                shutil.copy2(str(archive), str(manifest))
                swapped.append((manifest, backup))

                # 아카이브의 실제 status 읽어서 step_run에 반영
                try:
                    restored_data = json.loads(
                        manifest.read_text(encoding="utf-8"))
                    archived_status = restored_data.get("status") or "completed"
                except Exception:
                    archived_status = "completed"
                if archived_status not in _VALID_STATUSES:
                    archived_status = "completed"

                self.db.execute(sql_text(
                    "UPDATE step_run SET status = :status, updated_at = :now "
                    "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid"
                ), {
                    "pid": self.project_id, "eid": self.episode_id, "sid": sid,
                    "now": now_iso, "status": archived_status,
                })
                restored.append(sid)

            if not restored:
                raise AppError(
                    code="snapshot.version_not_found",
                    message=f"버전 {version}에 해당하는 스냅샷 없음",
                    status_code=404)

            # ★하류 무효화를 **같은 트랜잭션 안에서** 끝낸다. 종전에는 위에서
            #  한 번 commit 해 잠금을 놓은 뒤 따로 했는데, 그 틈에 하류가
            #  돌기 시작하면 복원 전 상류를 보고 시작한 주행이 그대로 이어졌다.
            if step_id and downstream_ids:
                for ds_id in downstream_ids:
                    self.db.execute(sql_text(
                        "UPDATE step_run SET status = 'stale', updated_at = :now "
                        "WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid "
                        "AND status = 'completed'"
                    ), {
                        "pid": self.project_id, "eid": self.episode_id,
                        "sid": ds_id, "now": now_iso,
                    })
                logger.info(
                    "Snapshot restore: marked %d downstream steps as stale for %s",
                    len(downstream_ids), step_id,
                )
            elif not step_id:
                # 전체 복원 → DB 파생 테이블 재구축 (Phase 2.1 orchestrator).
                # ★이것도 같은 트랜잭션 안이다 — 파생 테이블이 새 manifest 를
                #  보고 다시 만들어지기 전에 다른 실행이 끼어들면, 잠깐이지만
                #  옛 파생값과 새 manifest 가 섞인 판이 보인다.
                from app.services.checkpoint_sync import orchestrate_full_sync
                orchestrate_full_sync(self.project_id, self.episode_id, self.db)
                logger.info(
                    "Snapshot restore: DB resync completed for %d steps",
                    len(restored),
                )

            self.db.commit()
        except Exception:
            # ★파일과 DB 를 **같이** 되돌린다. DB 만 rollback 하면 이미 덮은
            #  manifest 는 그대로 남아 파일과 DB 가 갈린다.
            self.db.rollback()
            for manifest, backup in reversed(swapped):
                try:
                    if backup is not None and backup.exists():
                        shutil.copy2(str(backup), str(manifest))
                    elif manifest.exists():
                        # 원래 없던 파일이었다 — 만든 것을 지운다.
                        manifest.unlink()
                except Exception:  # noqa: BLE001
                    logger.error(
                        "Snapshot restore 되돌리기 실패 — %s 를 손으로 "
                        "확인해야 한다 (백업: %s)", manifest, backup,
                        exc_info=True)
            raise

        return {"version": version, "restored_steps": restored, "count": len(restored)}
