"""런타임 파이프라인 — SSE 이벤트를 내보내는 async 제너레이터.

현재 단계(1단 기준): silence → draft.
3·4단 추가 시 asr / filler 스텝을 STEPS 와 본문에 삽입.

함정 메모:
- ASR(추후)은 numba 비안전 → asyncio.Lock 으로 직렬화(동시 호출 segfault 방지). 자리만 마련.
- 단계당 최소 MIN_STEP 지연 → 캐시 hit 시에도 애니메이션 가시화.
"""
from __future__ import annotations

import asyncio
import os
import time
from typing import AsyncIterator, Dict, List, Tuple

from typing import Optional

from .probe import probe
from .silence import detect_speech_segments
from .transcribe import transcribe
from .highlight import cut_plan
from .media import make_frame, make_solid
from .scene import detect_scene_changes, split_clips_at_scenes
from .youtube import cut_youtube, cut_youtube_multi, download_paste_cuts, REPAIR_LOG
from .correct import has_gemini_key, correct_captions
from .draft import build_jumpcut_draft, build_bg_template_draft, _ty

MIN_STEP = 0.5  # 초

# ASR(numba) 직렬화 — 동시 호출 시 segfault 방지
ASR_LOCK = asyncio.Lock()

_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DOWNLOADS = os.path.join(_ROOT, ".downloads")


