"""유튜브 구간 잘라받기 (ClipCut 로직 흡수). yt-dlp --download-sections.

캡컷 에이전트 앞단: URL + 시작/끝 → 그 구간만 h264 mp4 로 받아 파이프라인에 투입.
"""
from __future__ import annotations

import glob
import hashlib
import json
import os
import re
import shutil
import subprocess
from typing import List, Tuple


def _js_runtime_args() -> List[str]:
    """yt-dlp 유튜브 추출용 JS 런타임 지정. 설치된 것(deno/node/bun) 자동 선택.

    최신 유튜브는 JS 챌린지 때문에 런타임이 없으면 포맷 누락→ffmpeg 크래시가 난다.
    없으면 빈 리스트(사용자가 Node.js 등 설치 필요).
    """
    for rt in ("deno", "node", "bun"):
        if shutil.which(rt):
            return ["--js-runtimes", rt]
    return []

# MM:SS 또는 HH:MM:SS
TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
TIMEOUT_SEC = 1800


def valid_time(t: str) -> bool:
    return bool(TIME_RE.match(t.strip()))


def _video_codec(path: str) -> str:
    try:
        out = subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", "v:0",
             "-show_entries", "stream=codec_name", "-of", "json", path],
            capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
        ).stdout
        return json.loads(out)["streams"][0]["codec_name"]
    except Exception:
        return ""


# ── 초록 프레임(GOP 중간 컷) 검증·복구 ──────────────────────────────────
# yt-dlp --download-sections 가 가끔 키프레임이 아닌 위치에서 스트림 복사로 잘라
# 파일을 만든다. 그러면 첫 키프레임 전까지는 참조 프레임이 없어 디코딩이 안 되고,
# 그 파일을 concat 재인코딩할 때 그 구간이 통째로 '초록 화면'으로 구워진다.
# ⚠ 함정: 파트를 단독 재생하면 ffmpeg 가 깨진 앞부분을 건너뛰어서 멀쩡해 보이고,
#   ffprobe -read_intervals 도 키프레임으로 시크해버려 못 잡는다.
#   → 시크 없이 프레임을 훑어 '첫 키프레임 시각'을 봐야 한다(0 이 아니면 깨진 것).
KEYFRAME_TOL = 0.05     # 첫 키프레임이 이보다 늦으면 앞부분 깨짐으로 판정(초)
DL_ATTEMPTS = 2         # 깨진 결과 재다운로드 횟수(간헐적 실패용). 그 뒤엔 재컷으로 확정 해결
RECUT_LEAD = 6.0        # 최후 수단: 앞에 이만큼 여유를 받아 로컬에서 다시 자름(초)

# 이번 요청에서 초록 깨짐을 고친 내역(파이프라인이 읽어 SSE 로그로 흘림).
# 로컬 단일 사용자 앱이라 모듈 전역으로 둔다 — 동시 작업 시 로그가 섞일 수 있으나 무해.
REPAIR_LOG: List[str] = []


def _first_keyframe_sec(path: str, max_frames: int = 3000) -> float:
    """첫 비디오 키프레임의 시각(초). 0 이면 맨 앞이 키프레임 = 정상.

    시크를 쓰지 않고(=`-read_intervals` 금지) 앞에서부터 훑는다. 첫 키프레임을 만나면
    즉시 종료하므로 정상 파일에서는 한 줄만 읽고 끝난다.
    판정 불가(ffprobe 실패 등)면 0.0 → 통과시킨다(다운로드를 살리는 쪽으로).
    """
    try:
        proc = subprocess.Popen(
            ["ffprobe", "-v", "error", "-select_streams", "v:0",
             "-show_entries", "frame=key_frame,pts_time", "-of", "csv=p=0", path],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
            text=True, encoding="utf-8", errors="replace",
        )
    except OSError:
        return 0.0
    try:
        for i, line in enumerate(proc.stdout or []):
            if i >= max_frames:
                break
            f = line.strip().split(",")
            if len(f) >= 2 and f[0] == "1":
                try:
                    return float(f[1])
                except ValueError:
                    return 0.0
        return 0.0
    finally:
        # ⚠ Windows: 파이프를 안 닫으면 ffprobe 가 파일을 계속 잡고 있어
        #   바로 뒤따르는 삭제/덮어쓰기가 PermissionError 로 실패한다.
        try:
            if proc.stdout:
                proc.stdout.close()
        except OSError:
            pass
        proc.kill()
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            pass


