"""pycapcut 으로 점프컷 CapCut 드래프트 생성.

함정 회피:
- Timerange 는 정수 µs 로 직접 구성 (float→tim 은 '초'가 아니라 µs 반올림이므로).
- target(타임라인) 커서를 µs 정수로 누적 → 세그먼트 사이 1µs 갭/오버랩 방지(tm_duration).
- 드래프트 캔버스를 원본 해상도와 동일하게 → transform/scale 불필요(transform_y 함정 회피).
"""
from __future__ import annotations

import os
from typing import List, Optional, Tuple

import pycapcut as p

from .probe import VideoMeta

Segment = Tuple[float, float]
# (src_start_sec, src_end_sec, text|None)
Clip = Tuple[float, float, Optional[str]]

# CapCut(Windows) 드래프트 루트
DEFAULT_DRAFT_ROOT = os.path.join(
    os.environ["LOCALAPPDATA"], "CapCut", "User Data", "Projects", "com.lveditor.draft"
)


def _us(seconds: float) -> int:
    return round(seconds * p.SEC)


def build_jumpcut_draft(
    video_path: str,
    speech_segments: List[Segment],
    meta: VideoMeta,
    draft_name: str,
    *,
    draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
    """발화 구간만 이어붙인 점프컷 드래프트를 생성하고 경로 반환."""
    if not speech_segments:
        raise ValueError("speech_segments 가 비어 있습니다 (감지된 발화 없음).")

    folder = p.DraftFolder(draft_root)
    script = folder.create_draft(
        draft_name, meta.width, meta.height, fps=meta.fps, allow_replace=True
    )
    script.add_track(p.TrackType.video)

    material = p.VideoMaterial(video_path)

    cursor_us = 0  # 타임라인 커서(µs 정수 누적 → 갭 방지)
    for s, e in speech_segments:
        src_start = _us(s)
        dur = _us(e) - src_start  # 길이도 같은 반올림으로 일관
        if dur <= 0:
            continue
        source = p.Timerange(src_start, dur)
        target = p.Timerange(cursor_us, dur)
        script.add_segment(p.VideoSegment(material, target, source_timerange=source))
        cursor_us += dur

    script.save()
    return os.path.join(draft_root, draft_name)


# 숏폼 세로 캔버스 기본값 (9:16)
SHORT_W, SHORT_H = 1080, 1920


def _cover_scale(src_w: int, src_h: int, canvas_w: int, canvas_h: int) -> float:
    """canvas 를 가득 채우는(cover) scale. CapCut scale=1.0 == contain(여백맞춤) 가정.

    src 가 canvas 보다 가로로 넓으면 scale=1 에서 가로가 맞고 위아래 여백 → 세로를
    채우려면 그만큼 키운다. 반대도 동일. 결과는 중앙 크롭.
    ※ scale 의미는 CapCut 렌더로 실측 검증 필요(transform 함정 구역).
    """
    src_ar = src_w / src_h
    canvas_ar = canvas_w / canvas_h
    if src_ar > canvas_ar:        # src 가 더 넓음 → 세로를 채우려 키움
        return (canvas_h * src_ar) / canvas_w
    else:                         # src 가 더 좁음/김 → 가로를 채우려 키움
        return (canvas_w / src_ar) / canvas_h


# 자막 위치(세로 하단). transform_y 방향은 CapCut 렌더로 실측 검증 필요.
CAPTION_Y = -0.62

# 제목 윗줄 포인트색(주황). RGB 0~1.
TITLE_ACCENT = (1.0, 0.62, 0.05)


def _ty(y_px: float, canvas_h: int = 1920) -> float:
    """픽셀 y → CapCut 정규화 transform_y. (+ 위 / − 아래, 0=중앙) 가정."""
    return round((canvas_h / 2 - y_px) / (canvas_h / 2), 4)


# 템플릿 텍스트 위치(픽셀 기준 → transform_y). 레이아웃: media.to_template 와 맞춤.
# 상단 띠 0~410 / 영상 410~1410 / 하단 띠 1410~1920
TPL_TITLE_TOP_Y = _ty(150)     # 윗줄(작게, 대괄호)  ≈ +0.84
TPL_TITLE_MAIN_Y = _ty(285)    # 아랫줄(크게)        ≈ +0.70
TPL_CAPTION_Y = _ty(1300)      # 영상 하단부 자막    ≈ -0.35
TPL_CHANNEL_Y = _ty(1520)      # 하단 띠 채널/출처   ≈ -0.58


# 레이아웃(워크맨 스타일): 상단 제목띠 / 영상(위로) / 하단 큰 영역(댓글캡쳐 직접) / 출처
# 캡컷 인스펙터 위치 Y = transform.y × 1920
LAYOUT_TOP_BAR = 440          # 상단 제목 띠
LAYOUT_BOTTOM_TOP = 1100      # 하단 띠 시작 → 영상 영역 = 440~1100, 하단 1100~1920(댓글+출처)
BG_TITLE_TOP_Y = _ty(150)     # 제목 윗줄  ≈ +0.84
BG_TITLE_MAIN_Y = _ty(320)    # 제목 아랫줄 ≈ +0.67
BG_VIDEO_Y = _ty(770)         # 영상 기본 위치(영상 영역 중앙, 위로) ≈ +0.20
BG_CAPTION_Y = _ty(1030)      # 영상 하단부 자막 ≈ -0.07
BG_CHANNEL_Y = _ty(1850)      # 맨 아래 출처 ≈ -0.93 (하단 댓글영역 아래)

COMMENT_SCALE = 0.89          # 댓글 카드 확대(캡컷 인스펙터 89%)
# 카드 아랫변이 넘으면 안 되는 y(px) — 출처(@채널, 파이프라인 기준 y≈1800) 위 여유까지.
# 캡처 쪽 5줄 말줄임이 1차 방어지만, 예외적으로 큰 카드(줄바꿈 연타 등)가 와도
# 여기서 비율 축소해 출처를 절대 덮지 않는다(안전망).
COMMENT_MAX_BOTTOM = 1760


# 하단 검은 배경 자막 글꼴 크기 — 고정값(캡컷 폰트 크기와 1:1).
# 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈.
# 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음.
CAPTION_SIZE = 12.0
# 하단 자막 스타일(캡컷 인스펙터 실측과 1:1): 제주명조체 · 흰색 · 검은 획 40 · 배경/그림자 없음.
CAPTION_COLOR = (1.0, 1.0, 1.0)         # 흰색
CAPTION_BORDER_W = 40.0                 # 획(외곽선) 두께 — 캡컷 UI 값과 동일 스케일

# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit)
CAPTION_PX_PER_UNIT = 5.15
CAPTION_TARGET_PX = 1000     # 한 줄 목표 폭(1080 캔버스에 여유)
_MALGUN_BD = "C:/Windows/Fonts/malgunbd.ttf"


def fit_caption_size(texts: List[str], *, max_size: float = 13.0, min_size: float = 7.0) -> float:
    """가장 긴 자막도 한 줄(≤CAPTION_TARGET_PX)에 들어가는 최대 글꼴 크기.

    ※ 현재 미사용 — 하단 자막은 CAPTION_SIZE 고정. 자동 맞춤으로 되돌릴 때 쓴다.
    """
    texts = [t for t in texts if t]
    if not texts or not os.path.exists(_MALGUN_BD):
        return max_size
    from PIL import ImageFont  # 지연 import

    def width(t: str, px: int) -> int:
        bb = ImageFont.truetype(_MALGUN_BD, max(1, px)).getbbox(t)
        return bb[2] - bb[0]

    longest = max(texts, key=lambda t: width(t, 50))
    s = max_size
    while s > min_size and width(longest, round(s * CAPTION_PX_PER_UNIT)) > CAPTION_TARGET_PX:
        s -= 0.5
    return s


def _img_wh(path: str) -> Tuple[int, int]:
    """이미지 픽셀 크기(w, h). 댓글 카드 폭 맞춤 축소에 사용."""
    from PIL import Image  # 지연 import
    with Image.open(path) as im:
        return im.width, im.height


def build_bg_template_draft(
    clip_path: str,
    bg_image_path: Optional[str],
    frame_image_path: str,
    video_clips: List[Tuple[float, float]],
    captions: List[Tuple[float, float, str]],
    meta: VideoMeta,
    draft_name: str,
    *,
    title_top: Optional[str] = None,
    title_main: Optional[str] = None,
    channel: Optional[str] = None,
    video_y: float = BG_VIDEO_Y,
    video_scale: float = 1.0,
    flip_horizontal: bool = False,
    title_top_y: float = BG_TITLE_TOP_Y,
    title_main_y: float = BG_TITLE_MAIN_Y,
    caption_y: float = BG_CAPTION_Y,
    channel_y: float = BG_CHANNEL_Y,
    effect_captions: Optional[List[Tuple[float, float, str]]] = None,
    effect_y: Optional[float] = None,
    comment_cards: Optional[List[Tuple[float, float, str]]] = None,
    comment_y: Optional[float] = None,
    comment_top: Optional[float] = None,
    bg_white: bool = False,
    logo_image: Optional[str] = None,
    canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
    draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
    """움직일 수 있는 영상 + 투명 가운데 프레임(검은 띠, 위) + 텍스트.

    - 영상(main): scale 1.0, transform 0. 사용자가 캡컷에서 확대/좌우 이동.
    - frame(검은 띠+투명 가운데): 영상 위 → 영상이 띠 영역을 침범해도 항상 깔끔.
    - bg(검정-흰색-검정, 선택): 주면 맨 아래에 깔아 빈 곳을 흰색으로. None 이면 생략(빈 곳 검정).
    렌더 순서(render_index): [bg] < main < frame < text.
    """
    if not video_clips:
        raise ValueError("video_clips 가 비어 있습니다.")
    cw, ch = canvas
    has_bg = bool(bg_image_path)
    politics_style = bool(logo_image)
    # 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집
    if not title_main:
        title_main = "메인제목"
    if not title_top:
        title_top = "서브제목"

    folder = p.DraftFolder(draft_root)
    script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
    # 영상 타입 트랙: [bg] → main → frame(위) → [logo].
    if has_bg:
        script.add_track(p.TrackType.video, "bg", relative_index=0)
    base_ri = 1 if has_bg else 0
    script.add_track(p.TrackType.video, "main", relative_index=base_ri)
    script.add_track(p.TrackType.video, "frame", relative_index=base_ri + 1)
    if logo_image and os.path.isfile(logo_image):
        script.add_track(p.TrackType.video, "logo", relative_index=base_ri + 2)
    if comment_cards:   # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어
        script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2)
    # 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지
    script.add_track(p.TrackType.text, "caption", relative_index=1)
    if title_top:
        script.add_track(p.TrackType.text, "title_top", relative_index=2)
    if title_main:
        script.add_track(p.TrackType.text, "title_main", relative_index=3)
    if channel:
        script.add_track(p.TrackType.text, "channel", relative_index=4)
    if effect_captions:
        script.add_track(p.TrackType.text, "effect", relative_index=5)

    # 영상 소재 로드 + 실제 소재 길이(µs)로 컷 끝 클램프
    #   ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임)
    #   source_timerange 가 소재 길이를 초과해 에러 나는 것 방지.
    material = p.VideoMaterial(clip_path)
    mat_us = getattr(material, "duration", 0) or 0
    clips_us = []
    for s, e in video_clips:
        s_us, e_us = _us(s), _us(e)
        if mat_us:
            e_us = min(e_us, mat_us)
        if e_us - s_us > 0:
            clips_us.append((s_us, e_us))

    # 타임라인 길이 = 보존 비디오 구간 합(클램프 반영)
    total_us = sum(e - s for s, e in clips_us)

    # 흰 배경(맨 아래, 전체 길이) — 선택
    if has_bg:
        bg_mat = p.VideoMaterial(bg_image_path)
        script.add_segment(p.VideoSegment(bg_mat, p.Timerange(0, total_us)), "bg")

    # 영상(점프컷): 보존 구간 이어붙임. 확대 + 영상영역 중앙. 캡컷에서 추가 조정 가능.
    vclip = p.ClipSettings(scale_x=video_scale, scale_y=video_scale, transform_y=video_y,
                           flip_horizontal=flip_horizontal)
    cursor_us = 0
    for s_us, e_us in clips_us:
        dur = e_us - s_us
        script.add_segment(p.VideoSegment(
            material, p.Timerange(cursor_us, dur),
            source_timerange=p.Timerange(s_us, dur),
            clip_settings=vclip,
        ), "main")
        cursor_us += dur

    # 자막: 검은 배경 박스 + 흰 글씨(레퍼런스 스타일), 영상 위.
    # bottom 에 \n 있으면 '같은 자리·시간 분할'로 순서대로: 앞 절반 윗줄 → 뒤 절반 아랫줄.
    def _cap_lines(t):
        # 진짜 줄바꿈뿐 아니라 글자 그대로의 \n \r (LLM 이중 이스케이프)도 줄바꿈으로 처리
        t = (t or "")
        for a, b in (("\\r\\n", "\n"), ("\\n", "\n"), ("\\r", "\n"), ("\r\n", "\n"), ("\r", "\n")):
            t = t.replace(a, b)
        return [ln.strip() for ln in t.split("\n") if ln.strip()]

    # 배경 박스 없음(background 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입.
    cap_style = p.TextStyle(size=18.0 if politics_style else CAPTION_SIZE,
                            bold=False, color=CAPTION_COLOR, align=1)
    cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=CAPTION_BORDER_W)
    for ts, te, text in captions:
        if _us(te) - _us(ts) <= 0 or not text:
            continue
        lines = _cap_lines(text)
        n = len(lines)
        for i, ln in enumerate(lines):
            # 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치)
            a = ts + (te - ts) * i / n
            b = ts + (te - ts) * (i + 1) / n
            seg = _us(b) - _us(a)
            if seg <= 0:
                continue
            script.add_segment(p.TextSegment(
                ln, p.Timerange(_us(a), seg),
                style=cap_style, border=cap_border,
                clip_settings=p.ClipSettings(transform_y=caption_y),
            ), "caption")

    # 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음).
    if effect_captions:
        eff_y = effect_y if effect_y is not None else (caption_y + 0.11)
        eff_style = p.TextStyle(size=13.0, bold=False,
                                color=(13/255, 255/255, 99/255), align=1)  # #0dff63 녹색
        eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0)
        for ts, te, text in effect_captions:
            dur = _us(te) - _us(ts)
            if dur <= 0 or not text:
                continue
            script.add_segment(p.TextSegment(
                text, p.Timerange(_us(ts), dur),
                style=eff_style, border=eff_border,
                clip_settings=p.ClipSettings(transform_y=eff_y),
            ), "effect")

    # 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이
    frame_mat = p.VideoMaterial(frame_image_path)
    script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame")

    # 정치팩트랩 로고: 기준 영상처럼 하단 검정 영역 중앙에 고정한다.
    if logo_image and os.path.isfile(logo_image):
        script.add_segment(p.VideoSegment(
            p.VideoMaterial(logo_image), p.Timerange(0, total_us),
            clip_settings=p.ClipSettings(scale_x=0.27, scale_y=0.27,
                                         transform_x=0.0, transform_y=_ty(1640, ch)),
        ), "logo")

    # 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0.
    # 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 —
    # 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.
    #   CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로
    #   가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale.
    if comment_cards:
        for ts, te, img in comment_cards:
            dur = _us(te) - _us(ts)
            if dur <= 0 or not img or not os.path.isfile(img):
                continue
            eff = COMMENT_SCALE
            if comment_top is not None:
                iw, ih = _img_wh(img)
                disp_h = cw * (ih / iw) * eff
                max_h = COMMENT_MAX_BOTTOM - comment_top
                if 0 < max_h < disp_h:   # 긴 카드 — 출처를 덮기 전에 그 카드만 축소
                    eff *= max_h / disp_h
                    disp_h = max_h
                cy = _ty(comment_top + disp_h / 2, ch)
            else:
                cy = comment_y if comment_y is not None else round(-1162/1920, 4)
            script.add_segment(p.VideoSegment(
                p.VideoMaterial(img), p.Timerange(_us(ts), dur),
                clip_settings=p.ClipSettings(scale_x=eff, scale_y=eff,
                                             transform_x=0.0, transform_y=cy),
            ), "comment")

    full = p.Timerange(0, total_us)
    # 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선.
    # 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자.
    main_border_w = 50.0 if bg_white else 18.0
    channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0)
    if title_top:   # 정치: 흰색 / 예능: 주황
        script.add_segment(p.TextSegment(
            title_top, full,
            style=p.TextStyle(size=17.0 if politics_style else 14.0, bold=True,
                              color=((1.0, 1.0, 1.0) if politics_style else TITLE_ACCENT), align=1),
            border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0),
            clip_settings=p.ClipSettings(transform_y=title_top_y),
        ), "title_top")
    if title_main:  # 정치: 빨강 / 예능: 흰색
        script.add_segment(p.TextSegment(
            title_main, full,
            style=p.TextStyle(size=20.0 if politics_style else 18.0, bold=True,
                              color=((1.0, 0.18, 0.18) if politics_style else (1.0, 1.0, 1.0)),
                              align=1),
            border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w),
            clip_settings=p.ClipSettings(transform_y=title_main_y),
        ), "title_main")
    if channel:
        # 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정)
        script.add_segment(p.TextSegment(
            channel, full,
            style=p.TextStyle(size=12.0 if politics_style else 10.0, bold=False,
                              color=channel_color, align=1),
            clip_settings=p.ClipSettings(transform_y=channel_y),
        ), "channel")

    script.save()
    draft_dir = os.path.join(draft_root, draft_name)
    _apply_font_to_texts(draft_dir, KOTRA_BOLD)   # 모든 텍스트에 코트라 볼드체 주입
    _apply_font_to_track(draft_dir, "caption",
                         CAFE24_DANJUNGHAE if politics_style else JEJU_MYEONGJO)
    if politics_style:
        _apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)
    # 예능 하단 자막은 그림자 없음(획 40 으로 대체).
    _lock_tracks(draft_dir, LOCK_TRACKS)          # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지)
    if bg_white and title_top:   # 서브제목에 그림자 주입(캡컷 실측 형식)
        _apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW)
    return draft_dir