def _card_paths(folder: str):
    """댓글 카드 이미지 경로 목록. 배치 방식과 무관하게 '순서'만 정한다.

    정렬 규칙:
      - 모든 파일명이 숫자로 시작하면 → 숫자순(1, 2, 10 …)
      - 아니면 → 저장(생성) 순서 = 다운로드한 순서
    folder 가 비었거나 없으면 [].
    """
    import re
    folder = (folder or "").strip().strip('"')
    if not folder or not os.path.isdir(folder):
        return []
    imgs = [os.path.join(folder, f) for f in os.listdir(folder)
            if os.path.splitext(f)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")]
    if not imgs:
        return []

    def _leadnum(path):
        m = re.match(r"\s*0*(\d+)", os.path.splitext(os.path.basename(path))[0])
        return int(m.group(1)) if m else None

    if all(_leadnum(p) is not None for p in imgs):
        imgs.sort(key=_leadnum)                 # 파일명 숫자순
    else:
        imgs.sort(key=lambda p: os.path.getctime(p))   # 저장(생성) 순서
    return imgs


def _load_comment_cards(folder: str, dur: float, min_sec: float = 3.0,
                        fixed: bool = False):
    """지정 폴더의 이미지를 영상 길이에 맞춰 하단에 균등 배치.

    장수: n = min(카드 수, max(1, floor(dur/min_sec))) — 카드 하나가 min_sec 밑으로
    내려가지 않는 상한. 배치 간격은 항상 dur/n → 카드가 모자라도 끝까지 빈 곳 없이
    채워지고(간격이 3초 이상으로 늘어남), 넘치면 초과분은 버린다.
    fixed=True 면 늘리지 않고 min_sec 고정 — 모자라면 뒤는 비운다
    (편집하면서 부분삭제를 많이 하는 경우 카드가 늘어나 있으면 타이밍이 꼬여서).

    ⚠ 이 함수는 컷을 모른다. 자동 탭처럼 '어느 컷에 붙일지'가 정해진 경우엔
    `_cards_by_cut()` 을 쓴다. 폴더 지정 경로(파일/유튜브 구간 탭)는 계속 이 함수를 쓴다.
    Returns: [(start, end, path)]
    """
    imgs = _card_paths(folder)
    if not imgs or dur <= 0:
        return []
    n = min(len(imgs), max(1, int(dur // min_sec)))
    if fixed:            # 3초 고정 — 뒤가 비어도 늘리지 않음
        return [(i * min_sec, min((i + 1) * min_sec, dur), path)
                for i, path in enumerate(imgs[:n])]
    interval = dur / n
    return [(i * interval, (i + 1) * interval, path)
            for i, path in enumerate(imgs[:n])]


def _cards_by_cut(paths, card_cuts, placements, dur: float, *,
                  min_sec: float = 3.0, fixed: bool = False):
    """카드를 '소속 컷 구간 안'에 배치 — 자동 탭 전용.

    card_cuts[i] = i번째 카드가 속한 컷 인덱스. 컷 안에서는 균등 분할
    (fixed=True 면 min_sec 고정, 컷 뒷부분은 비움).

    ⚠ placements/dur 은 **최종(무음 제거 반영) 타임라인** 기준이어야 한다.
    압축 전 시간으로 계산해 놓고 나중에 재매핑하면 fixed=True 의 "정확히 3초" 약속이
    깨지고(3초짜리가 2초로 눌린다) 3초 하한도 사라진다.

    컷당 장수 상한 = max(1, floor(컷길이/min_sec)) — `_load_comment_cards` 와 같은 규칙.
    초과분은 버린다(카드가 1초씩 번쩍이느니 몇 장 빼는 게 낫다).

    왜 컷 단위인가: 전체 균등 배치(`_load_comment_cards`)는 컷 경계를 몰라서
    3번 컷 얘기하는 댓글이 7번 컷 위에 뜬다. 컷 안에서 계산하면 한 컷이 덜 차도
    **다음 컷 카드가 앞으로 밀리지 않는다.**
    Returns: [(start, end, path)] 시간순
    """
    if not paths or not card_cuts or not placements or dur <= 0:
        return []
    groups: dict = {}
    for i, ci in enumerate(card_cuts[:len(paths)]):
        if isinstance(ci, int) and 0 <= ci < len(placements):
            groups.setdefault(ci, []).append(i)
    out = []
    for ci, idxs in groups.items():
        p0, p1 = placements[ci]
        p1 = min(p1, dur)
        if p1 <= p0:
            continue                       # 영상 실제 길이 밖 컷은 버린다
        n = min(len(idxs), max(1, int((p1 - p0) // min_sec)))   # 카드당 min_sec 하한
        idxs = idxs[:n]
        step = min_sec if fixed else (p1 - p0) / n
        for k, i in enumerate(idxs):
            s = p0 + k * step
            if s >= p1:
                break                      # fixed 로 컷을 넘치면 뒤는 비운다
            out.append((s, min(s + step, p1), paths[i]))
    out.sort(key=lambda t: t[0])
    return out


def _elapsed_kept(keep_sorted, t: float) -> float:
    """원본 시간 t 가 무음 제거 후(압축) 타임라인에서 놓이는 위치 = t 이전의 보존 길이 합."""
    tot = 0.0
    for s, e in keep_sorted:
        if s >= t:
            break
        tot += min(t, e) - s
    return tot


def _remap_caps(caps, keep_sorted):
    """자막 [(s,e,txt)] 를 무음 제거 타임라인으로 재매핑. 전부 무음이면 버림."""
    out = []
    for cs, ce, txt in caps:
        ns = _elapsed_kept(keep_sorted, cs)
        ne = _elapsed_kept(keep_sorted, ce)
        if ne - ns > 0.05:
            out.append((ns, ne, txt))
    return out


def _remap_placements(placements, keep_sorted):
    """컷 구간 [(p0,p1)] 을 무음 제거 타임라인으로 재매핑.

    ⚠ 자막(`_remap_caps`)과 목적이 다르다. 자막은 '그 말이 나오는 시각'을 따라가면 되지만
    카드는 **컷 구간 자체**를 옮겨야 한다. 카드 시간을 압축 전에 만들어 두고 나중에
    자막처럼 재매핑하면 `cards_fixed`(정확히 3초)가 2초로 눌리고, 3초 하한도 사라진다.
    구간을 먼저 옮기고 그 안에서 나누면 둘 다 지켜진다.

    통째로 무음이라 사라진 컷은 (x, x) 빈 구간이 되고 `_cards_by_cut` 이 건너뛴다.
    """
    return [(_elapsed_kept(keep_sorted, p0), _elapsed_kept(keep_sorted, p1))
            for p0, p1 in placements]


def captions_for_places(captions, places, *, cap: int = 500):
    """컷 구간마다 그 구간에 걸친 자막을 이어붙인다 — 댓글 추천의 근거.

    `captions`·`places` 둘 다 **같은(압축) 타임라인** 좌표여야 한다. 겹치는 부분이
    조금이라도 있으면 그 컷의 말로 본다(경계에 닿기만 하는 건 제외).
    cap 자에서 자른다 — 그 이상은 Gemini 토큰만 먹고 매칭 정확도가 안 오른다.
    Returns: places 와 같은 길이의 문자열 리스트
    """
    out = []
    for p0, p1 in places:
        parts = [txt for cs, ce, txt in captions if cs < p1 and ce > p0 and txt]
        out.append(" ".join(" ".join(parts).split())[:cap])
    return out


def _safe_name(name: str) -> str:
    s = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-", ".")).strip()
    return s[:60] or "video"


def _template_path() -> str:
    for n in ("배경.png", "배경템플릿.png", os.path.join("assets", "bg_template.png")):
        p = os.path.join(_ROOT, n)
        if os.path.isfile(p):
            return p
    return os.path.join(_ROOT, "배경.png")


BG_STEPS: List[Dict[str, str]] = [
    {"id": "silence", "label": "무음·발화 분석"},
    {"id": "asr", "label": "음성 인식(자막)"},
    {"id": "draft", "label": "템플릿 드래프트 생성"},
]


def bg_steps(youtube: Optional[dict], remove_silence: bool = True) -> List[Dict[str, str]]:
    """배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록.

    remove_silence=False 면 silence 스텝을 아예 안 낸다 — bg_analyze 도 그 스텝을
    내지 않으므로 manifest 에 남겨두면 화면에서 영원히 대기 상태로 보인다.
    """
    steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
    if remove_silence:
        steps.append({"id": "silence", "label": "무음·발화 분석"})
    return steps + [{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
                    {"id": "draft", "label": "템플릿 드래프트 생성"}]


async def bg_analyze(
    video_path: Optional[str],
    draft_name: str,
    *,
    title_top: str = "",
    title_main: str = "",
    channel: str = "",
    youtube: Optional[dict] = None,
    remove_silence: bool = True,
) -> AsyncIterator[dict]:
    """배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → [무음컷] → 받아쓰기.

    마지막에 `bg_draft` 로 이어줄 `{"type": "state", ...}` 를 낸다.
    (오디오가 전부 무음이면 `error` 를 내고 조용히 끝난다 — 이때는 `state` 가 안 나온다.)

    remove_silence=False 면 무음 분석을 아예 안 돌리고 `keep = [(0, duration)]` 한 덩어리로
    간다 → `cut_plan`·`_remap_placements` 가 항등 매핑이 돼 자막·카드 좌표가 그대로 남는다
    (붙여넣기 파이프라인 `paste_analyze` 와 같은 패턴). 이때 `places == raw_places`.
    """
    t_all = time.perf_counter()
    use_gemini = has_gemini_key()

    # 구간 탭이 "구간 N이 타임라인의 어디인지" 알아야 자막·배치·장수를 컷 단위로 낼 수 있다.
    # 파일 탭(유튜브 아님)은 빈 리스트 → 아무 데서도 안 쓰인다.
    ranges_sec: List[Tuple[float, float]] = []
    raw_places: List[Tuple[float, float]] = []

    # ── 유튜브 여러 구간 다운로드 + 병합 (URL 입력 시) ──
    if youtube:
        ranges = youtube.get("ranges") or [(youtube.get("start", ""), youtube.get("end", ""))]
        from .paste import parse_time
        c = 0.0
        for s, e in ranges:
            ss, ee = parse_time(str(s)), parse_time(str(e))
            ranges_sec.append((ss, ee))
            raw_places.append((c, c + (ee - ss)))
            c += ee - ss
        yield {"type": "step", "id": "download", "status": "start"}
        rng_txt = ", ".join(f"{s}~{e}" for s, e in ranges)
        yield {"type": "log", "msg": f"유튜브 {len(ranges)}개 구간 다운로드·병합 중… [{rng_txt}]"}
        t = time.perf_counter()
        REPAIR_LOG.clear()
        video_path, title, yt_channel = await asyncio.to_thread(
            cut_youtube_multi, youtube["url"], ranges, _DOWNLOADS,
        )
        for msg in REPAIR_LOG:          # 초록 깨짐 자동 수리 내역
            yield {"type": "log", "msg": f"🩹 {msg}"}
        draft_name = _safe_name(title) or draft_name
        # 출처를 안 적었으면 유튜브 채널명으로 자동 채움
        if not channel and yt_channel:
            channel = f"@{yt_channel}"
            yield {"type": "log", "msg": f"출처 자동: {channel}"}
        await _floor(t)
        yield {"type": "step", "id": "download", "status": "done",
               "elapsed": round(time.perf_counter() - t, 1), "detail": title}

    yield {"type": "log", "msg": f"입력: {os.path.basename(video_path)}"}
    meta = await asyncio.to_thread(probe, video_path)
    yield {"type": "log", "msg": f"{meta.width}×{meta.height} · {meta.fps}fps · {meta.duration:.1f}s"}

    # ── silence (오디오 무음 컷) — 선택. 끄면 통짜 한 덩어리(항등 매핑) ──
    if remove_silence:
        yield {"type": "step", "id": "silence", "status": "start"}
        t = time.perf_counter()
        keep = await asyncio.to_thread(
            detect_speech_segments, video_path, meta.duration,
            noise_db=-28.0, min_silence=0.3, pad=0.04,
        )
        if not keep:
            yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."}
            return
        kept = sum(e - s for s, e in keep)
        # raw_places(병합본 좌표) → 압축 타임라인 좌표. 자막·배치·장수는 전부 이걸 쓴다.
        places = _remap_placements(raw_places, keep) if raw_places else []
        await _floor(t)
        yield {"type": "step", "id": "silence", "status": "done",
               "elapsed": round(time.perf_counter() - t, 1),
               "detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"}
    else:
        keep = [(0.0, meta.duration)]     # 통짜 → cut_plan/_remap 이 항등 매핑
        places = list(raw_places)
        yield {"type": "log", "msg": "무음 제거 꺼짐 — 구간을 그대로 이어붙입니다."}

    # ── asr (받아쓰기): 타이밍=Whisper 단어 타임스탬프(정확, 드리프트 없음).
    #    글자 품질만 Gemini로 제자리 교정(1:1, 시간은 절대 안 건드림) → 싱크 유지 ──
    yield {"type": "step", "id": "asr", "status": "start"}
    t = time.perf_counter()

    yield {"type": "log", "msg": "Whisper로 받아쓰기·타이밍 분석 중… (1분당 ≈30초)"}
    async with ASR_LOCK:
        tr = await asyncio.to_thread(
            transcribe, video_path,
            model_size="medium", language="ko", use_cache=True, vad_filter=False,
        )
    video_clips, captions, total = cut_plan(keep, tr)   # Whisper 타이밍 자막
    method = "Whisper 추출"

    # Gemini 교정: 자막 글자만 다듬고 개수·순서·시간 그대로 유지(싱크 불변).
    if use_gemini and captions:
        yield {"type": "log", "msg": "Gemini로 자막 글자 교정 중… (시간은 그대로)"}
        try:
            texts = [c[2] for c in captions]
            fixed = await asyncio.to_thread(
                correct_captions, texts, title=(title_main or draft_name))
            if isinstance(fixed, list) and len(fixed) == len(captions):
                captions = [(s, e, (ft.strip() or captions[i][2]))
                            for i, ((s, e, _), ft) in enumerate(zip(captions, fixed))]
                method = "Whisper 타이밍 + Gemini 교정"
        except Exception as exc:  # noqa: BLE001 — 교정 실패해도 Whisper 원문 유지
            yield {"type": "log", "msg": f"⚠ Gemini 교정 실패({type(exc).__name__}) → Whisper 원문"}

    await _floor(t)
    yield {"type": "step", "id": "asr", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1),
           "detail": f"{method} · 자막 {len(captions)}개"}

    yield {"type": "state", "state": {
        "video_path": video_path, "meta": meta, "keep": keep,
        "video_clips": video_clips, "captions": captions, "total": total,
        "draft_name": draft_name, "title_top": title_top, "title_main": title_main,
        "channel": channel, "t_all": t_all,
        "ranges_sec": ranges_sec, "raw_places": raw_places, "places": places,
    }}


async def bg_draft(
    state: dict,
    *,
    video_scale: float = 1.0,
    flip_horizontal: bool = False,
    scene_split: bool = False,
    comments_dir: str = "",
    cards_fixed: bool = False,
    bg_white: bool = False,
    comment_cards: Optional[list] = None,
) -> AsyncIterator[dict]:
    """배경템플릿 파이프라인 뒷부분: 장면전환 분할 → 댓글 카드 → 드래프트 생성 → result.

    `bg_analyze` 가 낸 `state` 를 이어받는다. `comment_cards` 가 주어지면
    `_load_comment_cards` 대신 그걸 쓴다(댓글 매칭 단계 준비).
    """
    video_path = state["video_path"]
    meta = state["meta"]
    video_clips = state["video_clips"]
    captions = state["captions"]
    total = state["total"]
    draft_name = state["draft_name"]
    title_top = state["title_top"]
    title_main = state["title_main"]
    channel = state["channel"]
    t_all = state["t_all"]

    # ── 장면전환 분할 (선택): 컷이 바뀌는 지점에서 세그먼트 추가 분할 ──
    if scene_split:
        yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
        before = len(video_clips)
        scenes = await asyncio.to_thread(detect_scene_changes, video_path)
        video_clips = split_clips_at_scenes(video_clips, scenes)
        yield {"type": "log",
               "msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {before} → {len(video_clips)}개"}

    # ── draft (배경템플릿) ──
    yield {"type": "step", "id": "draft", "status": "start"}
    t = time.perf_counter()
    frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white)
    cards = comment_cards if comment_cards is not None else _load_comment_cards(
        comments_dir, total, fixed=cards_fixed)
    if cards:
        yield {"type": "log", "msg": f"댓글 카드 {len(cards)}개 하단 삽입(3초 간격)"}
    path = await asyncio.to_thread(
        lambda: build_bg_template_draft(
            video_path, bg, frame, video_clips, captions, meta, draft_name,
            title_top=title_top or None, title_main=title_main or None,
            channel=channel or None, video_scale=video_scale,
            flip_horizontal=flip_horizontal, comment_cards=cards, bg_white=bg_white, **pos,
        )  # video_clips 는 장면분할 반영된 최신 리스트 사용
    )
    await _floor(t)
    yield {"type": "step", "id": "draft", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1), "detail": draft_name}

    yield {
        "type": "result",
        "draft_name": draft_name,
        "draft_path": path,
        "stats": {
            "duration": round(meta.duration, 1),
            "kept": round(total, 1),
            "cut": round(meta.duration - total, 1),
            "segments": len(video_clips),
            "captions": len(captions),
            "elapsed": round(time.perf_counter() - t_all, 1),
        },
    }


async def process_bg_template(
    video_path: Optional[str],
    draft_name: str,
    *,
    title_top: str = "",
    title_main: str = "",
    channel: str = "",
    video_scale: float = 1.0,
    flip_horizontal: bool = False,
    scene_split: bool = False,
    comments_dir: str = "",
    cards_fixed: bool = False,
    bg_white: bool = False,
    youtube: Optional[dict] = None,
    remove_silence: bool = True,
) -> AsyncIterator[dict]:
    """배경템플릿 파이프라인 — 📁 파일 탭 / ▶ 유튜브 구간 탭용 얇은 래퍼.

    analyze/draft 두 조각을 연달아 부른다. `state` 이벤트는 밖으로 안 흘린다
    (기존 UI가 모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다).
    """
    yield {"type": "manifest", "steps": bg_steps(youtube, remove_silence)}
    state = None
    async for ev in bg_analyze(video_path, draft_name, title_top=title_top,
                               title_main=title_main, channel=channel, youtube=youtube,
                               remove_silence=remove_silence):
        if ev.get("type") == "state":
            state = ev["state"]
            continue
        yield ev
    if state is None:
        return                      # analyze 가 error 로 끝난 경우
    async for ev in bg_draft(state, video_scale=video_scale,
                             flip_horizontal=flip_horizontal, scene_split=scene_split,
                             comments_dir=comments_dir, cards_fixed=cards_fixed,
                             bg_white=bg_white):
        yield ev


# ── 템플릿 레이아웃 (캔버스 1080×1920, 단위 = 픽셀, 위가 0) ───────────────────
# 레퍼런스 템플릿을 실측해 잡은 값. **여기만 고치면 전체 배치가 같이 움직인다.**
# (예전엔 배경.png 흰밴드 자동감지였는데, 좌표를 정확히 통제하려고 상수로 바꿨다)
CANVAS_H = 1920
VIDEO_TOP = 323        # 영상 창 시작 = 위 흰 띠가 끝나는 지점
VIDEO_BOTTOM = 1122    # 영상 창 끝  = 아래 흰 띠가 시작하는 지점
POLITICS_VIDEO_TOP = 470
POLITICS_VIDEO_BOTTOM = 1450
TITLE_TOP_Y = 109      # 서브제목(주황) 중앙
TITLE_MAIN_Y = 252     # 메인제목(흰색) 중앙
CAPTION_GAP = 72       # 하단 자막 중앙 = VIDEO_BOTTOM − 이 값 (영상 창 안쪽 아래)
EFFECT_GAP = 25        # 효과자막 중앙 = VIDEO_TOP + 이 값 (영상 창 안쪽 위)
COMMENT_TOP = VIDEO_BOTTOM   # 댓글 카드 윗변 = 영상 바로 아래(딱 붙음)
CHANNEL_RATIO = 0.85   # 출처: 아래 띠에서 85% 내려간 지점


def _template_pos(white: bool = False, profile: str = "entertainment"):
    """배경템플릿 프레임/배경 생성 + 위치 dict 계산. (frame_path, bg_path, pos) 반환.

    white=True 면 상하 띠·빈 곳을 흰색으로(흰 띠 프레임 + 흰 배경 레이어),
    아니면 검은색(기본, 배경 레이어 없음 → 빈 곳 검정).
    좌표는 전부 위 레이아웃 상수에서 파생 — 한 곳만 고치면 된다.
    """
    band = (255, 255, 255) if white else (0, 0, 0)
    suffix = "_politics" if profile == "politics" else ""
    fname = f"frame_template{suffix}{'_white' if white else ''}.png"
    frame_root = os.path.join(_ROOT, ".cache", "frames") if profile == "politics" else os.path.join(_ROOT, "assets")
    frame = os.path.join(frame_root, fname)
    os.makedirs(os.path.dirname(frame), exist_ok=True)
    video_top = POLITICS_VIDEO_TOP if profile == "politics" else VIDEO_TOP
    video_bottom = POLITICS_VIDEO_BOTTOM if profile == "politics" else VIDEO_BOTTOM
    make_frame(frame, top_bar=video_top, bottom_top=video_bottom, band_color=band)
    bg = None
    if white:
        bg = os.path.join(_ROOT, "assets", "bg_white.png")
        make_solid(bg, (255, 255, 255))
    pos = dict(
        video_y=_ty((video_top + video_bottom) / 2),
        title_top_y=_ty(190 if profile == "politics" else TITLE_TOP_Y),
        title_main_y=_ty(365 if profile == "politics" else TITLE_MAIN_Y),
        caption_y=_ty(video_bottom - CAPTION_GAP),
        effect_y=_ty(video_top + EFFECT_GAP),
        channel_y=_ty(video_bottom + (CANVAS_H - video_bottom) * CHANNEL_RATIO),
        comment_top=video_bottom,
    )
    return frame, bg, pos


def paste_steps(asr_bottom: bool) -> List[Dict[str, str]]:
    """붙여넣기 파이프라인의 manifest 스텝."""
    steps = [{"id": "download", "label": "컷 정밀 다운로드·병합"}]
    if asr_bottom:
        steps.append({"id": "asr", "label": "받아쓰기 (Whisper)"})
    steps.append({"id": "draft", "label": "템플릿 드래프트 생성"})
    return steps


async def paste_analyze(
    payload: dict,
    draft_name: str,
    *,
    remove_silence: bool = False,
    asr_bottom: bool = False,
    name_suffix: str = "",
) -> AsyncIterator[dict]:
    """붙여넣기 파이프라인 앞부분: 컷 정밀 다운로드·병합 → probe → 컷 배치·자막 계산 →
    [무음 제거] → [asr_bottom 받아쓰기].

    자막 배치 시간 = 컷 순서 누적. asr_bottom=True 면 JSON bottom 대신 병합본을
    Whisper로 받아써 실제 발화 타이밍에 맞춘 하단 자막을 생성(effect/제목은 JSON 유지).
    payload: paste.parse_paste 결과 dict.
    마지막에 `paste_draft` 로 이어줄 `{"type": "state", ...}` 를 낸다.
    """
    t_all = time.perf_counter()
    cuts = payload["cuts"]                       # [(s, e, bottom, effect)]
    url = payload["url"]
    title_top = payload.get("title_top", "")
    title_main = payload.get("title_main", "")
    channel = payload.get("channel", "")

    # ── 컷 정밀 다운로드 + 병합 ──
    yield {"type": "step", "id": "download", "status": "start"}
    yield {"type": "log", "msg": f"{len(cuts)}개 컷 정밀 다운로드·병합 중… (프레임 정확 컷)"}
    t = time.perf_counter()
    ranges = [(s, e) for s, e, _, _ in cuts]
    REPAIR_LOG.clear()
    video_path, title, yt_channel = await asyncio.to_thread(
        download_paste_cuts, url, ranges, _DOWNLOADS,
    )
    for msg in REPAIR_LOG:              # 초록 깨짐 자동 수리 내역
        yield {"type": "log", "msg": f"🩹 {msg}"}
    draft_name = _safe_name(title) or draft_name
    if name_suffix:   # 같은 영상에서 여럿 만들 때 이름 충돌(=드래프트 교체) 방지
        draft_name = f"{draft_name}_{name_suffix}"
    if not channel and yt_channel:
        channel = f"@{yt_channel}"
        yield {"type": "log", "msg": f"출처 자동: {channel}"}
    await _floor(t)
    yield {"type": "step", "id": "download", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1), "detail": title}

    meta = await asyncio.to_thread(probe, video_path)
    dur = meta.duration
    yield {"type": "log", "msg": f"병합 결과 {meta.width}×{meta.height} · {dur:.1f}s"}

    # 자막 배치 = 컷 순서 누적(공급된 컷 길이 기준). 영상 실제 길이로 클램프.
    placements, c = [], 0.0
    for s, e, _, _ in cuts:
        placements.append((c, c + (e - s)))
        c += (e - s)
    bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _) in zip(placements, cuts)
                   if b and p0 < dur]
    eff_caps = [(p0, min(p1, dur), ef) for (p0, p1), (_, _, _, ef) in zip(placements, cuts)
                if ef and p0 < dur]

    card_places = placements          # 카드 배치 기준 구간(무음 제거 시 압축본으로 교체)
    video_clips = [(0.0, dur)]                    # 병합본 = 한 덩어리(재컷 없음)
    timeline_dur = dur

    # 무음 제거(선택): 컷 안의 무음까지 잘라내고 자막 시간을 압축 타임라인으로 재매핑
    if remove_silence:
        yield {"type": "log", "msg": "무음 분석 중… (컷 안의 무음 제거)"}
        keep = await asyncio.to_thread(
            detect_speech_segments, video_path, dur,
            noise_db=-28.0, min_silence=0.3, pad=0.04,
        )
        if keep:
            keep = sorted(keep)
            bottom_caps = _remap_caps(bottom_caps, keep)
            eff_caps = _remap_caps(eff_caps, keep)
            # 카드는 '시간'이 아니라 '컷 구간'을 옮긴다 — 아래에서 이 구간 안에 나눠 넣는다.
            card_places = _remap_placements(placements, keep)
            video_clips = keep
            timeline_dur = sum(e - s for s, e in keep)
            yield {"type": "log",
                   "msg": f"무음 {dur - timeline_dur:.1f}s 제거 → {timeline_dur:.1f}s"}

    # ── asr_bottom(선택): 병합본을 Whisper로 받아써 하단 자막 자동 생성 ──
    # Whisper 타임스탬프는 '병합 파일' 기준. cut_plan(video_clips, tr)이 파일 시간을
    # 최종(무음제거 반영) 타임라인으로 매핑 + 짧은 자막 청킹까지 처리한다.
    # (무음제거 안 켰으면 video_clips=[(0,dur)] → 항등 매핑. 파일/유튜브 탭과 동일 패턴)
    if asr_bottom:
        yield {"type": "step", "id": "asr", "status": "start"}
        t = time.perf_counter()
        yield {"type": "log", "msg": "Whisper로 받아쓰기 중… (1분당 ≈30초)"}
        async with ASR_LOCK:
            tr = await asyncio.to_thread(
                transcribe, video_path,
                model_size="medium", language="ko", use_cache=True, vad_filter=False,
            )
        _, asr_caps, _ = cut_plan(video_clips, tr)
        method = "Whisper 추출"
        if asr_caps and has_gemini_key():
            yield {"type": "log", "msg": "Gemini로 자막 글자 교정 중… (시간은 그대로)"}
            try:
                texts = [ct for _, _, ct in asr_caps]
                fixed = await asyncio.to_thread(
                    correct_captions, texts, title=(title_main or draft_name))
                if isinstance(fixed, list) and len(fixed) == len(asr_caps):
                    asr_caps = [(s, e, (ft.strip() or asr_caps[i][2]))
                                for i, ((s, e, _), ft) in enumerate(zip(asr_caps, fixed))]
                    method = "Whisper 타이밍 + Gemini 교정"
            except Exception as exc:  # noqa: BLE001 — 교정 실패해도 원문 유지
                yield {"type": "log", "msg": f"⚠ Gemini 교정 실패({type(exc).__name__}) → Whisper 원문"}
        if asr_caps:
            bottom_caps = asr_caps          # JSON bottom 완전 대체
        else:
            yield {"type": "log", "msg": "⚠ 받아쓰기 결과 없음 → JSON bottom 자막 사용"}
        await _floor(t)
        yield {"type": "step", "id": "asr", "status": "done",
               "elapsed": round(time.perf_counter() - t, 1),
               "detail": f"{method} · 자막 {len(bottom_caps)}개"}

    yield {"type": "state", "state": {
        "video_path": video_path, "meta": meta, "dur": dur, "cuts": cuts,
        "placements": placements, "card_places": card_places,
        "video_clips": video_clips, "timeline_dur": timeline_dur,
        "bottom_caps": bottom_caps, "eff_caps": eff_caps,
        "draft_name": draft_name, "title_top": title_top, "title_main": title_main,
        "channel": channel, "t_all": t_all,
    }}


async def paste_draft(
    state: dict,
    *,
    video_scale: float = 1.0,
    flip_horizontal: bool = False,
    scene_split: bool = False,
    comments_dir: str = "",
    cards_fixed: bool = False,
    card_cuts: Optional[List[int]] = None,
    bg_white: bool = False,
    content_profile: str = "entertainment",
) -> AsyncIterator[dict]:
    """붙여넣기 파이프라인 뒷부분: [장면분할] → 댓글 카드 → 드래프트 생성 → result.

    `paste_analyze` 가 낸 `state` 를 이어받는다.
    """
    video_path = state["video_path"]
    meta = state["meta"]
    dur = state["dur"]
    cuts = state["cuts"]
    placements = state["placements"]
    card_places = state["card_places"]
    video_clips = state["video_clips"]
    timeline_dur = state["timeline_dur"]
    bottom_caps = state["bottom_caps"]
    eff_caps = state["eff_caps"]
    draft_name = state["draft_name"]
    title_top = state["title_top"]
    title_main = state["title_main"]
    channel = state["channel"]
    t_all = state["t_all"]

    # ── draft ──
    yield {"type": "step", "id": "draft", "status": "start"}
    t = time.perf_counter()
    frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white, content_profile)

    # 장면분할(선택): 화면 바뀌는 지점마다 세그먼트 추가 분할(자막 시간 불변)
    if scene_split:
        yield {"type": "log", "msg": "장면전환 감지 중… (화면 바뀌는 컷 찾기)"}
        scenes = await asyncio.to_thread(detect_scene_changes, video_path)
        video_clips = split_clips_at_scenes(video_clips, scenes)
        yield {"type": "log", "msg": f"장면전환 {len(scenes)}곳 → 세그먼트 {len(video_clips)}개"}

    # 댓글 카드(자동 탭): '몇 번 컷 소속'만 받아 여기서 시간을 만든다.
    # 서버가 시간을 확정하면 무음 제거 때 자막만 당겨지고 카드는 혼자 어긋난다.
    # ⚠ 무음 제거 **뒤**에 계산한다 — 압축 전 시간으로 만들어 재매핑하면
    # cards_fixed(정확히 3초)가 눌리고 카드당 3초 하한이 사라진다.
    cut_cards = _cards_by_cut(_card_paths(comments_dir), card_cuts or [],
                              card_places, timeline_dur, fixed=cards_fixed)
    # 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치
    cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
    if cards:
        msg = f"댓글 카드 {len(cards)}개 하단 삽입"
        if cut_cards:
            # 무음 제거로 컷이 짧아지면 quotas_for(원본 길이)가 고른 장수보다
            # _cards_by_cut(압축 후 길이)의 상한이 작아질 수 있다 — 그 차이를 조용히 삼키지 않는다.
            dropped = len(card_cuts or []) - len(cut_cards)
            msg += "(컷별 배치" + (f", {dropped}장은 컷 길이가 짧아 제외)" if dropped > 0 else ")")
        else:
            msg += "(전체 균등)"
        yield {"type": "log", "msg": msg}

    path = await asyncio.to_thread(
        lambda: build_bg_template_draft(
            video_path, bg, frame, video_clips, bottom_caps, meta, draft_name,
            title_top=title_top or None, title_main=title_main or None,
            channel=channel or None, video_scale=video_scale,
            flip_horizontal=flip_horizontal, effect_captions=eff_caps,
            comment_cards=cards, bg_white=bg_white, **pos,
            logo_image=(os.path.join(_ROOT, "assets", "politics_factlab_logo.png")
                        if content_profile == "politics" else None),
        )
    )
    await _floor(t)
    yield {"type": "step", "id": "draft", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1), "detail": draft_name}

    yield {
        "type": "result",
        "draft_name": draft_name,
        "draft_path": path,
        "stats": {
            "duration": round(dur, 1),
            "kept": round(dur, 1),
            "cut": 0.0,
            "segments": len(cuts),
            "captions": len(bottom_caps),
            "elapsed": round(time.perf_counter() - t_all, 1),
        },
    }


