#!/usr/bin/env python3
"""최소 검증판 러너 — 스텝 목록을 인자로 받아 순차 실행.

usage: backend/.venv/bin/python scratchpad/minimal_e2e_runner.py step1 step2 ...
       (인자 없으면 STEPS_TEXT 기본 목록)
"""
import json
import sys
import time
from pathlib import Path

import requests

BASE = "http://localhost:8000"
ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
STATE = json.loads((ROOT / "scratchpad" / "minimal_e2e_state.json").read_text())
PID, EID = STATE["project_id"], STATE["episode_id"]

# 샷 수를 재기 전까지 도는 텍스트 스텝 (이미지 없음 — 값이 싸다)
STEPS_TEXT = [
    "text_cleanup", "scene_segmentation", "episode_summary",
    "visual_world_rules", "scene_save", "entity_character_list",
    "scene_summary", "beat_extract", "shot_extract", "shot_validator",
]

TERMINAL_OK = {"completed", "skipped", "not_applicable"}
TERMINAL_BAD = {"failed"}
MAX_RESUME_RETRY = 3
POLL = 10

s = requests.Session()


def login():
    r = s.post(f"{BASE}/api/v1/auth/login",
               json={"username": "admin", "password": "admin123"}, timeout=30)
    r.raise_for_status()


def status_map():
    url = f"{BASE}/api/v1/projects/{PID}/episodes/{EID}/steps"
    r = s.get(url, timeout=60)
    if r.status_code == 401:
        login()
        r = s.get(url, timeout=60)
    r.raise_for_status()
    return {x["step_id"]: x for x in r.json()["steps"]}


def run_once(sid, attempt, limit):
    t0 = time.time()
    st = status_map().get(sid) or {}
    if st.get("status") in TERMINAL_OK:
        print(f"[{sid}] already {st.get('status')}", flush=True)
        return "ok"
    url = f"{BASE}/api/v1/projects/{PID}/episodes/{EID}/steps/{sid}"
    r = s.post(url, params={"mode": "resume"}, timeout=120)
    if r.status_code == 401:
        login()
        r = s.post(url, params={"mode": "resume"}, timeout=120)
    if r.status_code != 200:
        # ★비활성 단계는 **실패가 아니라 건너뛸 것**이다 (2026-08-25).
        #  스텝 목록 API 는 `enabled` 를 안 주므로 이 400 이 유일한 신호다.
        #  실패로 세면 러너가 여기서 멈추고, 남은 스텝이 영영 안 돈다 —
        #  autodrive 가 deprecated 12개를 「남은 것」으로 세어 7시간 헛돌던
        #  것과 같은 자리다.
        if r.status_code == 400 and "step.disabled" in r.text:
            print(f"[{sid}] disabled — 건너뛴다", flush=True)
            return "ok"
        print(f"[{sid}] start FAILED http={r.status_code} {r.text[:300]}",
              flush=True)
        return "start_failed"
    print(f"[{sid}] started (attempt {attempt})", flush=True)
    while True:
        time.sleep(POLL)
        try:
            st = status_map().get(sid) or {}
        except Exception as exc:
            print(f"[{sid}] poll error {exc}", flush=True)
            continue
        cur = st.get("status")
        if cur in TERMINAL_OK:
            print(f"[{sid}] {cur} ({int(time.time()-t0)}s) "
                  f"done={st.get('completed_count')}/"
                  f"{st.get('applicable_count')} "
                  f"failed={st.get('failed_count')}", flush=True)
            return "ok"
        if cur in TERMINAL_BAD:
            print(f"[{sid}] FAILED ({int(time.time()-t0)}s) — "
                  f"{json.dumps(st, ensure_ascii=False)[:600]}", flush=True)
            return "failed"
        if time.time() - t0 > limit:
            print(f"[{sid}] TIMEOUT {limit}s (status={cur})", flush=True)
            return "timeout"


def run_step(sid, limit):
    for attempt in range(1, MAX_RESUME_RETRY + 1):
        res = run_once(sid, attempt, limit)
        if res == "ok":
            return True
        if attempt < MAX_RESUME_RETRY:
            print(f"[{sid}] retrying ({attempt}/{MAX_RESUME_RETRY})…",
                  flush=True)
            time.sleep(15)
    return False


def drop_retired(steps):
    """폐기된 단계를 목록에서 뺀다 — **돈이 걸린 문제다.**

    ★2026-08-25 실측: `outlook_extraction` 이 폐기 상태인데 3회 6,264자를
     실제로 발송했다. 대체한 `outlook_phase1/2/3` 가 이미 다 돈 뒤였다.

    왜 관문에 안 걸리나: 실행을 막는 것은 `applicability == "disabled"`(8개)
    인데 폐기 표시는 `lifecycle != "active"`(11개)라 **축이 다르다.** 차이
    3개는 `on_demand` 라서, 요청하면 돈다 — 목록을 스텝 API 에서 그대로
    받아 순서대로 돌리면 그 요청을 우리가 하게 된다(API 는 lifecycle 을
    안 준다).
    """
    sys.path.insert(0, str(ROOT / "backend"))
    try:
        from app.core.step_manifest import STEP_MANIFEST as M
    except Exception as exc:  # noqa: BLE001 — 못 읽으면 거르지 않고 알린다
        print(f"[filter] manifest 를 못 읽었다({exc}) — 거르지 않는다",
              flush=True)
        return steps
    kept, dropped = [], []
    for sid in steps:
        meta = M.get(sid) or {}
        if meta.get("lifecycle", "active") != "active":
            dropped.append(f"{sid}→{meta.get('replaced_by') or '?'}")
        else:
            kept.append(sid)
    if dropped:
        # ★조용히 줄이지 않는다 — 말없이 빼면 「전부 돌았다」로 읽힌다.
        print(f"[filter] 폐기 단계 {len(dropped)}개 제외: "
              f"{', '.join(dropped)}", flush=True)
    return kept


def main():
    if sys.argv[1:] == ["--rest"]:
        steps = json.loads(
            (ROOT / "scratchpad" / "minimal_e2e_steps.json").read_text())
    else:
        steps = sys.argv[1:] or STEPS_TEXT
    steps = drop_retired(steps)
    login()
    cat = {sid: st.get("category") for sid, st in status_map().items()}
    print(f"target {len(steps)} steps | pid={PID} eid={EID}", flush=True)
    for sid in steps:
        limit = 6 * 3600 if cat.get(sid) == "image" else 2400
        if not run_step(sid, limit):
            print(f"STOP at {sid}", flush=True)
            sys.exit(1)
    print("ALL STEPS DONE", flush=True)


if __name__ == "__main__":
    main()