# 편집 중 잠글 트랙: CapCut에서 이 트랙들이 실수로 쪼개지면(예: 재생헤드 전체 분할)
# CapCut이 render_index 를 다시 매기다 main 영상을 frame 위로 올려버려 영상이 흰 띠·댓글
# 위로 삐져나오는 버그가 있었다. 미리 잠가 두면 분할/재배치가 막혀 레이어가 안 꼬인다.
# main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다.
# bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고,
# 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청).
LOCK_TRACKS = ("frame", "comment", "logo", "title_top", "title_main", "channel")


# 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일.
#
# ※ 한때 frame/comment 를 비디오 대역 밖(14500/14501)으로 올리고 텍스트를 24000+ 로 미는
#   방식을 넣었다가 사용자 요청으로 **원복**했다. 의도는 "복붙 클립이 받는 max+1 이
#   비디오 대역 안에서만 계산된다면 frame 아래에 갇힌다"였다. 다시 시도하려면
#   레이어_삐짐_수리.md 를 먼저 읽을 것 — 검증되지 않은 가정이다.
CANON_RI = {"bg": 0, "main": 1, "frame": 2, "comment": 3}


def timeline_jsons(draft_dir: str):
    """이 드래프트에서 render_index 를 담고 있는 JSON 파일 전부(최근 수정순 아님).

    ⚠ **루트 `draft_content.json` 만 고치면 CapCut 에 반영되지 않는다.**
    CapCut 9.x(`draft_meta_info.json` 의 `draft_new_version` 164+)부터 프로젝트 실데이터가
    `Timelines/<GUID>/` 아래로 옮겨갔다. 실측 근거(2026-08-03):

    - `Timelines/` 폴더는 **CapCut 에서 한 번이라도 연** 드래프트에만 생긴다
      (빌더가 막 만든 드래프트엔 없다 → 그래서 생성은 멀쩡했다).
    - 드래프트 `소지섭이 올리브를…(2)` 는 루트 `draft_content.json` 이 **아예 없는데도**
      CapCut 이 계속 편집 중이었다(`Timelines/…/template.json` 이 최신).
    - 저장 시각도 `template.json` 이 항상 가장 늦다 → 루트는 레거시 미러.

    그래서 수리는 루트 + `Timelines/*/draft_content.json` + `Timelines/*/template.json`
    을 **전부** 고쳐야 한다. `.tmp` 는 저장 중 임시파일이라 건드리지 않는다.
    """
    out = []
    root = os.path.join(draft_dir, "draft_content.json")
    if os.path.isfile(root):
        out.append(root)
    tl = os.path.join(draft_dir, "Timelines")
    if os.path.isdir(tl):
        for guid in sorted(os.listdir(tl)):
            gdir = os.path.join(tl, guid)
            if not os.path.isdir(gdir):
                continue
            for fn in ("draft_content.json", "template.json"):
                p = os.path.join(gdir, fn)
                if os.path.isfile(p):
                    out.append(p)
    return out