def _duration(path: str) -> float:
    try:
        out = subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
             "-of", "json", path],
            capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
        ).stdout
        return float(json.loads(out)["format"]["duration"])
    except Exception:
        return 0.0


def _encode_h264(src: str, dst: str, *, ss: float = 0.0, t: float = 0.0) -> str:
    """h264/aac 로 재인코딩. ss/t 는 '-i 뒤'에 둬서 프레임 정확(출력 시크)."""
    cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src]
    if ss > 0:
        cmd += ["-ss", f"{ss:.3f}"]
    if t > 0:
        cmd += ["-t", f"{t:.3f}"]
    cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
            "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", dst]
    subprocess.run(cmd, check=True, encoding="utf-8", errors="replace")
    return dst


def _unlink(path: str) -> None:
    try:
        os.remove(path)
    except OSError:
        pass


def cut_youtube(url: str, start: str, end: str, out_dir: str) -> Tuple[str, str, str]:
    """유튜브 [start,end] 구간을 h264 mp4 로 받아 (경로, 제목, 채널명) 반환.

    - h264(avc1)+aac 우선 → pycapcut/CapCut 호환(AV1/VP9 함정 회피). 안 되면 best.
    - 받은 게 h264 아니면 ffmpeg 로 h264 재인코딩.
    - 채널명(uploader)도 함께 받아 출처 자동 입력에 사용.
    """
    url, start, end = url.strip(), start.strip(), end.strip()
    if not (url.startswith("http://") or url.startswith("https://")):
        raise ValueError("올바른 URL이 아닙니다.")
    if not valid_time(start) or not valid_time(end):
        raise ValueError("시간 형식 오류. 예) 03:30 또는 01:03:30")

    os.makedirs(out_dir, exist_ok=True)
    base_suffix = f"{start}-{end}".replace(":", "")

    def _dl(section: str, tag: str = "") -> Tuple[str, str, str]:
        """tag 는 재시도용 파일명 구분자. 제목에선 떼어내 드래프트 이름을 깨끗이 유지."""
        suffix = base_suffix + tag
        outtmpl = os.path.join(out_dir, f"%(title).80s_{suffix}.%(ext)s")
        cmd = [
            "yt-dlp", *_js_runtime_args(),
            "--download-sections", section,
            # h264(avc1) + aac 우선, 안되면 best
            "-f", "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/b",
            "--merge-output-format", "mp4", "--no-playlist",
            # 채널명(uploader) + 최종 경로 출력 (탭 구분, after_move 시점)
            "--no-simulate", "--print", "after_move:%(uploader)s\t%(filepath)s",
            "-o", outtmpl, url,
        ]
        # yt-dlp 가 한글 경로를 UTF-8 로 출력하도록 강제(Windows cp949 디코드 깨짐 방지)
        env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
        proc = subprocess.run(cmd, capture_output=True, text=True,
                              encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env)
        if proc.returncode != 0:
            tail = (proc.stderr or proc.stdout or "").strip()[-800:]
            raise RuntimeError(f"yt-dlp 실패:\n{tail}")

        # stdout 에서 채널명 파싱 (경로는 폴더 스캔으로 확정 — 인코딩 의존 제거)
        ch = ""
        for line in (proc.stdout or "").splitlines():
            if "\t" in line:
                ch = line.split("\t", 1)[0].strip()

        # 출력 폴더에서 suffix 매칭 최신 파일 = 결과물 (stdout 경로 인코딩에 의존 안 함)
        cands = [c for c in glob.glob(os.path.join(out_dir, f"*_{suffix}.*"))
                 if c.lower().endswith((".mp4", ".mkv", ".webm"))]
        if not cands:
            raise RuntimeError("다운로드 결과 파일을 찾지 못했습니다.")
        p = max(cands, key=os.path.getmtime)
        t = os.path.splitext(os.path.basename(p))[0]
        if tag and t.endswith(tag):
            t = t[: -len(tag)]
        if _video_codec(p) != "h264":     # CapCut 호환(AV1/VP9 회피)
            h264 = os.path.splitext(p)[0] + "_h264.mp4"
            _encode_h264(p, h264)
            _unlink(p)
            p = h264
        return p, t, ch

    # GOP 중간 컷(앞부분 초록) 검증 → 재시도 → 최후엔 앞 여유 붙여 로컬 재컷.
    # 상세는 cut_youtube_precise 주석 참고(같은 문제·같은 전략).
    last_lead = 0.0
    for attempt in range(1, DL_ATTEMPTS + 1):
        path, title, channel = _dl(f"*{start}-{end}", "" if attempt == 1 else f"r{attempt}")
        last_lead = _first_keyframe_sec(path)
        if last_lead <= KEYFRAME_TOL:
            if attempt > 1:
                REPAIR_LOG.append(f"{start}~{end}: 앞부분 초록 → 재다운로드로 해결")
            return path, title, channel
        _unlink(path)

    REPAIR_LOG.append(f"{start}~{end}: 앞부분 초록 {last_lead:.1f}s → 여유분 재다운로드 후 정밀 재컷")
    start_sec, end_sec = _hms_to_sec(start), _hms_to_sec(end)
    want = end_sec - start_sec
    lead = min(max(RECUT_LEAD, last_lead * 2 + 2.0), start_sec)
    if lead <= 0:
        raise RuntimeError(
            f"구간 앞부분이 계속 깨집니다(첫 키프레임 {last_lead:.2f}s). "
            "시작 시각을 조금 뒤로 옮겨 다시 시도하세요.")
    path, title, channel = _dl(f"*{_fmt_hms(start_sec - lead)}-{_fmt_hms(end_sec)}", "lead")
    recut = os.path.splitext(path)[0] + "_recut.mp4"
    _encode_h264(path, recut, ss=max(0.0, _duration(path) - want), t=want)
    _unlink(path)
    return recut, title, channel


