"""캡컷 에이전트 로컬 웹 서버 (FastAPI).

실행:  python -m uvicorn server.app:app --port 8000
열기:  http://127.0.0.1:8000
"""
from __future__ import annotations

import asyncio
import glob
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
import time
import urllib.parse
import urllib.request

from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles

from capcut_agent.pipeline import (
    process_bg_template, process_paste, paste_analyze, paste_draft,
    paste_steps as pipeline_paste_steps, _remap_caps,
    bg_analyze, bg_draft, bg_steps, _cards_by_cut, _card_paths,
)
from capcut_agent.paste import parse_paste, parse_time
from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers
from capcut_agent import comments as hlab
from capcut_agent import recommend
from capcut_agent import plan as autoplan
from capcut_agent import prompts as prompt_store
from capcut_agent.correct import GeminiQuotaError, has_gemini_key

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
UPLOAD_DIR = os.path.join(os.path.dirname(BASE_DIR), ".uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
COMMENTS_DIR = os.path.join(os.path.dirname(BASE_DIR), ".comments")
os.makedirs(COMMENTS_DIR, exist_ok=True)

app = FastAPI(title="캡컷 에이전트")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

STATE_TTL_SECONDS = int(os.getenv("CAPCUT_STATE_TTL_SECONDS", "86400"))
UPLOAD_TTL_SECONDS = int(os.getenv("CAPCUT_UPLOAD_TTL_SECONDS", "604800"))
MAX_UPLOAD_BYTES = int(os.getenv("CAPCUT_MAX_UPLOAD_BYTES", str(4 * 1024**3)))
UPLOAD_CHUNK_BYTES = 1024 * 1024


class _ExpiringStore(dict):
    """기존 dict 인터페이스를 유지하면서 오래된 작업 상태를 지우는 메모리 저장소."""

    def __init__(self, ttl: int):
        super().__init__()
        self.ttl = ttl
        self._touched: dict[str, float] = {}

    def _purge(self) -> None:
        cutoff = time.time() - self.ttl
        for key, touched in list(self._touched.items()):
            if touched < cutoff:
                super().pop(key, None)
                self._touched.pop(key, None)

    def __setitem__(self, key, value) -> None:
        self._purge()
        super().__setitem__(key, value)
        self._touched[key] = time.time()

    def get(self, key, default=None):
        self._purge()
        value = super().get(key, default)
        if key in self:
            self._touched[key] = time.time()
        return value

    def pop(self, key, default=None):
        self._touched.pop(key, None)
        return super().pop(key, default)


def _cleanup_uploads() -> None:
    """참조되지 않고 보존 기간이 지난 업로드와 중단된 임시 파일을 정리한다."""
    cutoff = time.time() - UPLOAD_TTL_SECONDS
    if isinstance(JOBS, _ExpiringStore):
        JOBS._purge()
    active = {os.path.abspath(j["path"]) for j in JOBS.values() if j.get("path")}
    for entry in os.scandir(UPLOAD_DIR):
        if not entry.is_file() or os.path.abspath(entry.path) in active:
            continue
        try:
            if entry.stat().st_mtime < cutoff:
                os.remove(entry.path)
        except OSError:
            pass


# job_id(content hash) → {path, draft_name, title_top, title_main, channel}
JOBS: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)

# analysis_id → {"url": …}  (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
ANALYSES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)

# 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"}
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
PSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)

# ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"}
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
YSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)

# 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"}
# /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다.
# 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다
# (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다).
PREPARES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)


_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")


@app.get("/")
async def index() -> HTMLResponse:
    with open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8") as f:
        html = f.read()
    # 댓글 카드 폴더 기본 경로 주입(그 PC 기준 실제 경로)
    html = html.replace("__CDIR__", _DEFAULT_CDIR.replace("\\", "/"))
    # ?v= 캐시 무력화 — StaticFiles 는 Cache-Control 을 안 붙여서 브라우저가 auto.js 를
    # 옛 버전 그대로 들고 있는 일이 있었다(index.html 만 바뀌어 UI가 반만 갱신됨).
    # 파일 mtime 을 버전으로 박아 파일이 바뀌면 URL 이 달라지게 한다.
    mt = 0.0
    for f in ("auto.js", "modern-screenshot.js"):
        p = os.path.join(STATIC_DIR, f)
        if os.path.isfile(p):
            mt = max(mt, os.path.getmtime(p))
    html = html.replace("__V__", str(int(mt)))
    return HTMLResponse(html, headers={"Cache-Control": "no-store"})


def _scale(pct: str) -> float:
    """확대 퍼센트 문자열 → scale(0.5~3.0). 잘못되면 1.0(100%)."""
    try:
        return max(0.5, min(3.0, float(pct) / 100))
    except (ValueError, TypeError):
        return 1.0


def _truthy(v: str) -> bool:
    return str(v).strip().lower() in ("1", "true", "on", "yes")