def list_drafts(draft_root: str = DEFAULT_DRAFT_ROOT):
    """드래프트 목록(최근 수정순). [{name, title, path, mtime, broken, bad}].

    - `name`  폴더명, `title` CapCut 홈에 보이는 프로젝트 이름, `path` 실제 폴더 경로(수리 키)
    - `broken`/`bad` 레이어 꼬임 여부·개수

    ⚠ **기본 폴더만 훑으면 안 된다.** CapCut 설정에서 저장 위치를 바꾸면 드래프트가
    `%LOCALAPPDATA%\\CapCut\\…\\com.lveditor.draft` 밖(예: `D:/…/CapCut Drafts`)에 생긴다.
    실측(2026-08-03): 프로젝트 `222222222222` 가 D 드라이브에 있어 목록에 안 떴다.
    어디에 있든 `root_meta_info.json` 의 `all_draft_store[].draft_fold_path` 가 알고 있다.

    또 캡컷에서 이름을 바꿔도 폴더명은 그대로라 `title` 과 `name` 이 갈린다
    (예: 화면 `열심히 하는 나경 땜걸~ 찡긋` ↔ 폴더 `열심히 하는 나경`).
    """
    out, seen = [], set()
    reg = _registered_drafts(draft_root)   # {폴더경로: CapCut 홈에 뜨는 이름}

    def add(ddir: str) -> None:
        key = os.path.normcase(os.path.abspath(ddir))
        if key in seen or not os.path.isdir(ddir):
            return
        files = timeline_jsons(ddir)
        if not files:
            return
        seen.add(key)
        bad, mtime = 0, 0.0
        for jf in files:
            mtime = max(mtime, os.path.getmtime(jf))
            try:
                # 파일마다 세는 값이 같으므로 합이 아니라 최댓값(꼬인 세그먼트 수)
                bad = max(bad, _count_bad_ri(jf))
            except Exception:  # noqa: BLE001 — 읽기 실패한 드래프트는 목록에만 노출
                bad = max(bad, -1) if bad else -1
        name = os.path.basename(ddir.rstrip("\\/"))
        # 이름은 CapCut 홈과 같게 — root_meta_info 가 정답이고 폴더 안 메타는 옛 이름이 남는다
        title = (reg.get(key) or {}).get("name") or _draft_title(ddir) or name
        out.append({"name": name, "title": title,
                    "path": os.path.abspath(ddir), "mtime": mtime,
                    "broken": bad > 0, "bad": bad})

    if os.path.isdir(draft_root):
        for name in os.listdir(draft_root):
            add(os.path.join(draft_root, name))
    for v in reg.values():
        add(v["path"])

    out.sort(key=lambda d: d["mtime"], reverse=True)
    return out