def _concat_parts(parts: List[str], out_dir: str, key: str) -> str:
    """h264 mp4 조각들을 concat demuxer 로 재인코딩 병합 → 합친 파일 경로.

    ⚠ Windows ffmpeg 의 concat demuxer 는 목록 파일 '내부'의 non-ASCII(한글) 경로를
    열지 못한다("Impossible to open ... Invalid argument"). argv 로 직접 넘기는 경로는
    되지만 목록 안 경로는 유니코드 변환이 안 되기 때문. → 조각들을 ASCII 임시 이름
    (하드링크, 같은 볼륨이라 데이터 복사 없음)으로 가리켜 목록에 넣고, 병합 후 임시
    링크·목록만 지운다. 목록의 상대 basename 은 목록 파일이 있는 폴더 기준으로 해석된다.
    """
    list_txt = os.path.join(out_dir, f"_concat_{key}.txt")
    merged = os.path.join(out_dir, f"merged_{key}.mp4")
    tmp_links: List[str] = []
    try:
        with open(list_txt, "w", encoding="utf-8") as f:
            for i, p in enumerate(parts):
                ext = os.path.splitext(p)[1] or ".mp4"
                link = os.path.join(out_dir, f"_cpart_{key}_{i}{ext}")
                if os.path.abspath(link) == os.path.abspath(p):
                    name = os.path.basename(p)  # 이미 ASCII 임시명 (이론상 없음)
                else:
                    try:
                        if os.path.exists(link):
                            os.remove(link)
                        os.link(p, link)          # 하드링크 (데이터 복사 없음)
                    except OSError:
                        shutil.copy2(p, link)     # 폴백: 복사 (다른 볼륨 등)
                    tmp_links.append(link)
                    name = os.path.basename(link)
                f.write(f"file '{name}'\n")
        subprocess.run(
            ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
             "-f", "concat", "-safe", "0", "-i", list_txt,
             "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
             "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", merged],
            check=True, encoding="utf-8", errors="replace",
        )
        return merged
    finally:
        for link in tmp_links:
            try:
                os.remove(link)
            except OSError:
                pass
        try:
            os.remove(list_txt)
        except OSError:
            pass