def _fmt_range_time(sec: float) -> str:
    """초 → `M:SS`(1시간 이상은 `H:MM:SS`). youtube.valid_time()이 강제하는 형식과
    맞춰야 한다 — youtube._fmt_hms()는 밀리초까지 붙어(`H:MM:SS.mmm`) valid_time을
    통과 못 하므로 여기 전용 포맷 함수를 따로 둔다."""
    sec = max(0, round(sec))
    h, rem = divmod(int(sec), 3600)
    m, s = divmod(rem, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


def _parse_ranges(raw: str) -> list[tuple[str, str]]:
    """JSON [[시작,끝],...] → [(M:SS, M:SS)]. 항목은 "16:07" 문자열/967 같은 초 숫자
    모두 허용해 parse_time()으로 초로 바꾼 뒤 valid_time()이 통과하는 M:SS/H:MM:SS로
    재포맷한다.

    ⚠ 함정: 프런트(ytRanges())가 초 숫자를 보내던 시절 이 정규화 없이 그대로
    저장했다가, 다운로드 경로의 cut_youtube()→valid_time()이 "967" 같은 순수
    숫자 문자열을 시간 형식으로 인정하지 않아 매 분석이 다운로드 단계에서
    "시간 형식 오류"로 죽었다(구간 탭 전면 불능). 여기서 형식을 통일해두면
    프런트가 무엇을 보내든(문자열이든 숫자든) 다운로드 경로까지 안전하게 도달한다.
    빈 값/파싱실패 항목은 건너뛴다.
    """
    raw = (raw or "").strip()
    if not raw:
        return []
    try:
        arr = json.loads(raw)
    except json.JSONDecodeError:
        return []
    out: list[tuple[str, str]] = []
    for item in arr if isinstance(arr, list) else []:
        if isinstance(item, (list, tuple)) and len(item) == 2:
            try:
                s_sec, e_sec = parse_time(item[0]), parse_time(item[1])
            except (ValueError, TypeError):
                continue
            out.append((_fmt_range_time(s_sec), _fmt_range_time(e_sec)))
    return out


def _highlight_card_count(total: float) -> int:
    return max(1, int(total // 3))


def _cuts_json(cuts) -> list[dict]:
    return [{"start": s, "end": e, "bottom": b, "effect": f}
            for s, e, b, f in cuts]


def _whole_highlight(candidate: dict, url: str, *, profile: str = "entertainment") -> dict:
    """후보 구간 전체를 컷 하나로 쓰는 자동 편집안으로 변환한다."""
    total = candidate["end"] - candidate["start"]
    return {
        "id": candidate["id"], "start": candidate["start"], "end": candidate["end"],
        "reason": candidate["reason"],
        "paste": {"url": url,
                  "title_top": candidate.get("title_top") or "",
                  "title_main": candidate.get("title_main") or "",
                  # 정치 모드는 비워 둬야 paste_analyze가 URL의 실제 uploader를 가져온다.
                  "channel": "",
                  "cuts": [{"start": candidate["start"], "end": candidate["end"],
                            "bottom": "", "effect": ""}]},
        "titles": [], "editable_title": True,
        "total": round(total, 1),
        "need": 0 if profile == "politics" else _highlight_card_count(total),
        "content_profile": profile,
        "issue": candidate.get("issue") or "",
        "viral_type": candidate.get("viral_type") or "",
    }


async def _plan_highlight(url: str, index: int, candidate: dict) -> dict:
    """Gemini 할당량 충돌 시 요청별 시차를 두고 편집안을 최대 세 번 시도한다."""
    for attempt in range(1, 4):
        try:
            return await asyncio.to_thread(
                autoplan.edit_plan, url, candidate["start"], candidate["end"])
        except GeminiQuotaError:
            if attempt == 3:
                raise
            await asyncio.sleep(15 * attempt + index * 5)
    raise RuntimeError("편집안 생성 재시도 횟수를 초과했습니다.")


class _UploadTooLarge(Exception):
    pass


async def _save_upload(file: UploadFile) -> tuple[str, str]:
    """업로드를 메모리에 적재하지 않고 저장하고 (content hash, 경로)를 반환한다."""
    _cleanup_uploads()
    digest = hashlib.sha1()
    size = 0
    tmp_path = ""
    try:
        with tempfile.NamedTemporaryFile(dir=UPLOAD_DIR, prefix="upload-", suffix=".part",
                                         delete=False) as tmp:
            tmp_path = tmp.name
            while chunk := await file.read(UPLOAD_CHUNK_BYTES):
                size += len(chunk)
                if size > MAX_UPLOAD_BYTES:
                    raise _UploadTooLarge
                digest.update(chunk)
                tmp.write(chunk)

        content_hash = digest.hexdigest()[:12]
        ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4"
        path = os.path.join(UPLOAD_DIR, content_hash + ext)
        if os.path.exists(path):
            os.remove(tmp_path)
            os.utime(path, None)
        else:
            os.replace(tmp_path, path)
        return content_hash, path
    except Exception:
        if tmp_path and os.path.exists(tmp_path):
            try:
                os.remove(tmp_path)
            except OSError:
                pass
        raise


@app.post("/upload")
async def upload(
    file: UploadFile = File(...),
    title_top: str = Form(""),
    title_main: str = Form(""),
    channel: str = Form(""),
    video_scale: str = Form("100"),
    flip: str = Form(""),
    scene: str = Form(""),
    comments_dir: str = Form(""),
    bg_white: str = Form(""),
    remove_silence: str = Form("1"),
) -> JSONResponse:
    try:
        h, path = await _save_upload(file)
    except _UploadTooLarge:
        gib = MAX_UPLOAD_BYTES / 1024**3
        return JSONResponse({"error": f"업로드 파일이 제한({gib:g}GB)을 넘습니다."}, 413)
    base = os.path.splitext(os.path.basename(file.filename or "video"))[0]
    safe = "".join(c for c in base if c.isalnum() or c in (" ", "_", "-")).strip() or "video"
    JOBS[h] = {
        "path": path, "draft_name": f"{safe}_{h}", "youtube": None,
        "title_top": title_top, "title_main": title_main, "channel": channel,
        "video_scale": _scale(video_scale), "flip": _truthy(flip),
        "scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
        "remove_silence": _truthy(remove_silence),
    }
    return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]})


@app.post("/paste")
async def paste(
    data: str = Form(...),
    video_scale: str = Form("144"),
    flip: str = Form(""),
    scene: str = Form(""),
    comments_dir: str = Form(""),
    bg_white: str = Form(""),
    remove_silence: str = Form(""),
    asr_bottom: str = Form(""),
) -> JSONResponse:
    """붙여넣기(JSON) 편집안 → 작업 생성. asr_bottom=1 이면 하단 자막을 Whisper로 자동 생성."""
    try:
        payload = parse_paste(data)
    except ValueError as e:
        return JSONResponse({"error": str(e)}, 400)
    sig = payload["url"] + "|" + "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"])
    h = hashlib.sha1(sig.encode()).hexdigest()[:12]
    JOBS[h] = {
        "paste": payload, "draft_name": f"paste_{h}",
        "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
        "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
        "remove_silence": _truthy(remove_silence),
        "asr_bottom": _truthy(asr_bottom),
    }
    return JSONResponse({"job_id": h, "cuts": len(payload["cuts"])})


@app.get("/stream/{job_id}")
async def stream(job_id: str) -> StreamingResponse:
    job = JOBS.get(job_id)

    async def gen():
        if not job:
            yield _sse({"type": "error", "message": "알 수 없는 작업입니다."})
            return
        try:
            if job.get("paste_state"):   # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만
                # paste_draft 는 manifest 를 내지 않는다(analyze 쪽이 이미 냈던 스텝들과
                # 별개 스트림이라 여기서 새로 내야 함) — 안 내면 step 이벤트만 홀로 와서
                # 화면에 진행 표시가 아예 안 뜬다.
                yield _sse({"type": "manifest",
                            "steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
                stream_iter = paste_draft(
                    job["paste_state"],
                    video_scale=job.get("video_scale", 1.0),
                    flip_horizontal=job.get("flip", False),
                    scene_split=job.get("scene", False),
                    comments_dir=job.get("comments_dir", ""),
                    cards_fixed=job.get("cards_fixed", False),
                    card_cuts=job.get("card_cuts") or None,
                    bg_white=job.get("bg_white", False),
                    content_profile=job.get("content_profile", "entertainment"),
                )
            elif job.get("bg_state"):   # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만
                # paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다.
                yield _sse({"type": "manifest",
                            "steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
                stream_iter = bg_draft(
                    job["bg_state"],
                    video_scale=job.get("video_scale", 1.0),
                    flip_horizontal=job.get("flip", False),
                    scene_split=job.get("scene", False),
                    comments_dir=job.get("comments_dir", ""),
                    cards_fixed=job.get("cards_fixed", False),
                    bg_white=job.get("bg_white", False),
                    comment_cards=job.get("comment_cards"),
                )
            elif job.get("paste"):     # 붙여넣기(JSON) 모드
                stream_iter = process_paste(
                    job["paste"], job["draft_name"],
                    video_scale=job.get("video_scale", 1.0),
                    flip_horizontal=job.get("flip", False),
                    scene_split=job.get("scene", False),
                    comments_dir=job.get("comments_dir", ""),
                    bg_white=job.get("bg_white", False),
                    remove_silence=job.get("remove_silence", False),
                    asr_bottom=job.get("asr_bottom", False),
                    name_suffix=job.get("name_suffix", ""),
                    cards_fixed=job.get("cards_fixed", False),
                    card_cuts=job.get("card_cuts") or None,
                )
            else:
                stream_iter = process_bg_template(
                    job["path"], job["draft_name"],
                    title_top=job.get("title_top", ""),
                    title_main=job.get("title_main", ""),
                    channel=job.get("channel", ""),
                    video_scale=job.get("video_scale", 1.0),
                    flip_horizontal=job.get("flip", False),
                    scene_split=job.get("scene", False),
                    comments_dir=job.get("comments_dir", ""),
                    bg_white=job.get("bg_white", False),
                    youtube=job.get("youtube"),
                    cards_fixed=job.get("cards_fixed", False),
                    remove_silence=job.get("remove_silence", True),
                )
            async for ev in stream_iter:
                yield _sse(ev)
        except Exception as exc:  # noqa: BLE001 — 사용자에게 그대로 노출
            yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})

    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})