def _registered_drafts(draft_root: str = DEFAULT_DRAFT_ROOT) -> dict:
    """`root_meta_info.json` 에 등록된 드래프트 — {대조용 키: {"path", "name"}}.

    키는 `normcase(abspath(...))`(대소문자 무시 대조용)이고, 표시·접근에는 원본 `path` 를 쓴다.
    CapCut 홈 화면이 읽는 인덱스라 기본 경로 밖 드래프트와 최신 이름이 여기에만 있다.
    """
    import json
    p = os.path.join(draft_root, "root_meta_info.json")
    if not os.path.isfile(p):
        return {}
    try:
        store = json.load(open(p, encoding="utf-8")).get("all_draft_store") or []
    except Exception:  # noqa: BLE001 — 인덱스가 깨져도 기본 폴더 스캔은 살린다
        return {}
    reg = {}
    for d in store:
        fp = (d or {}).get("draft_fold_path") or ""
        if fp:
            path = os.path.abspath(os.path.normpath(fp))
            reg[os.path.normcase(path)] = {"path": path,
                                           "name": (d.get("draft_name") or "").strip()}
    return reg


def _draft_title(draft_dir: str) -> str:
    """CapCut 홈에 보이는 프로젝트 이름(`draft_meta_info.json` 의 `draft_name`)."""
    import json
    mi = os.path.join(draft_dir, "draft_meta_info.json")
    if not os.path.isfile(mi):
        return ""
    try:
        return (json.load(open(mi, encoding="utf-8")).get("draft_name") or "").strip()
    except Exception:  # noqa: BLE001 — 이름 못 읽으면 폴더명으로 대체
        return ""