async def process_paste(payload, draft_name, *, video_scale=1.0, flip_horizontal=False,
                        scene_split=False, comments_dir="", cards_fixed=False,
                        card_cuts=None, bg_white=False, remove_silence=False,
                        asr_bottom=False, name_suffix="") -> AsyncIterator[dict]:
    """붙여넣기(JSON) 파이프라인 — 기존 호출부용 얇은 래퍼.

    analyze/draft 두 조각을 연달아 부른다. `state` 이벤트는 밖으로 안 흘린다.
    """
    yield {"type": "manifest", "steps": paste_steps(asr_bottom)}
    state = None
    async for ev in paste_analyze(payload, draft_name, remove_silence=remove_silence,
                                  asr_bottom=asr_bottom, name_suffix=name_suffix):
        if ev.get("type") == "state":
            state = ev["state"]
            continue
        yield ev
    if state is None:
        return
    async for ev in paste_draft(state, video_scale=video_scale,
                                flip_horizontal=flip_horizontal, scene_split=scene_split,
                                comments_dir=comments_dir, cards_fixed=cards_fixed,
                                card_cuts=card_cuts, bg_white=bg_white):
        yield ev


STEPS: List[Dict[str, str]] = [
    {"id": "silence", "label": "무음·발화 분석"},
    {"id": "draft", "label": "점프컷 드래프트 생성"},
]