@app.post("/yt/analyze")
async def yt_analyze(
    url: str = Form(...),
    ranges: str = Form(...),
    title_top: str = Form(""),
    title_main: str = Form(""),
    channel: str = Form(""),
    remove_silence: str = Form("1"),
) -> JSONResponse:
    """▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서.

    title_top/title_main/channel 도 여기서 받는다: bg_analyze 가 title_main 을
    Gemini 자막 교정 힌트로 쓰고, /yt/stream 은 GET(SSE)이라 요청 본문이 없어
    지금 안 받으면 넘길 곳이 없다. video_scale 등 렌더 전용 옵션은 /yt/build 에서.
    """
    u = url.strip()
    if not (u.startswith("http://") or u.startswith("https://")):
        return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
    rng = _parse_ranges(ranges)
    if not rng:
        return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
    rs = _truthy(remove_silence)
    aid = hashlib.sha1(("yt|" + u + "|" + ranges + "|rs" + ("1" if rs else "0")).encode()
                       ).hexdigest()[:12]
    ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top,
                      "title_main": title_main, "channel": channel, "remove_silence": rs}
    return JSONResponse({"analysis_id": aid})


@app.get("/yt/stream/{aid}")
async def yt_stream(aid: str) -> StreamingResponse:
    """▶ 유튜브 구간 탭(새 흐름) — 구간 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result."""
    a = ANALYSES.get(aid)

    async def gen():
        if not a:
            yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
            return
        url = a["url"]
        ranges = a["ranges"]
        youtube = {"url": url, "ranges": ranges}
        warnings: list[str] = []
        # draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로
        # 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
        rs = a.get("remove_silence", True)
        yield _sse({"type": "manifest", "steps":
                    [s for s in bg_steps(youtube, rs) if s["id"] != "draft"] +
                    [{"id": "comments", "label": "댓글 수집 (h-lab)"},
                     {"id": "recommend", "label": "컷별 댓글 추천"}]})
        com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
        yield _sse({"type": "step", "id": "comments", "status": "start"})
        state = None
        try:
            async for ev in bg_analyze(None, "yt_" + aid,
                                       title_top=a.get("title_top", ""),
                                       title_main=a.get("title_main", ""),
                                       channel=a.get("channel", ""),
                                       youtube=youtube, remove_silence=rs):
                if ev.get("type") == "state":   # 내부 전용 — 밖으로 흘리지 않는다
                    state = ev["state"]
                    continue
                if ev.get("type") == "error":
                    com_task.cancel()
                    yield _sse(ev)
                    return
                yield _sse(ev)
        except Exception as exc:  # noqa: BLE001 — 다운로드 등 실패는 드래프트 생성을 막는다
            com_task.cancel()
            yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
            return
        if state is None:
            com_task.cancel()
            yield _sse({"type": "error", "message": "분석 상태를 만들지 못했습니다."})
            return

        comments: list[dict] = []
        try:
            comments = await com_task
            yield _sse({"type": "step", "id": "comments", "status": "done",
                        "detail": f"{len(comments)}개"})
        except Exception as exc:  # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다
            warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
            yield _sse({"type": "step", "id": "comments", "status": "done",
                        "detail": "실패(생략)"})

        yield _sse({"type": "step", "id": "recommend", "status": "start"})
        # ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용),
        #   ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
        # state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만들어 둔 것이다
        # (무음 제거 켬 = 압축 좌표 / 끔 = raw_places 그대로). 여기서 _remap_placements 를
        # 또 부르면 두 번 압축된다 — 부르지 않는다.
        places = state["places"]
        orig = state["ranges_sec"]
        try:
            cuts, need, ai_failed = await asyncio.to_thread(
                recommend.cuts_from_state, places, orig, state["captions"], comments)
            if ai_failed:
                warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
        except Exception as exc:  # noqa: BLE001
            # cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/➕ 폴백으로 넘어간다.
            cuts, need = None, max(1, int(state["total"] // 3))
            warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})")
        yield _sse({"type": "step", "id": "recommend", "status": "done",
                    "detail": (f"{len(cuts)}컷 · 카드 {need}장" if cuts is not None
                               else f"폴백 · 카드 {need}장")})

        YSTATES[aid] = {"state": state}
        no_ts = [c for c in comments if not c["times"]]
        matched = hlab.match_ranges(comments, orig) if comments else []
        yield _sse({"type": "result", "cuts": cuts, "need": need,
                    "total": round(state["total"], 1),
                    "cutRanges": [{"start": s, "end": e} for s, e in orig],
                    "matched": matched,
                    "candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
                    "comments": comments, "warnings": warnings,
                    # 검토 화면 입력칸을 채우기 위한 확정값 — channel 은 비워 뒀어도
                    # bg_analyze 내부에서 유튜브 채널명으로 자동인식됐을 수 있다(state 에 반영됨).
                    "title_top": state["title_top"], "title_main": state["title_main"],
                    "channel": state["channel"]})

    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})