def _count_bad_ri(json_path: str) -> int:
    """render_index 가 트랙 정상값과 다른 비디오 세그먼트 수."""
    import json
    j = json.load(open(json_path, encoding="utf-8"))
    n = 0
    for tr in j.get("tracks", []):
        if tr.get("type") != "video":
            continue
        want = CANON_RI.get(tr.get("name"))
        if want is None:
            continue
        n += sum(1 for s in tr.get("segments", []) if s.get("render_index") != want)
    return n


def repair_layers(draft_dir: str) -> dict:
    """레이어 수리: 비디오 세그먼트 render_index 를 트랙별 정상값으로 되돌리고 재잠금.

    왜 필요한가: CapCut 에서 main 클립을 옮기면(드래그/잘라붙이기) CapCut 이 그 세그먼트에
    `현재 최대 render_index + 1` 을 새로 찍는다. 그러면 그 클립만 frame(흰 띠)·comment(댓글)
    위로 올라가서 확대 시 템플릿 밖으로 삐져나온다. 트랙 잠금으로는 못 막는다
    (main 은 편집해야 하므로 잠그지 않음). → 사후 수리가 유일한 확실한 방법.

    ⚠ CapCut 에서 해당 프로젝트를 '닫은 상태'로 실행할 것. 열어둔 채 수리하면 CapCut 이
      메모리 상태로 다시 덮어쓴다.
    백업은 `<파일명>.repair.bak` — `.bak`는 CapCut 자체 백업 파일명이라 쓰면 안 된다.
    ⚠ 루트 파일만이 아니라 `Timelines/*` 사본까지 전부 고친다(`timeline_jsons` 주석 참고).
    Returns: {"fixed": 고친 세그먼트 수, "detail": {트랙명: 개수}, "files": 고친 파일 수}
    """
    files = timeline_jsons(draft_dir)
    if not files:
        raise FileNotFoundError(f"타임라인 JSON 없음(draft_content.json / Timelines): {draft_dir}")

    fixed, detail, patched = 0, {}, 0
    for jf in files:
        n, d = _repair_one(jf)
        if n:
            patched += 1
        # 파일마다 같은 내용이므로 합이 아니라 최댓값을 대표값으로 쓴다
        if n > fixed:
            fixed, detail = n, d
    _lock_tracks(draft_dir, LOCK_TRACKS)      # 편집 중 풀린 잠금도 다시 채움
    return {"fixed": fixed, "detail": detail, "files": patched}


