# -*- coding: utf-8 -*-
"""
FFmpeg 切分、去字幕、合并，与 Java ChunkSplitService / FFmpegService / ChunkMergeService 逻辑对齐。
"""
import logging
import os
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path
from typing import List, Optional, Tuple

logger = logging.getLogger(__name__)


class InvalidSourceError(RuntimeError):
    """源文件不存在、损坏或无法解析，应跳过该任务并继续处理其它任务。"""


# 硬件编码器检测缓存：Windows 优先 nvenc → qsv → amf，macOS 为 videotoolbox
_hw_encoder_cache: Optional[str] = None


def _subprocess_run_kw() -> dict:
    """Windows 下统一使用 utf-8 解码子进程输出，避免中文路径/输出导致解码异常或异常退出码。"""
    kw = dict(capture_output=True, text=True)
    if sys.platform == "win32":
        kw["encoding"] = "utf-8"
        kw["errors"] = "replace"
    return kw


def _get_hw_encoder() -> Optional[str]:
    """检测当前系统可用的硬件编码器，与 Java FFmpegService 逻辑一致。"""
    global _hw_encoder_cache
    if _hw_encoder_cache is not None:
        return _hw_encoder_cache if _hw_encoder_cache else None
    try:
        r = subprocess.run(
            [_find_ffmpeg(), "-hide_banner", "-encoders"],
            timeout=10,
            **_subprocess_run_kw(),
        )
        out = (r.stdout or "") + (r.stderr or "")
        if sys.platform == "darwin":
            if "h264_videotoolbox" in out:
                _hw_encoder_cache = "h264_videotoolbox"
        elif sys.platform == "win32":
            if "h264_nvenc" in out:
                _hw_encoder_cache = "h264_nvenc"
            elif "h264_qsv" in out:
                _hw_encoder_cache = "h264_qsv"
            elif "h264_amf" in out:
                _hw_encoder_cache = "h264_amf"
        if _hw_encoder_cache:
            logger.info("检测到硬件编码器: %s", _hw_encoder_cache)
        else:
            _hw_encoder_cache = ""
    except Exception as e:
        logger.warning("检测硬件编码器失败，将使用软件编码: %s", e)
        _hw_encoder_cache = ""
    return _hw_encoder_cache if _hw_encoder_cache else None


def _bitrate_mbps_from_crf(crf: int) -> int:
    """根据 CRF 换算码率(Mbps)，与 Java calculateBitrateFromCRF 一致，用于 GPU 编码。"""
    if crf <= 20:
        return 10
    if crf <= 23:
        return 6
    if crf <= 26:
        return 5
    return 4


def _find_ffmpeg() -> str:
    return os.environ.get("FFMPEG_PATH", "ffmpeg")


def _find_ffprobe() -> str:
    return os.environ.get("FFPROBE_PATH", "ffprobe")


def check_ffmpeg_available() -> Optional[str]:
    """
    检测 ffmpeg / ffprobe 是否可用。可用则返回 None，不可用则返回错误说明（含安装提示）。
    """
    missing = []
    for name, getter in [("ffmpeg", _find_ffmpeg), ("ffprobe", _find_ffprobe)]:
        path = getter()
        try:
            r = subprocess.run(
                [path, "-version"],
                timeout=5,
                **_subprocess_run_kw(),
            )
            if r.returncode != 0:
                missing.append(name)
        except (FileNotFoundError, OSError):
            missing.append(name)
    if not missing:
        return None
    return (
        "未检测到 {}，请先安装 FFmpeg（本程序不会自动安装系统依赖）。\n"
        "  macOS:    brew install ffmpeg\n"
        "  Ubuntu:   sudo apt install ffmpeg\n"
        "  Windows:  从 https://ffmpeg.org/download.html 下载或将 ffmpeg/ffprobe 加入 PATH\n"
        "  或设置环境变量: FFMPEG_PATH=... FFPROBE_PATH=..."
    ).format(" / ".join(missing))