@app.post("/yt/build")
async def yt_build(
    aid: str = Form(...),
    cards: list[UploadFile] = File(default=[]),
    card_cuts: str = Form(""),
    title_top: str = Form(""),
    title_main: str = Form(""),
    channel: str = Form(""),
    video_scale: str = Form("100"),
    flip: str = Form(""),
    scene: str = Form(""),
    bg_white: str = Form(""),
    cards_fixed: str = Form(""),
    comments_dir: str = Form(""),
) -> JSONResponse:
    """▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).

    title_top/title_main/channel 을 여기서도 받아 state 에 덮어쓴다: 검토 화면은 분석
    (/yt/analyze) 시점 값을 그대로 입력칸에 채워 보여주고(자동인식된 channel 포함) 여기서
    고칠 수 있게 하는데, bg_draft 는 title_*/channel 을 오직 state 에서만 읽으므로
    (Task 6) 여기서 덮어쓰지 않으면 검토 화면에서 고친 값이 실제 드래프트에 반영되지
    않는다 — 재분석(다운로드·ASR 재실행) 없이 오타만 고치는 게 이 필드들의 존재 이유다.
    """
    st = YSTATES.get(aid)
    if not st:
        return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
    state = st["state"]
    state["title_top"] = title_top
    state["title_main"] = title_main
    state["channel"] = channel

    # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
    cut_map: list[int] = []
    try:
        parsed = json.loads(card_cuts) if card_cuts.strip() else []
        if isinstance(parsed, list):
            cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
    except (json.JSONDecodeError, ValueError, TypeError):
        cut_map = []

    sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
           + bg_white + "|" + cards_fixed + "|" + str(len(cards)))
    h = hashlib.sha1(sig.encode()).hexdigest()[:12]
    # 카드가 오면 폴더 지정보다 우선(/paste/build 와 같은 규칙). 카드를 하나도 안 골랐으면
    # 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
    cdir = comments_dir.strip()
    if cards:
        cdir = os.path.join(COMMENTS_DIR, h)
        if os.path.isdir(cdir):        # 재빌드 시 이전 카드 잔재 제거
            shutil.rmtree(cdir, ignore_errors=True)
        os.makedirs(cdir, exist_ok=True)
        for i, f in enumerate(cards, 1):
            body = await f.read()
            with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
                out.write(body)

    # ⚠ state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만든 것이다
    # (무음 제거 켬 = 압축 좌표 / 끔 = 원본 그대로).
    # 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다.
    cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"],
                              fixed=_truthy(cards_fixed))

    JOBS[h] = {
        "bg_state": state,
        "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
        "comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
        # cut_cards 가 비면 None → bg_draft 가 comments_dir 기준 전체 균등 배치로 폴백
        # (/paste/build·paste_draft 의 `cards = cut_cards or _load_comment_cards(...)`와 동일 규칙).
        "comment_cards": cut_cards or None,
    }
    return JSONResponse({"job_id": h})


@app.post("/auto/analyze")
async def auto_analyze(url: str = Form(""), mode: str = Form("full"),
                       data: str = Form("")) -> JSONResponse:
    """자동 탭 1단계 — 분석 예약. 실제 작업은 /auto/stream 에서 SSE 로.

    mode: full  = Step1+Step3 (AI 컷편집, 기본)
          whole = Step1만 — 구간 5개를 통짜로(컷 편집 없음)
          paste = 오팔 JSON 여러 개 붙여넣기 — Gemini 안 씀
          politics = 정치 구간 JSON — 댓글·검토 없이 정치 레이아웃 생성
    """
    mode = mode if mode in ("full", "whole", "wpaste", "paste", "politics") else "full"
    u = url.strip()
    if mode == "paste":
        if not data.strip():
            return JSONResponse({"error": "오팔 JSON을 붙여넣으세요."}, 400)
    elif mode in ("wpaste", "politics"):
        # 구간 JSON 붙여넣기 — Gemini 안 씀. URL은 JSON에 없으므로 입력칸이 필수.
        if not data.strip():
            return JSONResponse({"error": "구간 JSON을 붙여넣으세요."}, 400)
        if not (u.startswith("http://") or u.startswith("https://")):
            return JSONResponse({"error": "유튜브 주소를 입력하세요. "
                                 "(구간 JSON에는 URL이 없어 직접 넣어야 합니다)"}, 400)
    else:
        if not has_gemini_key():
            return JSONResponse({"error": "Gemini 키가 없습니다. 프로젝트 루트의 "
                                 ".gemini_key 파일을 확인하세요. (그동안은 오팔 → "
                                 "📋 오팔 JSON 방식을 쓰면 됩니다)"}, 400)
        if not (u.startswith("http://") or u.startswith("https://")):
            return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
    aid = hashlib.sha1((mode + "|" + u + "|" + data).encode()).hexdigest()[:12]
    ANALYSES[aid] = {"url": u, "mode": mode, "data": data}
    return JSONResponse({"analysis_id": aid})