def _repair_one(json_path: str):
    """타임라인 JSON 한 개의 비디오 render_index 를 정상값으로 되돌린다.

    Returns: (고친 세그먼트 수, {트랙명: 개수}). 고칠 게 없으면 파일을 건드리지 않는다(멱등).
    """
    import json
    import shutil
    j = json.load(open(json_path, encoding="utf-8"))

    fixed, detail = 0, {}
    # 알려진 트랙은 고정값, 그 외 사용자가 추가한 비디오 트랙은 그 위(4, 5 …)로 밀어 유지
    extra = max(CANON_RI.values()) + 1
    for tr in j.get("tracks", []):
        if tr.get("type") != "video":
            continue
        name = tr.get("name")
        want = CANON_RI.get(name)
        if want is None:                      # 사용자가 추가한 오버레이 트랙 → 맨 위 유지
            want, extra = extra, extra + 1
        n = 0
        for seg in tr.get("segments", []):
            if seg.get("render_index") != want:
                seg["render_index"] = want
                n += 1
        if n:
            detail[name or "(이름없음)"] = n
            fixed += n

    if fixed:
        # CapCut 이 draft_content.json.bak 을 자기 백업으로 쓰므로 다른 이름을 쓴다
        shutil.copyfile(json_path, os.path.splitext(json_path)[0] + ".repair.bak")
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(j, f, ensure_ascii=False)
    return fixed, detail


def _lock_tracks(draft_dir: str, names) -> None:
    """지정 트랙을 잠금 처리 — 루트 + `Timelines/*` 사본 전부.

    잠금은 트랙 `attribute` 의 비트4(=4). mute 비트(1)는 보존(OR). CapCut 실측 확인값.
    """
    import json
    nameset = set(names)
    for jf in timeline_jsons(draft_dir):
        try:
            j = json.load(open(jf, encoding="utf-8"))
        except Exception:  # noqa: BLE001 — 깨진 사본 하나 때문에 전체를 실패시키지 않는다
            continue
        changed = False
        for tr in j.get("tracks", []):
            if tr.get("name") in nameset:
                attr = (tr.get("attribute") or 0) | 4
                if attr != tr.get("attribute"):
                    tr["attribute"], changed = attr, True
        if changed:
            with open(jf, "w", encoding="utf-8") as f:
                json.dump(j, f, ensure_ascii=False)


# 코트라 볼드체(KOTRA_BOLD) — CapCut 폰트 캐시. pycapcut FontType엔 없어 JSON에 직접 주입.
# 경로는 사용자명에 안 묶이게 LOCALAPPDATA 기반(다른 PC에서도 동작). 단 그 PC CapCut에
# 코트라 볼드체가 한 번 다운로드돼 캐시가 있어야 함(없으면 주입 생략 → 기본 폰트).
KOTRA_BOLD = {
    "path": os.path.join(
        os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
        "7480846567709265157", "782a91b14f1661b95e7e587be27f1af4", "font.ttf",
    ).replace("\\", "/"),
    "id": "7480846567709265157",
}


# 제주명조체(JejuMyeongjo) — 하단 자막 전용. 코트라 볼드체와 같은 CapCut 폰트 캐시 방식.
# 캐시가 없는 PC 에서는 주입이 생략돼 코트라 볼드체(전체 주입분)가 그대로 남는다.
JEJU_MYEONGJO = {
    "path": os.path.join(
        os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
        "7480851064179133702", "6ab2b6b0bd71a41109ad5506d600f257", "font.ttf",
    ).replace("\\", "/"),
    "id": "7480851064179133702",
}

# 카페24 단정해 — 정치 숏폼 자막. CapCut에서 사용자가 맞춘 기준 드래프트의
# 실제 폰트 소재 ID/캐시 경로를 그대로 사용한다.
CAFE24_DANJUNGHAE = {
    "path": os.path.join(
        os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
        "7528305055972199681", "0e4893968fe2d82714917f69c69826aa", "font.ttf",
    ).replace("\\", "/"),
    "id": "7528305055972199681",
}


def _text_material_ids(j: dict, track_name: str) -> set:
    """지정 텍스트 트랙 세그먼트들의 material_id 집합."""
    ids = set()
    for tr in j.get("tracks", []):
        if tr.get("type") == "text" and tr.get("name") == track_name:
            ids.update(s.get("material_id") for s in tr.get("segments", []))
    return ids