def _format_time(seconds: float) -> str:
    if seconds < 0:
        return "0"
    if seconds < 3600:
        m = int(seconds // 60)
        s = seconds % 60
        return f"{m}:{s:05.2f}" if s != int(s) else f"{m}:{int(s):02d}"
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = seconds % 60
    return f"{h}:{m:02d}:{s:05.2f}" if s != int(s) else f"{h}:{m:02d}:{int(s):02d}"


def _ffprobe_error_message(video_path: str, stderr: str) -> str:
    """将 ffprobe 原始错误转为用户可读说明。"""
    err = (stderr or "").strip().lower()
    if "moov atom not found" in err or "invalid data found when processing input" in err:
        return f"视频文件损坏或未完整（可能未下载完、拷贝中断或格式异常）: {video_path}"
    return f"ffprobe 失败: {stderr or '(无输出)'}"


def get_duration(video_path: str) -> float:
    cmd = [
        _find_ffprobe(),
        "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path,
    ]
    r = subprocess.run(cmd, timeout=30, **_subprocess_run_kw())
    if r.returncode != 0:
        msg = _ffprobe_error_message(video_path, r.stderr or r.stdout or "")
        err = (r.stderr or r.stdout or "").strip().lower()
        if "moov atom not found" in err or "invalid data found when processing input" in err:
            raise InvalidSourceError(msg)
        raise RuntimeError(msg)
    line = (r.stdout or "").strip()
    if not line:
        return 0.0
    return float(line)


def get_video_size(video_path: str) -> Tuple[int, int]:
    """返回 (width, height)，用于去字幕滤镜。"""
    cmd = [
        _find_ffprobe(),
        "-v", "error",
        "-select_streams", "v:0",
        "-show_entries", "stream=width,height",
        "-of", "csv=p=0",
        video_path,
    ]
    r = subprocess.run(cmd, timeout=30, **_subprocess_run_kw())
    if r.returncode != 0:
        return 1920, 1080
    line = (r.stdout or "").strip()
    if not line:
        return 1920, 1080
    parts = line.split(",")
    if len(parts) >= 2:
        try:
            return int(parts[0]), int(parts[1])
        except ValueError:
            pass
    return 1920, 1080


def split_chunk(video_path: str, start_time: float, duration: float, output_path: str) -> None:
    cmd = [
        _find_ffmpeg(),
        "-ss", _format_time(start_time),
        "-i", video_path,
        "-t", _format_time(duration),
        "-c", "copy",
        "-avoid_negative_ts", "make_zero",
        "-y", output_path,
    ]
    run_ffmpeg(cmd)


def run_ffmpeg(cmd: List[str], timeout: Optional[int] = None) -> None:
    logger.debug("FFmpeg: %s", " ".join(cmd))
    run_kw = _subprocess_run_kw()
    run_kw["timeout"] = timeout or 86400
    p = subprocess.run(cmd, **run_kw)
    if p.returncode != 0:
        # Windows 上退出码 4294967295 为 -1 的无符号表示，常表示进程崩溃或未正常退出
        raw_code = p.returncode
        if sys.platform == "win32" and raw_code == 4294967295:
            raw_code = -1
        err_text = (p.stderr or p.stdout or "").strip()
        err_preview = err_text[:2000] if err_text else "(无输出)"
        hint = ""
        if raw_code == -1 and sys.platform == "win32":
            hint = (
                " 提示：退出码 -1 在 Windows 上常见原因："
                "① 路径或文件名含中文/特殊字符导致编码问题；"
                "② 杀毒软件拦截 FFmpeg；"
                "③ FFmpeg 依赖的 DLL 缺失。"
                "建议将 FFmpeg 与视频放在纯英文路径下重试，或在命令行直接执行相同命令查看具体报错。"
            )
        raise RuntimeError(f"FFmpeg 退出码 {raw_code}: {err_preview}{hint}")


def build_video_filter(config: dict, original_width: int, original_height: int) -> str:
    """与 Java FFmpegService buildVideoFilter 对齐：1080p 输入、低分辨率不放大、高分辨率填满 1080P 居中裁、裁区钳位。"""
    target_width = config.get("targetWidth", 1920)
    target_height = config.get("targetHeight", 1080)
    crop_bottom = config.get("cropBottom", 0)
    logo_crop_right = config.get("logoCropRight", 0)
    if target_width <= 0 or target_height <= 0:
        target_width, target_height = 1920, 1080
    cropped_height = original_height - crop_bottom
    cropped_width = (original_width - logo_crop_right) if logo_crop_right > 0 else original_width
    # 钳位与 Java 一致，避免 FFmpeg crop 报错
    if cropped_height <= 0 or cropped_height > original_height:
        cropped_height = original_height
    if cropped_width <= 0 or cropped_width > original_width:
        cropped_width = original_width

    if original_width == 1920 and original_height == 1080:
        filters = []
        if crop_bottom > 0 or logo_crop_right > 0:
            cw = cropped_width if logo_crop_right > 0 else original_width
            ch = cropped_height if crop_bottom > 0 else original_height
            if cw < original_width or ch < original_height:
                filters.append(f"crop={cw}:{ch}:0:0")
                current_w, current_h = cw, ch
            else:
                current_w, current_h = original_width, original_height
        else:
            current_w, current_h = original_width, original_height
        if logo_crop_right > 0:
            filters.append(f"scale={target_width}:{target_height}:force_original_aspect_ratio=increase")
            filters.append(f"crop={target_width}:{target_height}:(iw-{target_width})/2:(ih-{target_height})/2")
        else:
            aspect = current_w / current_h if current_h else 16 / 9
            if abs(aspect - 16 / 9) > 0.01:
                final_w = int(current_h * 16 / 9)
                crop_x = (current_w - final_w) // 2
                filters.append(f"crop={final_w}:{current_h}:{crop_x}:0")
                current_w = final_w
            if current_w != target_width or current_h != target_height:
                filters.append(f"scale={target_width}:{target_height}")
        return ",".join(filters) if filters else "null"

    # 低分辨率（如 720p）：若无台标则只裁底+左右成 16:9 不放大；若有台标则裁底+裁右后填满 1080P 再居中裁
    if cropped_height <= 1080 and original_width <= 1920:
        if logo_crop_right > 0:
            cw, ch = cropped_width, cropped_height
            if cw <= 0 or ch <= 0:
                cw, ch = max(1, original_width), max(1, original_height)
            return (
                f"crop={cw}:{ch}:0:0,"
                f"scale={target_width}:{target_height}:force_original_aspect_ratio=increase,"
                f"crop={target_width}:{target_height}:(iw-{target_width})/2:(ih-{target_height})/2"
            )
        # 无台标：仅裁底+左右成 16:9，不放大到 1080p（与 Java buildFilterForLowResInput 一致）
        if original_width < 1 or original_height < 1:
            return f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease"
        ch = cropped_height
        if ch <= 0:
            ch = original_height
        if ch > original_height:
            ch = original_height
        final_w = int(ch * 16 / 9)
        if final_w > original_width:
            final_w = original_width
        crop_x = (original_width - final_w) // 2
        if crop_x < 0:
            crop_x = 0
        parts = [f"crop={original_width}:{ch}:0:0"]
        if final_w < original_width:
            parts.append(f"crop={final_w}:{ch}:{crop_x}:0")
        logger.info("低分辨率输入，保持原分辨率不放大: 输出 %dx%d", final_w, ch)
        return ",".join(parts) if parts else "null"

    # 字幕=0 且 台标=0 的高分辨率（如无字幕无台标 4K）：不裁底不裁右，等比填满 1080P 再居中裁，无黑边、不变形
    if crop_bottom == 0 and logo_crop_right == 0:
        return (
            f"scale={target_width}:{target_height}:force_original_aspect_ratio=increase,"
            f"crop={target_width}:{target_height}:(iw-{target_width})/2:(ih-{target_height})/2"
        )

    # 4K/高分辨率（有裁底或裁右）：裁底+裁右(台标) -> 等比填满 1080P -> 居中裁，无黑边、不变形
    return (
        f"crop={cropped_width}:{cropped_height}:0:0,"
        f"scale={target_width}:{target_height}:force_original_aspect_ratio=increase,"
        f"crop={target_width}:{target_height}:(iw-{target_width})/2:(ih-{target_height})/2"
    )


def build_desubtitle_command(config: dict, input_path: str, output_path: str) -> List[str]:
    """构建去字幕 FFmpeg 命令。若 config.useHardwareAccel 且检测到 GPU 编码器则用硬件编码，否则 libx264。
    与 Java FFmpegService.buildCommand 对齐：字幕高度为 0 且原视频 1080P 时仅时间截断、流复制。"""
    start_time = config.get("startTime", 0) or 0
    end_time = config.get("endTime", 0) or 0
    keep_audio = config.get("keepAudio", True)
    audio_bitrate = config.get("audioBitrate", 192)
    video_quality = config.get("videoQuality", 23)
    original_width = config.get("originalWidth", 0) or 1920
    original_height = config.get("originalHeight", 0) or 1080
    crop_bottom = config.get("cropBottom", 0)
    force_keyframe = config.get("forceKeyframeAtStart", False)
    use_hw = config.get("useHardwareAccel", False)

    cmd = [_find_ffmpeg()]
    if start_time > 0:
        cmd += ["-ss", _format_time(start_time)]
    cmd += ["-i", input_path]
    if end_time > 0 and start_time >= 0:
        duration = end_time - start_time
        if duration > 0:
            cmd += ["-t", _format_time(duration)]
    elif end_time > 0:
        cmd += ["-t", _format_time(end_time)]

    # 字幕高度为 0 且原视频明确为 1080P：仅时间截断，流复制（尺寸未设置时不假定 1080p，与 Java 一致）
    if crop_bottom == 0 and config.get("originalWidth") == 1920 and config.get("originalHeight") == 1080:
        cmd += ["-map", "0:v:0"]
        if keep_audio:
            cmd += ["-map", "0:a:0"]
        cmd += ["-c", "copy", "-y", output_path]
        logger.info("字幕高度为 0 且原视频为 1080P：仅时间截断，流复制输出")
        return cmd

    vf = build_video_filter(config, original_width, original_height)
    if vf and vf != "null":
        cmd += ["-vf", vf]

    hw_encoder = _get_hw_encoder() if use_hw else None
    if hw_encoder:
        bitrate_mbps = _bitrate_mbps_from_crf(int(video_quality))
        cmd += ["-c:v", hw_encoder, "-b:v", f"{bitrate_mbps}M"]
        if hw_encoder == "h264_nvenc":
            cmd += ["-preset", "p4"]
            if force_keyframe:
                cmd += ["-forced-idr", "1"]  # 确保首帧为 IDR，concat 时边界不花屏
        if hw_encoder == "h264_videotoolbox":
            cmd += ["-allow_sw", "1"]
    else:
        cmd += [
            "-c:v", "libx264", "-preset", "fast", "-crf", str(video_quality),
            "-maxrate", "6M", "-bufsize", "12M",
        ]

    if force_keyframe:
        cmd += ["-force_key_frames", "expr:eq(n,0)"]
    if keep_audio:
        cmd += ["-c:a", "aac", "-b:a", f"{audio_bitrate}k", "-ac", "2"]
    else:
        cmd += ["-an"]
    cmd += ["-y", output_path]
    return cmd


def run_desubtitle(config: dict, input_path: str, output_path: str, progress_callback=None) -> bool:
    cmd = build_desubtitle_command(config, input_path, output_path)
    run_ffmpeg(cmd)
    return True


def merge_with_concat(filelist_path: str, output_path: str) -> None:
    cmd = [
        _find_ffmpeg(),
        "-f", "concat", "-safe", "0",
        "-i", filelist_path,
        "-c", "copy",
        "-y", output_path,
    ]
    run_ffmpeg(cmd)


def merge_with_concat_reencode(
    filelist_path: str,
    trim_start_sec: float,
    duration_sec: float,
    output_path: str,
) -> None:
    """合并后按时间裁切并重新编码为单一路流，避免 concat+copy 在边界/trim 处花屏、卡屏。"""
    cmd = [
        _find_ffmpeg(),
        "-f", "concat", "-safe", "0",
        "-i", filelist_path,
        "-ss", _format_time(trim_start_sec),
        "-t", _format_time(duration_sec),
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "192k", "-ac", "2",
        "-y", output_path,
    ]
    run_ffmpeg(cmd)


def trim_video(input_path: str, start_offset: float, duration: float, output_path: str) -> None:
    cmd = [
        _find_ffmpeg(),
        "-i", input_path,
        "-ss", _format_time(start_offset),
        "-t", _format_time(duration),
        "-c", "copy",
        "-y", output_path,
    ]
    run_ffmpeg(cmd)


def split_video_to_chunks(
    video_path: str,
    output_dir: str,
    chunk_size_sec: float,
    range_start: float,
    range_end: float,
) -> Tuple[dict, str]:
    """
    切分视频为多个 chunk，写入 output_dir/<video_id>/，返回 (metadata_dict, video_id)。
    """
    path = Path(video_path)
    if not path.exists():
        raise InvalidSourceError(f"视频不存在: {video_path}")
    duration = get_duration(video_path)
    if duration <= 0:
        raise InvalidSourceError(f"无法获取视频时长: {video_path}")

    start_sec = max(0, range_start)
    end_sec = min(duration, range_end) if range_end > 0 else duration
    if start_sec >= end_sec:
        raise ValueError("开始时间必须小于结束时间")
    effective = end_sec - start_sec

    video_id = path.stem + "_" + uuid.uuid4().hex[:8]
    chunk_dir = Path(output_dir) / video_id
    original_dir = chunk_dir / "original"
    original_dir.mkdir(parents=True, exist_ok=True)

    total_chunks = int(__import__("math").ceil(effective / chunk_size_sec))
    chunks = []
    for i in range(total_chunks):
        ch_start = start_sec + i * chunk_size_sec
        ch_end = min(start_sec + (i + 1) * chunk_size_sec, end_sec)
        ch_duration = ch_end - ch_start
        chunk_id = f"chunk_{i:03d}"
        out_path = original_dir / f"{chunk_id}.mp4"
        split_chunk(video_path, ch_start, ch_duration, str(out_path))
        rel_path = f"{video_id}/original/{chunk_id}.mp4"
        chunks.append({
            "chunkId": chunk_id,
            "startTime": ch_start,
            "endTime": ch_end,
            "originalPath": rel_path,
            "processedPath": "",
            "status": "pending",
            "processedAt": "",
            "errorMessage": "",
        })
        logger.info("切分完成: %s (%.1f - %.1f秒)", chunk_id, ch_start, ch_end)

    _now = __import__("datetime").datetime.utcnow()
    metadata = {
        "videoId": video_id,
        "originalPath": video_path,
        "chunkSize": chunk_size_sec,
        "totalChunks": total_chunks,
        "chunks": chunks,
        "createdAt": _now.isoformat() + "Z",
        "splitStartedAt": _now.isoformat() + "Z",
    }
    meta_path = chunk_dir / "metadata.json"
    with open(meta_path, "w", encoding="utf-8") as f:
        __import__("json").dump(metadata, f, indent=2, ensure_ascii=False)
    metadata["_metadataPath"] = str(meta_path)
    return metadata, video_id


def merge_chunks(metadata: dict, start_time: float, end_time: float, output_path: str) -> bool:
    """合并已处理的 chunk（按 startTime 排序，concat + 可选 trim）。processedPath 支持相对路径（相对 output_dir/video_id）或绝对路径。"""
    chunks = metadata.get("chunks") or []
    processed = [c for c in chunks if c.get("status") == "processed" and c.get("processedPath")]
    out_path = Path(output_path)
    video_id = metadata.get("videoId") or ""
    chunk_dir = out_path.parent / video_id if video_id else out_path.parent

    def resolve_path(c: dict) -> Path:
        raw = c["processedPath"]
        p = Path(raw)
        if p.is_absolute():
            return p.resolve()
        return (chunk_dir / raw).resolve()

    missing = [c for c in processed if not resolve_path(c).exists()]
    if missing:
        missing_info = ", ".join(f"{c.get('chunkId', '?')}={resolve_path(c)}" for c in missing)
        raise ValueError(
            f"有 {len(missing)} 个已标记为已处理的 chunk 文件不存在，无法合成完整视频，请检查去字幕是否全部完成。缺失: {missing_info}"
        )
    if not processed:
        raise ValueError("没有可用的已处理小块")
    processed.sort(key=lambda c: c["startTime"])

    first_start = processed[0]["startTime"]
    trim_start = max(0, start_time - first_start)
    exact_duration = end_time - start_time

    # 默认 concat+copy 以保持合并速度；若出现花屏/卡屏可设 VIDEOCONVERTER_MERGE_REENCODE=1 改为重新编码合并
    use_reencode = os.environ.get("VIDEOCONVERTER_MERGE_REENCODE", "0").strip().lower() in ("1", "true", "yes")

    with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
        for c in processed:
            p = resolve_path(c)
            path_str = str(p).replace("'", "'\\''")
            f.write(f"file '{path_str}'\n")
        list_path = f.name
    try:
        if use_reencode:
            # 合并时重新编码为单一路流，避免 concat+copy 在 chunk 边界/trim 处花屏、卡屏
            merge_with_concat_reencode(list_path, trim_start, exact_duration, str(out_path))
        else:
            tmp_concat = out_path.parent / f"chunk_merge_{os.getpid()}.mp4"
            tmp_trim = out_path.parent / f"chunk_trim_{os.getpid()}.mp4"
            try:
                merge_with_concat(list_path, str(tmp_concat))
                trim_video(str(tmp_concat), trim_start, exact_duration, str(tmp_trim))
                tmp_trim.replace(out_path)
            finally:
                tmp_concat.unlink(missing_ok=True)
                tmp_trim.unlink(missing_ok=True)
        return True
    finally:
        os.unlink(list_path)