@app.get("/auto/stream/{aid}")
async def auto_stream(aid: str) -> StreamingResponse:
    """자동 탭 1단계 분석 SSE: Step1 → (Step3 ×N) → result(편집안·타이틀 후보만).

    ⚠ 댓글 매칭은 여기서 안 한다(Task 7) — 검토 화면의 ✕ 제외 뒤에 받아쓰기를 돌려야
    낭비가 없으므로, 다운로드·받아쓰기·댓글 매칭은 전부 /auto/prepare 로 미뤘다.
    `highlights` 는 여기서 만든 `paste`(편집안 JSON)·`titles`(제목 후보)까지만 담고,
    /auto/prepare 가 그대로 꺼내 쓸 수 있게 `ANALYSES[aid]` 에도 남겨 둔다.
    """
    a = ANALYSES.get(aid)

    async def gen():
        if not a:
            yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
            return
        url = a["url"]
        mode = a.get("mode", "full")
        highlights: list[dict] = []

        if mode == "paste":
            # ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
            yield _sse({"type": "manifest", "steps": [
                {"id": "parse", "label": "오팔 JSON 파싱"},
            ]})
            yield _sse({"type": "step", "id": "parse", "status": "start"})
            import re as _re
            from capcut_agent.paste import split_json_objects
            from capcut_agent.plan import parse_titles
            raw_text = a.get("data", "")
            blocks = split_json_objects(raw_text)
            # 각 JSON 블록의 위치 → 블록 사이 텍스트에서 '타이틀 후보 5선' 추출용
            spans, pos = [], 0
            for b in blocks:
                st = raw_text.find(b, pos)
                spans.append((st, st + len(b)))
                pos = st + len(b)
            # URL 결정: 입력 칸 > 블록들 중 유효한 유튜브 ID(11자)가 있는 첫 URL.
            # LLM이 url 을 ""/플레이스홀더로 주는 일이 흔해서 블록별 url 은 믿지 않는다.
            vid_re = _re.compile(
                r"(?:v=|youtu\.be/|shorts/|embed/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])")
            parsed = []
            best_url = url
            for blk in blocks:
                try:
                    d = json.loads(blk, strict=False)
                except json.JSONDecodeError:
                    d = None
                parsed.append(d)
                if not best_url and isinstance(d, dict):
                    u2 = str(d.get("url") or "")
                    if u2.startswith("http") and vid_re.search(u2):
                        best_url = u2
                        yield _sse({"type": "log", "msg": f"URL 자동 인식: {u2}"})
            if not best_url:
                yield _sse({"type": "error",
                            "message": "유튜브 URL을 알 수 없습니다 — JSON들의 url이 비어 있거나 "
                                       "플레이스홀더입니다. 위 유튜브 URL 칸을 채우고 다시 시도하세요."})
                return
            for i, d in enumerate(parsed, 1):
                if not isinstance(d, dict):
                    yield _sse({"type": "log", "msg": f"{i}번 JSON 무시: 형식 오류"})
                    continue
                d["url"] = best_url          # ""/플레이스홀더 무시하고 검증 전에 강제 주입
                try:
                    p = parse_paste(d)
                except ValueError as e:
                    yield _sse({"type": "log", "msg": f"{i}번 JSON 무시: {e}"})
                    continue
                # 이 JSON 뒤 ~ 다음 JSON 앞 텍스트의 타이틀 후보를 이 편집안에 연결
                tail_end = spans[i][0] if i < len(spans) else len(raw_text)
                titles = parse_titles(raw_text[spans[i - 1][1]:tail_end])
                total = sum(e - s for s, e, _, _ in p["cuts"])
                highlights.append({
                    "id": len(highlights) + 1,
                    "start": min(s for s, _, _, _ in p["cuts"]),
                    "end": max(e for _, e, _, _ in p["cuts"]),
                    "reason": (p["title_top"] + " / " + p["title_main"]).strip(" /"),
                    "paste": {"url": p["url"], "title_top": p["title_top"],
                              "title_main": p["title_main"], "channel": p["channel"],
                              "cuts": _cuts_json(p["cuts"])},
                    "titles": titles, "total": round(total, 1),
                    "need": _highlight_card_count(total),
                })
            if not highlights:
                yield _sse({"type": "error",
                            "message": "붙여넣은 텍스트에서 유효한 편집안 JSON을 찾지 못했습니다."})
                return
            url = best_url
            yield _sse({"type": "step", "id": "parse", "status": "done",
                        "detail": f"{len(highlights)}개 편집안"})
        elif mode in ("wpaste", "politics"):
            # ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
            yield _sse({"type": "manifest", "steps": [
                {"id": "parse", "label": "구간 JSON 파싱"},
            ]})
            yield _sse({"type": "step", "id": "parse", "status": "start"})
            try:
                cands = autoplan.parse_candidates(a.get("data", ""), src="구간 JSON")
            except Exception as exc:  # noqa: BLE001
                yield _sse({"type": "error", "message": str(exc)})
                return
            profile = "politics" if mode == "politics" else "entertainment"
            highlights.extend(_whole_highlight(c, url, profile=profile) for c in cands)
            yield _sse({"type": "step", "id": "parse", "status": "done",
                        "detail": f"{len(highlights)}개 구간"})
        else:
            steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}]
            if mode == "full":
                steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"})
            yield _sse({"type": "manifest", "steps": steps})
            # ── Step 1 ──
            yield _sse({"type": "step", "id": "step1", "status": "start"})
            try:
                cands = await asyncio.to_thread(autoplan.select_highlights, url)
            except Exception as exc:  # noqa: BLE001
                yield _sse({"type": "error",
                            "message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n"
                                       "오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."})
                return
            yield _sse({"type": "step", "id": "step1", "status": "done",
                        "detail": f"{len(cands)}개 구간"})

            if mode == "whole":
                # ── 구간 통짜 — 컷 편집 없이 구간 전체가 컷 1개 ──
                highlights.extend(_whole_highlight(c, url) for c in cands)
            else:
                # ── Step 3 (동시) ──
                yield _sse({"type": "step", "id": "step3", "status": "start"})

                plan_tasks = [asyncio.create_task(_plan_highlight(url, i, c))
                              for i, c in enumerate(cands)]
                for c, t in zip(cands, plan_tasks):
                    hl = {"id": c["id"], "start": c["start"], "end": c["end"],
                          "reason": c["reason"]}
                    try:
                        r = await t
                        p = r["paste"]
                        total = sum(e - s for s, e, _, _ in p["cuts"])
                        hl.update({
                            "paste": {"url": p["url"], "title_top": p["title_top"],
                                      "title_main": p["title_main"],
                                      "channel": p["channel"],
                                      "cuts": _cuts_json(p["cuts"])},
                            "titles": r["titles"],
                            "total": round(total, 1),
                            "need": _highlight_card_count(total),
                        })
                        if r["time_note"]:
                            yield _sse({"type": "log",
                                        "msg": f"ID {c['id']}: {r['time_note']}"})
                        yield _sse({"type": "log",
                                    "msg": f"ID {c['id']} 편집안 완료 — 컷 "
                                           f"{len(p['cuts'])}개 · {total:.1f}초"})
                    except Exception as exc:  # noqa: BLE001
                        hl["error"] = f"{type(exc).__name__}: {exc}"
                        yield _sse({"type": "log",
                                    "msg": f"ID {c['id']} 실패: {hl['error']}"})
                    highlights.append(hl)
                ok = sum(1 for h in highlights if "paste" in h)
                yield _sse({"type": "step", "id": "step3", "status": "done",
                            "detail": f"{ok}/{len(highlights)}개 성공"})

        # 댓글 매칭·다운로드·받아쓰기는 여기서 안 한다(Task 7) — /auto/prepare 가
        # 검토 화면에서 제외(✕)하지 않은 ID만 이어받아 돌린다. url 은 paste/wpaste 모드에서
        # best_url 로 바뀌었을 수 있어(오팔 JSON 안 url) 여기서 다시 저장해 둔다.
        a["highlights"] = highlights
        a["url"] = url
        yield _sse({"type": "result", "highlights": highlights})

    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})


def _hl_paste_payload(paste: dict) -> dict:
    """하이라이트의 paste(화면용 dict 컷) → paste_analyze 가 받는 튜플 컷 payload.

    highlights 의 cuts 는 화면 표시용 dict({"start":…,"end":…,…})인데 paste_analyze 는
    parse_paste 출력형 [(s,e,bottom,effect)] 튜플을 기대한다. 옛 /auto/build 는 프런트가
    되보낸 JSON 을 parse_paste 로 재파싱해 이 변환을 공짜로 얻었지만, /auto/prepare 는
    서버 보관본을 직접 쓰므로 여기서 변환해야 한다 — dict 를 그대로 넘기면 언패킹이
    키 문자열("start","end",…)을 풀어 다운로드가 시간 형식 오류로 전면 실패한다.
    """
    cuts = [(float(c["start"]), float(c["end"]),
             c.get("bottom") or "", c.get("effect") or "")
            for c in paste["cuts"]]
    return {**paste, "cuts": cuts}


@app.post("/auto/prepare")
async def auto_prepare(aid: str = Form(...), ids: str = Form(...),
                       remove_silence: str = Form("1")) -> JSONResponse:
    """자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서.

    ids: 남길 하이라이트 id의 JSON 배열(예: [1,2,4]) — ✕ 로 제외된 ID는 여기 안 들어온다.
    remove_silence: 무음 제거 여부(기본 켬). 받아쓰기(asr)와 달리 댓글 매칭에 필수가 아니다 —
    매칭은 원본 시각(orig) 기준이고 paste_analyze 가 False 면 항등 매핑으로 돈다.
    """
    a = ANALYSES.get(aid)
    if not a or not a.get("highlights"):
        return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
    try:
        id_list = json.loads(ids)
        if not isinstance(id_list, list) or not id_list:
            raise ValueError
    except (json.JSONDecodeError, ValueError):
        return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400)
    rs = _truthy(remove_silence)
    pid = hashlib.sha1((aid + "|" + ids + "|rs" + ("1" if rs else "0")).encode()).hexdigest()[:12]
    PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs,
                     "content_profile": "politics" if a.get("mode") == "politics"
                     else "entertainment"}
    return JSONResponse({"prepare_id": pid})