def _apply_font_to_track(draft_dir: str, track_name: str, font: dict) -> None:
    """지정 텍스트 트랙의 소재에만 폰트 주입(_apply_font_to_texts 의 트랙 한정판).

    전체 주입(코트라 볼드체) 뒤에 호출해 하단 자막만 다른 폰트로 덮어쓴다.
    """
    import json
    if not font.get("path") or not os.path.isfile(font["path"]):
        return
    jf = os.path.join(draft_dir, "draft_content.json")
    if not os.path.isfile(jf):
        return
    j = json.load(open(jf, encoding="utf-8"))
    ids = _text_material_ids(j, track_name)
    if not ids:
        return
    for m in j["materials"].get("texts", []):
        if m.get("id") not in ids:
            continue
        try:
            c = json.loads(m["content"])
        except Exception:  # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀
            continue
        for st in c.get("styles", []):
            st["font"] = dict(font)
        m["content"] = json.dumps(c, ensure_ascii=False)
    with open(jf, "w", encoding="utf-8") as f:
        json.dump(j, f, ensure_ascii=False)


def _apply_font_to_texts(draft_dir: str, font: dict) -> None:
    """저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입.

    폰트 캐시 파일이 없으면(다른 PC에 코트라체 미설치 등) 주입 생략 → 기본 폰트로 안전 동작.
    """
    import json
    if not font.get("path") or not os.path.isfile(font["path"]):
        return
    jf = os.path.join(draft_dir, "draft_content.json")
    j = json.load(open(jf, encoding="utf-8"))
    for m in j["materials"].get("texts", []):
        try:
            c = json.loads(m["content"])
        except Exception:
            continue
        for st in c.get("styles", []):
            st["font"] = dict(font)
        m["content"] = json.dumps(c, ensure_ascii=False)
    with open(jf, "w", encoding="utf-8") as f:
        json.dump(j, f, ensure_ascii=False)


# 텍스트 그림자 — CapCut 실측 형식(색 검정, 불투명도 90%, 흐림 15%, 거리 5, 각도 -45).
# 제목·하단 자막 공용. 반드시 _SHADOW_MATERIAL 과 **같이** 넣어야 실제로 켜진다.
_TEXT_SHADOW = {
    "thickness_projection_angle": -45,
    "thickness_projection_enable": False,
    "diffuse": 0.025,
    "alpha": 0.9,
    "distance": 5.0,
    "content": {"render_type": "solid", "solid": {"color": [0, 0, 0]}},
    "angle": -45,
    "thickness_projection_distance": 0,
}


# 그림자 켤 때 **소재 레벨**에도 같이 박아야 하는 값 — CapCut UI 로 켠 자막에서 실측.
# ⚠ `styles[].shadows` 만 넣으면 CapCut 이 그림자를 안 켠다(`has_shadow=False` 라서).
#   기존 제목 그림자 주입이 딱 이 상태였다 — JSON 엔 있는데 화면엔 안 나옴.
_SHADOW_MATERIAL = {
    "has_shadow": True,
    "shadow_alpha": 0.8999999761581421,
    "shadow_angle": -45.0,
    "shadow_color": "#000000",
    "shadow_distance": 5.0,
    "shadow_point": {"x": 0.6363961030678928, "y": -0.6363961030678928},
    "shadow_smoothing": 0.45000001788139343,
    "shadow_thickness_projection_angle": 0.0,
    "shadow_thickness_projection_distance": 0.0,
    "shadow_thickness_projection_enable": False,
}


def _apply_shadow_to_track(draft_dir: str, track_name: str, shadow: dict) -> None:
    """지정 텍스트 트랙의 모든 소재에 그림자 주입(소재 플래그 + styles[].shadows).

    자막은 세그먼트가 수십 개라 텍스트 값으로 찾는 방식(_apply_shadow_to_text)을 못 쓴다
    — 같은 문장이 여러 번 나올 수 있어서. 트랙 → material_id 로 잡는다.
    """
    import json
    jf = os.path.join(draft_dir, "draft_content.json")
    if not os.path.isfile(jf):
        return
    j = json.load(open(jf, encoding="utf-8"))
    ids = _text_material_ids(j, track_name)
    if not ids:
        return
    for m in j["materials"].get("texts", []):
        if m.get("id") not in ids:
            continue
        m.update(_SHADOW_MATERIAL)
        try:
            c = json.loads(m["content"])
        except Exception:  # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀
            continue
        for st in c.get("styles", []):
            st["shadows"] = [dict(shadow)]
        m["content"] = json.dumps(c, ensure_ascii=False)
    with open(jf, "w", encoding="utf-8") as f:
        json.dump(j, f, ensure_ascii=False)


def _apply_shadow_to_text(draft_dir: str, text_value: str, shadow: dict) -> None:
    """draft_content.json 에서 text_value 와 일치하는 텍스트 소재 스타일에 그림자 주입."""
    import json
    jf = os.path.join(draft_dir, "draft_content.json")
    j = json.load(open(jf, encoding="utf-8"))
    target = (text_value or "").strip()
    for m in j["materials"].get("texts", []):
        try:
            c = json.loads(m["content"])
        except Exception:
            continue
        if (c.get("text") or "").strip() != target:
            continue
        m.update(_SHADOW_MATERIAL)      # 소재 플래그 없으면 CapCut 이 그림자를 안 켠다
        for st in c.get("styles", []):
            st["shadows"] = [dict(shadow)]
        m["content"] = json.dumps(c, ensure_ascii=False)
    with open(jf, "w", encoding="utf-8") as f:
        json.dump(j, f, ensure_ascii=False)