def cut_youtube_multi(
    url: str,
    ranges: List[Tuple[str, str]],
    out_dir: str,
) -> Tuple[str, str, str]:
    """한 URL의 여러 [start,end] 구간을 각각 받아 순서대로 이어붙인 h264 mp4 반환.

    Returns: (합친영상경로, 제목, 채널명). 구간이 1개면 그대로 반환(병합 생략).
    각 구간은 cut_youtube 로 h264/aac 통일 → concat demuxer 재인코딩으로 안전 병합.
    """
    if not ranges:
        raise ValueError("구간이 하나도 없습니다.")
    os.makedirs(out_dir, exist_ok=True)

    parts: List[str] = []
    title = channel = ""
    for i, (s, e) in enumerate(ranges):
        p, t, ch = cut_youtube(url, s, e, out_dir)
        parts.append(p)
        if i == 0:
            title, channel = t, ch

    if len(parts) == 1:
        return parts[0], title, channel

    key = hashlib.sha1("|".join(f"{s}-{e}" for s, e in ranges).encode()).hexdigest()[:8]
    merged = _concat_parts(parts, out_dir, key)
    merged_title = f"{title}_{len(ranges)}구간" if title else f"merged_{key}"
    return merged, merged_title, channel


def _hms_to_sec(t: str) -> float:
    """MM:SS 또는 HH:MM:SS → 초. (구간 탭 입력 → 로컬 재컷 계산용)"""
    parts = [float(x) for x in t.strip().split(":")]
    sec = 0.0
    for p in parts:
        sec = sec * 60 + p
    return sec