@app.get("/auto/prepare/{pid}")
async def auto_prepare_stream(pid: str) -> StreamingResponse:
    """자동 탭 2단계 SSE — 남은 ID만 순차로 다운로드·받아쓰기 + 댓글 매칭.

    ⚠ 순차로 돌린다(전역 방침) — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 써서 동시에 여러 개를
    돌리면 서로 느려지기만 한다. ID 하나 실패는 그 ID만 건너뛰고 나머지는 계속 진행한다
    (다운로드 실패 말고는 드래프트 생성을 막지 않는다는 전역 원칙과 같은 이유).
    """
    p = PREPARES.get(pid)

    async def gen():
        if not p:
            yield _sse({"type": "error", "message": "알 수 없는 준비 요청입니다."})
            return
        aid = p["aid"]
        a = ANALYSES.get(aid)
        if not a or not a.get("highlights"):
            yield _sse({"type": "error", "message": "분석 결과가 만료됐습니다. 다시 분석해 주세요."})
            return
        by_id = {h["id"]: h for h in a["highlights"] if "paste" in h}
        targets = [by_id[i] for i in p["ids"] if i in by_id]
        if not targets:
            yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."})
            return
        url = a.get("url", "")
        politics = p.get("content_profile") == "politics"
        warnings: list[str] = []

        steps = [{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"}]
        if not politics:
            steps.insert(0, {"id": "comments", "label": "댓글 수집 (h-lab)"})
        yield _sse({"type": "manifest", "steps": steps})

        comments: list[dict] = []
        if not politics:
            yield _sse({"type": "step", "id": "comments", "status": "start"})
            try:
                comments = await asyncio.to_thread(hlab.fetch_comments, url)
                yield _sse({"type": "step", "id": "comments", "status": "done",
                            "detail": f"{len(comments)}개"})
            except Exception as exc:  # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다
                warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
                yield _sse({"type": "step", "id": "comments", "status": "done",
                            "detail": "실패(생략)"})

        yield _sse({"type": "step", "id": "prepare", "status": "start"})
        highlights_out: list[dict] = []
        all_ranges: list[tuple] = []
        # ⚠ 순차로 돌려라 — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 쓴다(전역 방침).
        for h in targets:
            hid = h["id"]
            state = None
            failed = False
            # ⚠ paste_analyze 내부에서 draft_name = _safe_name(title) or draft_name 로
            # 영상 제목이 항상 draft_name 을 덮어쓴다(_safe_name 은 거의 항상 truthy) —
            # 여기서 넘기는 f"auto_{aid}_{hid}" 는 그래서 절대 안 쓰인다. 같은 URL(한 aid)의
            # 하이라이트 5개는 제목이 똑같으니 name_suffix 없이는 draft_name 이 전부 같아져
            # build_bg_template_draft 의 allow_replace=True 가 뒤엣것으로 앞을 덮어쓴다
            # (옛 /auto/build 의 tag→name_suffix 메커니즘 — 여기서도 반드시 넘겨야 한다).
            safe_tag = "".join(ch for ch in f"하이라이트{hid}" if ch.isalnum() or ch in "-_")[:20]
            payload = _hl_paste_payload(h["paste"])   # dict 컷 → 튜플 컷 (필수 — docstring 참고)
            try:
                async for ev in paste_analyze(payload, f"auto_{aid}_{hid}",
                                              remove_silence=p.get("remove_silence", True),
                                              asr_bottom=True,
                                              name_suffix=safe_tag):
                    if ev.get("type") == "state":   # 내부 전용 — 밖으로 흘리지 않는다
                        state = ev["state"]
                        continue
                    if ev.get("type") == "log":
                        yield _sse({"type": "log", "msg": f"ID {hid}: {ev.get('msg')}"})
                        continue
                    yield _sse(ev)     # step(download/asr) — manifest 밖 id 는 화면에서 무시됨
            except Exception as exc:  # noqa: BLE001 — 이 ID만 건너뛰고 나머지는 계속
                failed = True
                warnings.append(f"ID {hid} 준비 실패 — {type(exc).__name__}: {exc}")
                yield _sse({"type": "log", "msg": f"ID {hid} 실패: {type(exc).__name__}: {exc}"})
            if failed or state is None:
                if not failed:
                    warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다")
                continue

            if politics:
                # paste_analyze가 다운로드 결과의 uploader를 "@채널명"으로 채운다.
                # 하단 표시는 계정 멘션이 아니라 출처 표기이므로 접두사를 바꾼다.
                actual_channel = str(state.get("channel") or "").strip().lstrip("@").strip()
                state["channel"] = (f"출처 · {actual_channel}" if actual_channel else "출처 · 채널명 확인 필요")

            # ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용),
            #   orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
            places = state["card_places"]
            orig = [(s, e) for s, e, _, _ in state["cuts"]]
            all_ranges.extend(orig)
            if politics:
                cuts, need = None, 0
            else:
                try:
                    cuts, need, ai_failed = await asyncio.to_thread(
                        recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
                    if ai_failed:
                        warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — "
                                         "분:초·자막단어·좋아요로 배정했습니다")
                except Exception as exc:  # noqa: BLE001
                    cuts, need = None, max(1, int(state["timeline_dur"] // 3))
                    warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 "
                                     f"({type(exc).__name__}: {exc})")

            PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig,
                                       "payload": payload, "content_profile": p["content_profile"]}
            matched = hlab.match_ranges(comments, orig) if comments else []
            highlights_out.append({
                "id": hid, "cuts": cuts, "need": need,
                "cutRanges": [{"start": s, "end": e} for s, e in orig],
                "matched": matched,
            })
            yield _sse({"type": "log", "msg": f"ID {hid} 준비 완료"})

        yield _sse({"type": "step", "id": "prepare", "status": "done",
                    "detail": f"{len(highlights_out)}/{len(targets)}개 완료"})

        no_ts = [c for c in comments if not c["times"]]
        matched_all = hlab.match_ranges(comments, all_ranges) if all_ranges else []
        yield _sse({"type": "result", "highlights": highlights_out,
                    "comments": comments,
                    "candidates": hlab.top_liked(no_ts, set(matched_all), len(no_ts)),
                    "warnings": warnings})

    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})


@app.get("/auto/avatar")
async def auto_avatar(url: str) -> Response:
    """프로필 이미지 동일 출처 프록시(canvas taint 회피). 구글 도메인만(SSRF 방지)."""
    try:
        host = (urllib.parse.urlparse(url).hostname or "").lower()
    except ValueError:
        host = ""
    if not (host.endswith("ggpht.com") or host.endswith("googleusercontent.com")):
        return Response(status_code=400)

    def _get():
        with urllib.request.urlopen(url, timeout=15) as r:
            return r.read(), r.headers.get("Content-Type") or "image/jpeg"

    try:
        data, ct = await asyncio.to_thread(_get)
    except Exception:  # noqa: BLE001
        return Response(status_code=502)
    return Response(content=data, media_type=ct,
                    headers={"Cache-Control": "public, max-age=21600"})


@app.get("/prompts")
async def prompts_get() -> JSONResponse:
    try:
        return JSONResponse({
            "step1": prompt_store.load_step1(),
            "step3": prompt_store.load_step3(),
            "config": prompt_store.load_config(),
            "step3_path": prompt_store.STEP3_PATH,
        })
    except Exception as exc:  # noqa: BLE001
        return JSONResponse({"error": str(exc)}, 500)


@app.post("/prompts")
async def prompts_post(step1: str = Form(None), step3: str = Form(None),
                       config: str = Form(None), reset: str = Form("")) -> JSONResponse:
    try:
        if _truthy(reset):
            prompt_store.reset()   # Step 3 지침서는 사용자 파일 — 리셋 대상 아님
        else:
            prompt_store.save(step1=step1, step3=step3, config=config)
    except Exception as exc:  # noqa: BLE001
        return JSONResponse({"error": f"저장 실패: {exc}"}, 400)
    return JSONResponse({"ok": True})