def build_template_draft(
    video_path: str,
    clips: List[Clip],
    meta: VideoMeta,
    draft_name: str,
    *,
    title_top: Optional[str] = None,
    title_main: Optional[str] = None,
    channel: Optional[str] = None,
    draft_root: str = DEFAULT_DRAFT_ROOT,
) -> str:
    """템플릿 드래프트: 굽힌 영상(scale 1.0) + 편집가능 제목(2줄)·자막·채널 텍스트.

    영상 틀/검은 띠는 media.to_template 로 이미 구워져 있고(1080×1920), 여기서는
    그 위에 캡컷에서 수정 가능한 텍스트만 올린다. 텍스트는 시간 겹침을 피해 트랙 분리:
    title_top / title_main / caption / channel.
    """
    if not clips:
        raise ValueError("clips 가 비어 있습니다.")

    folder = p.DraftFolder(draft_root)
    script = folder.create_draft(draft_name, meta.width, meta.height, fps=meta.fps,
                                 allow_replace=True)
    script.add_track(p.TrackType.video)
    script.add_track(p.TrackType.text, "caption")
    if title_top:
        script.add_track(p.TrackType.text, "title_top")
    if title_main:
        script.add_track(p.TrackType.text, "title_main")
    if channel:
        script.add_track(p.TrackType.text, "channel")

    material = p.VideoMaterial(video_path)
    vclip = p.ClipSettings(scale_x=1.0, scale_y=1.0)  # 이미 9:16 → 정확 일치

    # 스타일 (편안한 톤: 흰 본문 + 검은 외곽선). 크기/색은 캡컷에서 미세조정 가능.
    cap_style = p.TextStyle(size=13.0, bold=True, color=(1.0, 1.0, 1.0), align=1)
    cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0)

    cursor_us = 0
    total_us = 0
    for c in clips:
        s, e = c[0], c[1]
        text = c[2] if len(c) > 2 else None
        dur = _us(e) - _us(s)
        if dur <= 0:
            continue
        target = p.Timerange(cursor_us, dur)
        script.add_segment(p.VideoSegment(
            material, target, source_timerange=p.Timerange(_us(s), dur),
            clip_settings=vclip,
        ))
        if text:
            script.add_segment(p.TextSegment(
                text, p.Timerange(cursor_us, dur),
                style=cap_style, border=cap_border,
                clip_settings=p.ClipSettings(transform_y=TPL_CAPTION_Y),
            ), "caption")
        cursor_us += dur
    total_us = cursor_us

    full = p.Timerange(0, total_us)
    # 제목(2줄) — 편안한 톤. 윗줄 작게/연하게, 아랫줄 크게.
    if title_top:
        script.add_segment(p.TextSegment(
            title_top, full,
            style=p.TextStyle(size=8.0, bold=False, color=(0.92, 0.92, 0.92), align=1),
            clip_settings=p.ClipSettings(transform_y=TPL_TITLE_TOP_Y),
        ), "title_top")
    if title_main:
        script.add_segment(p.TextSegment(
            title_main, full,
            style=p.TextStyle(size=16.0, bold=True, color=(1.0, 1.0, 1.0), align=1),
            border=p.TextBorder(color=(0.0, 0.0, 0.0), width=20.0),
            clip_settings=p.ClipSettings(transform_y=TPL_TITLE_MAIN_Y),
        ), "title_main")
    if channel:
        script.add_segment(p.TextSegment(
            channel, full,
            style=p.TextStyle(size=6.0, bold=False, color=(0.8, 0.8, 0.8), align=1),
            clip_settings=p.ClipSettings(transform_y=TPL_CHANNEL_Y),
        ), "channel")

    script.save()
    return os.path.join(draft_root, draft_name)


def build_shortform_draft(
    video_path: str,
    clips: List[Clip],
    meta: VideoMeta,
    draft_name: str,
    *,
    draft_root: str = DEFAULT_DRAFT_ROOT,
    canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
    captions: bool = True,
) -> str:
    """하이라이트 구간 clip 들을 이어붙인 9:16 중앙크롭 숏폼 드래프트.

    clips: (src_start_sec, src_end_sec, text|None) 리스트. text 가 있고 captions=True 면
    해당 클립 구간에 자막(흰 글자 + 검은 외곽선, 하단 중앙) 번인.

    점프컷과 동일하게 구간만 연결, 캔버스 세로 + 각 비디오에 center-crop.
    자막은 별도 text 트랙에 클립 타임라인에 맞춰 배치 → 자동 싱크.
    """
    if not clips:
        raise ValueError("clips 가 비어 있습니다.")

    cw, ch = canvas
    scale = _cover_scale(meta.width, meta.height, cw, ch)
    has_text = captions and any(len(c) > 2 and c[2] for c in clips)

    folder = p.DraftFolder(draft_root)
    script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
    script.add_track(p.TrackType.video)
    if has_text:
        script.add_track(p.TrackType.text)
    material = p.VideoMaterial(video_path)

    vclip = p.ClipSettings(scale_x=scale, scale_y=scale, transform_x=0.0, transform_y=0.0)
    cap_style = p.TextStyle(size=12.0, bold=True, color=(1.0, 1.0, 1.0), align=1)
    cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=40.0)
    cap_clip = p.ClipSettings(transform_y=CAPTION_Y)

    cursor_us = 0
    for c in clips:
        s, e = c[0], c[1]
        text = c[2] if len(c) > 2 else None
        src_start = _us(s)
        dur = _us(e) - src_start
        if dur <= 0:
            continue
        target = p.Timerange(cursor_us, dur)
        script.add_segment(p.VideoSegment(
            material, target, source_timerange=p.Timerange(src_start, dur),
            clip_settings=vclip,
        ))
        if has_text and text:
            script.add_segment(p.TextSegment(
                text, p.Timerange(cursor_us, dur),
                style=cap_style, border=cap_border, clip_settings=cap_clip,
            ))
        cursor_us += dur

    script.save()
    return os.path.join(draft_root, draft_name)