async def _floor(t0: float) -> None:
    """최소 단계 시간 보장."""
    dt = time.perf_counter() - t0
    if dt < MIN_STEP:
        await asyncio.sleep(MIN_STEP - dt)


async def process_video(video_path: str, draft_name: str) -> AsyncIterator[dict]:
    """영상 1개 처리. SSE 로 흘려보낼 dict 이벤트를 yield."""
    t_all = time.perf_counter()
    yield {"type": "manifest", "steps": STEPS}

    meta = await asyncio.to_thread(probe, video_path)
    yield {"type": "meta", "resolution": f"{meta.width}×{meta.height}",
           "fps": meta.fps, "duration": round(meta.duration, 1)}

    # ── silence ──────────────────────────────────────────
    yield {"type": "step", "id": "silence", "status": "start"}
    t = time.perf_counter()
    segments = await asyncio.to_thread(detect_speech_segments, video_path, meta.duration)
    if not segments:
        await _floor(t)
        yield {"type": "error", "message": "발화 구간이 감지되지 않았습니다 (무음 임계값 확인)."}
        return
    speech_total = sum(e - s for s, e in segments)
    cut = meta.duration - speech_total
    await _floor(t)
    yield {"type": "step", "id": "silence", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1),
           "detail": f"발화 {len(segments)}구간 · {cut:.1f}s 컷"}

    # ── draft ────────────────────────────────────────────
    yield {"type": "step", "id": "draft", "status": "start"}
    t = time.perf_counter()
    path = await asyncio.to_thread(
        build_jumpcut_draft, video_path, segments, meta, draft_name
    )
    await _floor(t)
    yield {"type": "step", "id": "draft", "status": "done",
           "elapsed": round(time.perf_counter() - t, 1),
           "detail": draft_name}

    # ── result ───────────────────────────────────────────
    yield {
        "type": "result",
        "draft_name": draft_name,
        "draft_path": path,
        "stats": {
            "duration": round(meta.duration, 1),
            "kept": round(speech_total, 1),
            "cut": round(cut, 1),
            "cut_pct": round(cut / meta.duration * 100) if meta.duration else 0,
            "segments": len(segments),
            "resolution": f"{meta.width}×{meta.height}",
            "elapsed": round(time.perf_counter() - t_all, 1),
        },
    }
