"""v0.5.13 일괄 재분석 마이그레이션 스크립트.

**대상 프로젝트 조건**: shot-more 파이프라인(v0.5.0+)의 아래 단계가 모두
`completed` 또는 `partial` 상태여야 한다. 하나라도 `pending`/`blocked`면
해당 에피소드는 자동 스킵된다 (구(old) 파이프라인만 돌린 프로젝트 보호).

필수 선행 단계:
  - shot_validator, shot_staging, shot_director,
  - scene_consistency, scene_detail, shot_dependency_t2i

작업:
  1. 자동 스냅샷 저장 (label=pre_v0513_migration)
  2. scene_consistency force (v4 프롬프트 — 전신/확대 중복 keep 분리)
  3. shot_dependency_t2i force (v5 프롬프트 — zoom_in_detail 재분류)
  4. scene_detail force (v9 프롬프트 — 복장 언급 + zoom_in_detail 흡수)
  5. t2i_review force

사용 예:
  python scripts/migrate_v0513_reanalyze.py PID1 PID2 PID3
  python scripts/migrate_v0513_reanalyze.py --dry-run PID1
  python scripts/migrate_v0513_reanalyze.py --base-url http://localhost:8000 PID1

원칙:
  - 프로젝트 간 병렬 금지 (rate-limit)
  - 단계 간 완료 폴링 (30초 간격, 최대 30분)
  - 실패 시 해당 에피소드/프로젝트 스킵, 나머지 계속
  - 이미지 재생성은 포함 안 함 (별도 판단 필요)
"""
from __future__ import annotations

import argparse
import os
import sys
import time
from typing import Dict, List, Optional

import requests

DEFAULT_BASE_URL = os.environ.get("THEROAD_BASE_URL", "http://localhost:8000")
DEFAULT_USERNAME = os.environ.get("THEROAD_USERNAME", "admin")
DEFAULT_PASSWORD = os.environ.get("THEROAD_PASSWORD", "admin123")

FORCE_STEPS = [
    # scene_consistency → scene_detail → shot_dependency_t2i 체인 의존:
    #   - scene_detail depends_on scene_consistency (step_manifest.py:542)
    #   - shot_dependency_t2i depends_on [scene_detail, scene_consistency] (step_manifest.py:555)
    # scene_detail의 scene_context_loader는 shot_dependency_t2i 체크포인트를 우선 읽으므로
    # 더 정확히는 "shot_dependency_t2i → scene_detail"로 재호출하는 2-pass 구조이나,
    # 최초 run 시점에는 scene_consistency가 반드시 먼저 돌아야 두 하류 단계가 stale 없이 읽힌다.
    "scene_consistency",
    "shot_dependency_t2i",
    "scene_detail",
    "t2i_review",
]

# 이 단계들이 모두 completed/partial 상태여야 마이그레이션 대상 자격이 있다.
# 구(old) 파이프라인(shot_extract + shot_selection + scene_detail만)으로만 완료된
# 프로젝트는 여기서 스킵된다 — 전체 재분석이 필요하기 때문.
REQUIRED_PREREQ_STEPS = [
    "shot_validator",
    "shot_staging",
    "shot_director",
    "scene_consistency",
    "scene_detail",
    "shot_dependency_t2i",
]
POLL_INTERVAL_SEC = 30
POLL_TIMEOUT_SEC = 30 * 60  # 30분