@app.post("/auto/build")
async def auto_build(
    aid: str = Form(...),
    hid: str = Form(..., alias="id"),
    title_top: str = Form(""),
    title_main: str = Form(""),
    cards: list[UploadFile] = File(default=[]),
    card_cuts: str = Form(""),
    video_scale: str = Form("144"),
    flip: str = Form(""),
    scene: str = Form(""),
    bg_white: str = Form(""),
    cards_fixed: str = Form(""),
    asr_bottom: str = Form("1"),
    comments_dir: str = Form(""),
) -> JSONResponse:
    """자동 탭 3단계 — /auto/prepare 가 PSTATES 에 보관한 상태로 드래프트만 만든다.

    다운로드·받아쓰기는 다시 하지 않는다(/paste/build 와 같은 방식) — aid+id 로
    PSTATES[f"{aid}:{id}"] 를 찾아 paste_draft 만 돌린다. title_top/title_main 은
    검토 화면(1차)에서 고른 값을 여기서 받아 state 에 덮어쓴다.
    """
    key = f"{aid}:{hid}"
    st = PSTATES.get(key)
    if not st:
        return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404)
    state = st["state"]
    # 정치 모드는 검토 화면이 없어 브라우저 상태가 초기화돼 빈 제목이 올 수 있다.
    # 이 경우 준비 단계에 보관한 원래 JSON 제목을 복구해 자리표시자가 들어가지 않게 한다.
    original = st.get("payload") or {}
    state["title_top"] = title_top.strip() or str(original.get("title_top") or "").strip()
    state["title_main"] = title_main.strip() or str(original.get("title_main") or "").strip()
    # 자동 탭에서 만드는 모든 드래프트 이름은 화면 제목과 동일하게 맞춘다.
    # Windows/CapCut 폴더명에 안전한 문자만 남기고 "title_top title_main" 형식을 유지한다.
    display_name = " ".join(x for x in (state["title_top"], state["title_main"]) if x).strip()
    safe_name = "".join(c for c in display_name
                        if c.isalnum() or c in (" ", "_", "-", ".")).strip()[:80]
    if safe_name:
        state["draft_name"] = safe_name
    if st.get("content_profile") == "politics":
        state["channel"] = str(original.get("channel") or state.get("channel") or "").strip()

    # asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
    # 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가
    # 항상 asr_bottom=True 로 돈 결과라 받아쓰기로 덮여 있다).
    if not _truthy(asr_bottom):
        dur = state["dur"]
        bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
                       in zip(state["placements"], state["cuts"]) if b and p0 < dur]
        bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
        state = {**state, "bottom_caps": bottom_caps}

    # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
    cut_map: list[int] = []
    try:
        parsed = json.loads(card_cuts) if card_cuts.strip() else []
        if isinstance(parsed, list):
            cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
    except (json.JSONDecodeError, ValueError, TypeError):
        cut_map = []

    sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
           + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|"
           + st.get("content_profile", "entertainment") + "|" + str(len(cards)))
    h = hashlib.sha1(sig.encode()).hexdigest()[:12]
    cdir = comments_dir.strip()
    if cards:
        cdir = os.path.join(COMMENTS_DIR, h)
        if os.path.isdir(cdir):        # 재빌드 시 이전 카드 잔재 제거
            shutil.rmtree(cdir, ignore_errors=True)
        os.makedirs(cdir, exist_ok=True)
        for i, f in enumerate(cards, 1):
            body = await f.read()
            with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
                out.write(body)

    JOBS[h] = {
        "paste_state": state, "card_cuts": cut_map,
        "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
        "comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
        "content_profile": st.get("content_profile", "entertainment"),
    }
    return JSONResponse({"job_id": h})


@app.post("/paste/analyze")
async def paste_analyze_start(data: str = Form(...)) -> JSONResponse:
    """📋 붙여넣기 탭(새 흐름) 1단계 — 편집안 검증 후 분석 예약. 실제 작업은 /paste/stream 에서."""
    try:
        payload = parse_paste(data)
    except ValueError as e:
        return JSONResponse({"error": str(e)}, 400)
    aid = hashlib.sha1(("paste|" + data).encode()).hexdigest()[:12]
    ANALYSES[aid] = {"payload": payload}
    return JSONResponse({"analysis_id": aid})