def _fmt_hms(sec: float) -> str:
    """초(float) → HH:MM:SS.mmm (yt-dlp download-sections·정밀 컷용)."""
    sec = max(0.0, float(sec))
    h = int(sec // 3600)
    m = int((sec % 3600) // 60)
    s = sec % 60
    return f"{h:02d}:{m:02d}:{s:06.3f}"


def _dl_section(url: str, start_sec: float, end_sec: float, out_dir: str,
                tag: str = "") -> Tuple[str, str, str]:
    """[start_sec, end_sec] 구간 1회 다운로드 → (경로, 제목, 채널). h264 로 정규화.

    1) --force-keyframes-at-cuts (프레임 정확) → 2) 실패 시 키프레임 컷 폴백.
    tag 는 파일명 suffix 에 섞어 재시도 결과가 이전 파일과 안 섞이게 한다.
    """
    section = f"*{_fmt_hms(start_sec)}-{_fmt_hms(end_sec)}"
    suffix = hashlib.sha1((section + tag).encode()).hexdigest()[:10]
    outtmpl = os.path.join(out_dir, f"%(title).60s_{suffix}.%(ext)s")
    env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}

    def _run(extra):
        cmd = [
            "yt-dlp", *_js_runtime_args(),
            "--download-sections", section, *extra,
            "-f", "bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/b",
            "--merge-output-format", "mp4", "--no-playlist",
            "--no-simulate", "--print", "after_move:%(uploader)s\t%(filepath)s",
            "-o", outtmpl, url,
        ]
        return subprocess.run(cmd, capture_output=True, text=True,
                              encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env)

    proc = _run(["--force-keyframes-at-cuts"])
    if proc.returncode != 0:
        proc = _run([])
    if proc.returncode != 0:
        tail = (proc.stderr or proc.stdout or "").strip()[-800:]
        raise RuntimeError(f"yt-dlp 실패:\n{tail}")

    channel = ""
    for line in (proc.stdout or "").splitlines():
        if "\t" in line:
            channel = line.split("\t", 1)[0].strip()

    cands = [c for c in glob.glob(os.path.join(out_dir, f"*_{suffix}.*"))
             if c.lower().endswith((".mp4", ".mkv", ".webm"))]
    if not cands:
        raise RuntimeError("다운로드 결과 파일을 찾지 못했습니다.")
    path = max(cands, key=os.path.getmtime)
    title = os.path.splitext(os.path.basename(path))[0]

    if _video_codec(path) != "h264":     # CapCut 호환(AV1/VP9 회피)
        h264 = os.path.splitext(path)[0] + "_h264.mp4"
        _encode_h264(path, h264)
        _unlink(path)
        path = h264
    return path, title, channel


def cut_youtube_precise(url: str, start_sec: float, end_sec: float,
                        out_dir: str) -> Tuple[str, str, str]:
    """[start_sec, end_sec] (밀리초 정밀) 구간을 프레임 정확히·초록 없이 잘라 h264 mp4 로.

    yt-dlp 가 가끔 GOP 중간에서 잘라 앞부분이 깨진(초록) 파일을 준다. 그대로 두면
    병합 재인코딩 때 초록 화면이 구워지므로 매번 검증한다:
      1) 받은 파일의 첫 키프레임이 맨 앞이면 정상 → 그대로 사용
      2) 아니면 버리고 재다운로드 (DL_ATTEMPTS 회) — 간헐적 실패라 대개 여기서 해결
      3) 그래도 깨지면 앞에 RECUT_LEAD 초 여유를 붙여 받아 로컬에서 뒤쪽 want 초만
         재인코딩으로 잘라낸다. 깨진 앞부분은 버리는 여유 구간에 들어가므로 항상 깨끗하다.
    """
    url = url.strip()
    if not (url.startswith("http://") or url.startswith("https://")):
        raise ValueError("올바른 URL이 아닙니다.")
    if end_sec <= start_sec:
        raise ValueError("end 는 start 보다 커야 합니다.")

    os.makedirs(out_dir, exist_ok=True)
    want = end_sec - start_sec

    last_lead = 0.0
    for attempt in range(1, DL_ATTEMPTS + 1):
        path, title, channel = _dl_section(
            url, start_sec, end_sec, out_dir, tag="" if attempt == 1 else f"r{attempt}")
        last_lead = _first_keyframe_sec(path)
        if last_lead <= KEYFRAME_TOL:
            if attempt > 1:
                REPAIR_LOG.append(f"{start_sec:.1f}~{end_sec:.1f}s: 앞부분 초록 → 재다운로드로 해결")
            return path, title, channel
        _unlink(path)      # 앞 last_lead 초가 초록 → 버리고 다시

    # 최후 수단: 앞 여유 + 로컬 정밀 재컷 (여유가 깨져도 어차피 버리는 부분)
    REPAIR_LOG.append(
        f"{start_sec:.1f}~{end_sec:.1f}s: 앞부분 초록 {last_lead:.1f}s → 여유분 재다운로드 후 정밀 재컷")
    lead = min(max(RECUT_LEAD, last_lead * 2 + 2.0), start_sec)
    if lead <= 0:
        raise RuntimeError(
            f"구간 앞부분이 계속 깨집니다(첫 키프레임 {last_lead:.2f}s). "
            "시작 시각을 조금 뒤로 옮겨 다시 시도하세요.")
    path, title, channel = _dl_section(url, start_sec - lead, end_sec, out_dir, tag="lead")
    dur = _duration(path)
    recut = os.path.splitext(path)[0] + "_recut.mp4"
    _encode_h264(path, recut, ss=max(0.0, dur - want), t=want)   # 뒤에서 want 초만
    _unlink(path)
    return recut, title, channel


def download_paste_cuts(url: str, cuts: List[Tuple[float, float]],
                        out_dir: str) -> Tuple[str, str, str]:
    """붙여넣기 컷들(초 단위, 정밀)을 각각 정확히 잘라 순서대로 병합.

    Returns: (합친영상경로, 제목, 채널명). 1개면 병합 생략.
    """
    if not cuts:
        raise ValueError("컷이 하나도 없습니다.")
    os.makedirs(out_dir, exist_ok=True)
    parts: List[str] = []
    title = channel = ""
    for i, (s, e) in enumerate(cuts):
        p, t, ch = cut_youtube_precise(url, s, e, out_dir)
        parts.append(p)
        if i == 0:
            title, channel = t, ch
    if len(parts) == 1:
        return parts[0], title, channel
    key = hashlib.sha1("|".join(f"{s:.3f}-{e:.3f}" for s, e in cuts).encode()).hexdigest()[:8]
    merged = _concat_parts(parts, out_dir, key)
    return merged, (f"{title}_{len(cuts)}컷" if title else f"paste_{key}"), channel