class Migration:
    def __init__(self, base_url: str, session: requests.Session) -> None:
        self.base_url = base_url.rstrip("/")
        self.sess = session
        self._creds: Optional[tuple] = None  # (username, password) — 세션 만료 시 재로그인

    def login(self, username: str, password: str) -> None:
        resp = self.sess.post(
            f"{self.base_url}/api/v1/auth/login",
            json={"username": username, "password": password},
            timeout=10,
        )
        resp.raise_for_status()
        self._creds = (username, password)
        print(f"[login] OK — user={resp.json().get('username')}")

    def _relogin_if_needed(self, resp: requests.Response) -> bool:
        """401이면 재로그인 시도. 재시도 가능하면 True."""
        if resp.status_code != 401 or not self._creds:
            return False
        print("    [session expired] re-logging in...")
        try:
            self.login(*self._creds)
            return True
        except requests.RequestException as exc:
            print(f"    [relogin failed] {exc}")
            return False

    def list_episodes(self, pid: str) -> List[Dict]:
        resp = self.sess.get(
            f"{self.base_url}/api/v1/projects/{pid}/episodes/",
            timeout=10,
        )
        resp.raise_for_status()
        data = resp.json()
        if isinstance(data, dict) and "items" in data:
            return data["items"]
        if isinstance(data, list):
            return data
        return []

    def check_prereq(self, pid: str, eid: str) -> List[str]:
        """미완료 선행 단계 리스트 반환 (빈 리스트면 적격).

        허용 상태: completed / partial / stale.
        - stale: 하류가 force되어 상류가 obsolete 표시됐지만 체크포인트 데이터는 유효.
          재분석이 이 스크립트의 용도이므로 stale도 마이그레이션 대상으로 인정.
        그 외(pending/blocked/running/failed/missing)는 부적격.

        v0.5.20: HTTP 오류는 일시 네트워크 이슈일 수 있으므로 재시도(최대 3회, 지수 백오프).
        3회 실패 시에만 부적격으로 기록.
        """
        url = f"{self.base_url}/api/v1/projects/{pid}/episodes/{eid}/steps"
        max_retry = 3
        last_exc: Optional[Exception] = None
        for attempt in range(1, max_retry + 1):
            try:
                resp = self.sess.get(url, timeout=10)
                # 401 재로그인은 attempt에 포함 안 함 — 동일 iteration 내에서 즉시 재요청.
                # (사후 Codex/Claude 리뷰 v0.5.22 수용: 세션 만료가 retry 슬롯을 소진하지 않도록.)
                if resp.status_code == 401 and self._relogin_if_needed(resp):
                    resp = self.sess.get(url, timeout=10)
                resp.raise_for_status()
                states = {s["step_id"]: s.get("status", "") for s in resp.json().get("steps", [])}
                missing = []
                for sid in REQUIRED_PREREQ_STEPS:
                    status = states.get(sid, "")
                    if status not in ("completed", "partial", "stale"):
                        missing.append(f"{sid}({status or 'missing'})")
                return missing
            except requests.RequestException as exc:
                last_exc = exc
                if attempt < max_retry:
                    wait = 5 * attempt
                    print(f"    [prereq http error attempt {attempt}/{max_retry}] {exc} — retry in {wait}s")
                    time.sleep(wait)
        return [f"<http_error after {max_retry} attempts: {last_exc}>"]

    def save_snapshot(self, pid: str, eid: str, label: str) -> Optional[str]:
        url = f"{self.base_url}/api/v1/projects/{pid}/episodes/{eid}/steps/snapshots"
        resp = self.sess.post(url, params={"label": label}, timeout=60)
        if resp.status_code >= 400:
            print(f"[snapshot] FAIL pid={pid} eid={eid} {resp.status_code} {resp.text[:120]}")
            return None
        version = resp.json().get("version")
        print(f"[snapshot] saved version={version} label={label}")
        return version

    def force_step(self, pid: str, eid: str, step_id: str) -> bool:
        url = f"{self.base_url}/api/v1/projects/{pid}/episodes/{eid}/steps/{step_id}"
        resp = self.sess.post(url, params={"mode": "force"}, timeout=30)
        if resp.status_code >= 400:
            print(f"[force] FAIL {step_id} {resp.status_code} {resp.text[:120]}")
            return False
        return True

    def wait_step_done(self, pid: str, eid: str, step_id: str) -> str:
        """completed / partial / failed / timeout / error 반환.

        partial: 일부 실패하지만 일부 성공한 상태. StepRunner가 `partial`로 기록하며
                 마이그레이션 관점에서는 "다음 단계로 넘어가도 되는 완료"로 본다.
        """
        url = f"{self.base_url}/api/v1/projects/{pid}/episodes/{eid}/steps"
        t0 = time.time()
        last_status = ""
        while time.time() - t0 < POLL_TIMEOUT_SEC:
            try:
                resp = self.sess.get(url, timeout=10)
            except requests.RequestException as exc:
                print(f"    [poll error] {exc} — retrying")
                time.sleep(POLL_INTERVAL_SEC)
                continue
            if resp.status_code == 401:
                if self._relogin_if_needed(resp):
                    continue
                return "error"
            if resp.status_code >= 400:
                print(f"    [poll HTTP {resp.status_code}] {resp.text[:120]} — retrying")
                time.sleep(POLL_INTERVAL_SEC)
                continue
            for s in resp.json().get("steps", []):
                if s["step_id"] != step_id:
                    continue
                status = s.get("status", "")
                if status != last_status:
                    cc = s.get("completed_count") or 0
                    ac = s.get("applicable_count") or 0
                    print(f"    {step_id}: {status} {cc}/{ac}")
                    last_status = status
                if status in ("completed", "partial", "failed"):
                    return status
                break
            time.sleep(POLL_INTERVAL_SEC)
        return "timeout"

    def run_project(self, pid: str, dry_run: bool) -> bool:
        print(f"\n=== Project {pid} ===")
        try:
            episodes = self.list_episodes(pid)
        except requests.RequestException as exc:
            print(f"[project] FAIL list_episodes: {exc}")
            return False

        if not episodes:
            print(f"[project] no episodes — skip")
            return True

        any_failed = False
        for ep in episodes:
            eid = ep.get("id")
            title = ep.get("title", "")
            print(f"\n  -- Episode {eid} ({title}) --")
            missing = self.check_prereq(pid, eid)
            if missing:
                print(f"    [prereq] SKIP — 부적격 (미완료/누락 단계): {', '.join(missing)}")
                print(f"    이 에피소드는 shot-more 파이프라인 전체 재분석이 선행돼야 한다. 마이그레이션 스크립트 범위 밖.")
                any_failed = True
                continue
            if dry_run:
                print(f"    [dry-run] prereq OK — would snapshot + force {FORCE_STEPS}")
                continue
            snapshot_version = self.save_snapshot(pid, eid, "pre_v0513_migration")
            if not snapshot_version:
                any_failed = True
                print(f"    stop (snapshot failed — rollback safety required)")
                continue
            print(f"    rollback: POST /steps/snapshots/restore?version={snapshot_version}")
            for step_id in FORCE_STEPS:
                print(f"    → force {step_id}")
                if not self.force_step(pid, eid, step_id):
                    any_failed = True
                    print(f"    stop (force failed on {step_id})")
                    break
                status = self.wait_step_done(pid, eid, step_id)
                # partial도 "다음 단계로 진행 가능" — StepRunner의 정상 상태.
                if status not in ("completed", "partial"):
                    any_failed = True
                    print(f"    stop ({step_id} -> {status})")
                    break
        return not any_failed


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("pids", nargs="+", help="project IDs")
    ap.add_argument("--base-url", default=DEFAULT_BASE_URL)
    ap.add_argument("--username", default=DEFAULT_USERNAME)
    ap.add_argument("--password", default=DEFAULT_PASSWORD)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    session = requests.Session()
    mig = Migration(args.base_url, session)
    try:
        mig.login(args.username, args.password)
    except requests.RequestException as exc:
        print(f"[login] FAIL {exc}")
        return 1

    overall_ok = True
    for pid in args.pids:
        ok = mig.run_project(pid, args.dry_run)
        overall_ok = overall_ok and ok
        if not args.dry_run:
            print(f"\n[project {pid}] {'OK' if ok else 'PARTIAL FAIL'}")

    return 0 if overall_ok else 2


if __name__ == "__main__":
    sys.exit(main())