@app.get("/paste/stream/{aid}")
async def paste_stream(aid: str) -> StreamingResponse:
    """📋 붙여넣기 탭(새 흐름) — 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result.

    추천을 하려면 받아쓰기가 항상 필요하므로 remove_silence·asr_bottom 은 여기서
    True 로 고정한다. 사용자가 화면에서 끄는 asr_bottom 옵션은 화면 자막에만
    적용되며 /paste/build 단계에서 처리한다.
    """
    a = ANALYSES.get(aid)

    async def gen():
        if not a:
            yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
            return
        payload = a["payload"]
        warnings: list[str] = []
        # draft 스텝은 이 단계(analyze)에서 안 돈다 — /paste/build 때 별도 스트림으로 실행되므로
        # 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
        yield _sse({"type": "manifest", "steps":
                    [s for s in pipeline_paste_steps(True) if s["id"] != "draft"] +
                    [{"id": "comments", "label": "댓글 수집 (h-lab)"},
                     {"id": "recommend", "label": "컷별 댓글 추천"}]})
        com_task = asyncio.create_task(
            asyncio.to_thread(hlab.fetch_comments, payload["url"]))
        yield _sse({"type": "step", "id": "comments", "status": "start"})
        state = None
        try:
            async for ev in paste_analyze(payload, "paste_" + aid,
                                          remove_silence=True, asr_bottom=True):
                if ev.get("type") == "state":   # 내부 전용 — 밖으로 흘리지 않는다
                    state = ev["state"]
                    continue
                if ev.get("type") == "error":
                    com_task.cancel()
                    yield _sse(ev)
                    return
                yield _sse(ev)
        except Exception as exc:  # noqa: BLE001 — 다운로드 등 실패는 드래프트 생성을 막는다
            com_task.cancel()
            yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
            return
        if state is None:
            com_task.cancel()
            yield _sse({"type": "error", "message": "분석 상태를 만들지 못했습니다."})
            return

        comments: list[dict] = []
        try:
            comments = await com_task
            yield _sse({"type": "step", "id": "comments", "status": "done",
                        "detail": f"{len(comments)}개"})
        except Exception as exc:  # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다
            warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
            yield _sse({"type": "step", "id": "comments", "status": "done",
                        "detail": "실패(생략)"})

        yield _sse({"type": "step", "id": "recommend", "status": "start"})
        # ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용),
        #   orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
        places = state["card_places"]
        orig = [(s, e) for s, e, _, _ in state["cuts"]]
        # 예상 밖 예외가 나면(감싸지 않았을 때) SSE 가 error 이벤트도 없이 끊기고
        # PSTATES 도 안 남아 build 가 404 난다 — 추천 실패가 검토 화면 자체를 막으면 안 된다.
        try:
            cuts, need, ai_failed = await asyncio.to_thread(
                recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
            if ai_failed:
                warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
        except Exception as exc:  # noqa: BLE001
            # cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/➕ 폴백
            # 화면(자동/구간 탭과 동일한 hl.cuts 없음 경로)으로 넘어간다.
            cuts, need = None, max(1, int(state["timeline_dur"] // 3))
            warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})")
        yield _sse({"type": "step", "id": "recommend", "status": "done",
                    "detail": (f"{len(cuts)}컷 · 카드 {need}장" if cuts is not None
                               else f"폴백 · 카드 {need}장")})

        PSTATES[aid] = {"state": state, "places": places, "orig": orig, "payload": payload}
        no_ts = [c for c in comments if not c["times"]]
        matched = hlab.match_ranges(comments, orig) if comments else []
        yield _sse({"type": "result", "cuts": cuts, "need": need,
                    "total": round(state["timeline_dur"], 1),
                    "cutRanges": [{"start": s, "end": e} for s, e in orig],
                    "matched": matched,
                    "candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
                    "comments": comments, "warnings": warnings})

    return StreamingResponse(gen(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})


@app.post("/paste/build")
async def paste_build(
    aid: str = Form(...),
    cards: list[UploadFile] = File(default=[]),
    card_cuts: str = Form(""),
    video_scale: str = Form("144"),
    flip: str = Form(""),
    scene: str = Form(""),
    bg_white: str = Form(""),
    cards_fixed: str = Form(""),
    asr_bottom: str = Form("1"),
    comments_dir: str = Form(""),
) -> JSONResponse:
    """📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
    st = PSTATES.get(aid)
    if not st:
        return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
    state = st["state"]

    # asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
    # 되돌린다. state["bottom_caps"]는 analyze가 항상 asr_bottom=True로 돈 결과라
    # 받아쓰기로 덮여 있다 — paste_analyze가 하던 것과 같은 식으로 원본 JSON 자막을
    # 다시 만들고, 무음 제거로 압축된 타임라인에 맞춰 _remap_caps로 재매핑한다.
    if not _truthy(asr_bottom):
        dur = state["dur"]
        bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
                       in zip(state["placements"], state["cuts"]) if b and p0 < dur]
        bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
        state = {**state, "bottom_caps": bottom_caps}

    # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치)
    cut_map: list[int] = []
    try:
        parsed = json.loads(card_cuts) if card_cuts.strip() else []
        if isinstance(parsed, list):
            cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
    except (json.JSONDecodeError, ValueError, TypeError):
        cut_map = []

    sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
           + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
    h = hashlib.sha1(sig.encode()).hexdigest()[:12]
    # 카드가 오면 폴더 지정보다 우선(/youtube 와 같은 규칙). 카드를 하나도 안 골랐으면
    # 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
    cdir = comments_dir.strip()
    if cards:
        cdir = os.path.join(COMMENTS_DIR, h)
        if os.path.isdir(cdir):        # 재빌드 시 이전 카드 잔재 제거
            shutil.rmtree(cdir, ignore_errors=True)
        os.makedirs(cdir, exist_ok=True)
        for i, f in enumerate(cards, 1):
            body = await f.read()
            with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
                out.write(body)

    JOBS[h] = {
        "paste_state": state, "card_cuts": cut_map,
        "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
        "comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
    }
    return JSONResponse({"job_id": h})


@app.post("/open-capcut")
async def open_capcut() -> JSONResponse:
    """CapCut 실행 (Start Menu 바로가기 우선, 없으면 최신 버전 exe)."""
    lnk = os.path.join(os.environ.get("APPDATA", ""),
                       "Microsoft", "Windows", "Start Menu", "Programs", "CapCut.lnk")
    target = lnk if os.path.isfile(lnk) else None
    if not target:
        exes = sorted(glob.glob(os.path.join(
            os.environ.get("LOCALAPPDATA", ""), "CapCut", "Apps", "*", "CapCut.exe")))
        target = exes[-1] if exes else None
    if not target:
        return JSONResponse({"ok": False, "error": "CapCut 실행 파일을 못 찾았습니다."}, 404)
    try:
        os.startfile(target)  # type: ignore[attr-defined]  (Windows 전용)
        return JSONResponse({"ok": True})
    except Exception as exc:  # noqa: BLE001
        return JSONResponse({"ok": False, "error": str(exc)}, 500)


@app.get("/drafts")
async def drafts() -> JSONResponse:
    """드래프트 목록(최근순) + 레이어 꼬임 여부. 수리 UI 용."""
    return JSONResponse({"drafts": list_drafts()[:30]})


def _capcut_running() -> bool:
    """CapCut.exe 실행 여부. 실행 중이면 수리해도 CapCut 이 덮어써서 헛수고가 된다."""
    try:
        out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq CapCut.exe"],
                             capture_output=True, text=True, errors="replace",
                             timeout=10).stdout
        return "CapCut.exe" in out
    except Exception:  # noqa: BLE001 — 판정 실패 시 막지 않음
        return False


@app.post("/repair")
async def repair(draft: str = Form(...), force: str = Form("")) -> JSONResponse:
    """레이어 수리 — 옮긴 영상 클립이 흰 띠·댓글 위로 삐져나온 것을 되돌린다.

    ⚠ CapCut 이 실행 중이면 거부한다. 실측 사례: 11:04:32 수리 → 11:05:39 CapCut 저장으로
    되돌아감. CapCut 은 파일을 다시 읽지 않고 메모리 상태로 덮어쓰기 때문.
    """
    if _capcut_running() and not _truthy(force):
        return JSONResponse({
            "ok": False,
            "capcut_running": True,
            "error": "CapCut이 실행 중입니다. 수리해도 CapCut이 저장하면서 되돌립니다.\n"
                     "CapCut을 완전히 종료한 뒤 다시 눌러주세요.",
        }, 409)
    path = _resolve_draft(draft)
    if not path:
        return JSONResponse({"ok": False, "error": f"드래프트를 못 찾음: {draft}"}, 404)
    try:
        res = repair_layers(path)
    except Exception as exc:  # noqa: BLE001
        return JSONResponse({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
    return JSONResponse({"ok": True, "draft": os.path.basename(path), **res})


def _resolve_draft(key: str) -> str:
    """폼 값(드래프트 폴더 경로 또는 폴더명)을 실제 폴더로 변환. 못 찾으면 "".

    드래프트가 기본 경로 밖(CapCut 저장 위치 변경)에도 있어 `basename` 고정은 못 쓴다.
    대신 `list_drafts()` 가 아는 드래프트에만 매칭해 임의 경로 접근을 막는다.
    """
    key = (key or "").strip()
    if not key:
        return ""
    drafts = list_drafts()
    want = os.path.normcase(os.path.abspath(key))
    for d in drafts:                                   # ① 경로 완전 일치
        if os.path.normcase(os.path.abspath(d["path"])) == want:
            return d["path"]
    base = os.path.basename(key.rstrip("\\/"))
    for d in drafts:                                   # ② 폴더명 (구버전 UI 호환)
        if d["name"] == key or d["name"] == base:
            return d["path"]
    return ""


def _sse(obj: dict) -> str:
    return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
