"""FFmpeg engine — all video processing operations."""

from __future__ import annotations

import base64
import json
import math
import os
import re
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Callable

from .errors import (
    MCPVideoError,
    CodecError,
    FFmpegNotFoundError,
    FFprobeNotFoundError,
    InputFileError,
    ProcessingError,
    ResolutionMismatchError,
    parse_ffmpeg_error,
)
from .models import (
    ASPECT_RATIOS,
    PREVIEW_PRESETS,
    QUALITY_PRESETS,
    ColorPreset,
    EditResult,
    ErrorResult,
    ExportFormat,
    FilterType,
    ImageSequenceResult,
    MetadataResult,
    NamedPosition,
    Position,
    QualityLevel,
    QualityMetricsResult,
    SceneDetectionResult,
    SplitLayout,
    StoryboardResult,
    SubtitleResult,
    ThumbnailResult,
    Timeline,
    TimelineClip,
    TimelineImageOverlay,
    VideoInfo,
    WatermarkSettings,
    WaveformResult,
)


# ---------------------------------------------------------------------------
# FFmpeg / FFprobe availability
# ---------------------------------------------------------------------------

def _find_executable(name: str) -> str:
    path = shutil.which(name)
    if path is None:
        raise FFmpegNotFoundError() if name == "ffmpeg" else FFprobeNotFoundError()
    return path


_FFMPEG = _FFPROBE = ""
_AVAILABLE_FILTERS: set[str] | None = None


def _ffmpeg() -> str:
    global _FFMPEG
    if not _FFMPEG:
        _FFMPEG = _find_executable("ffmpeg")
    return _FFMPEG


def _ffprobe() -> str:
    global _FFPROBE
    if not _FFPROBE:
        _FFPROBE = _find_executable("ffprobe")
    return _FFPROBE


def _check_filter_available(name: str) -> bool:
    """Check if an FFmpeg filter is available."""
    global _AVAILABLE_FILTERS
    if _AVAILABLE_FILTERS is None:
        proc = subprocess.run(
            [_ffmpeg(), "-filters"],
            capture_output=True, text=True, timeout=10,
        )
        _AVAILABLE_FILTERS = set()
        for line in proc.stdout.split("\n"):
            parts = line.strip().split()
            if len(parts) >= 3 and "->" in parts[2]:
                _AVAILABLE_FILTERS.add(parts[1])
    return name in _AVAILABLE_FILTERS


def _require_filter(name: str, feature: str) -> None:
    """Raise an error if a required FFmpeg filter is not available."""
    if not _check_filter_available(name):
        raise MCPVideoError(
            f"FFmpeg filter '{name}' is not available. {feature} requires FFmpeg "
            f"to be compiled with additional libraries.\n"
            f"Install with: brew install ffmpeg (macOS) or rebuild FFmpeg with "
            f"libfreetype/libass support.",
            error_type="dependency_error",
            code=f"missing_filter_{name}",
            suggested_action={
                "auto_fix": False,
                "description": f"Reinstall FFmpeg with {name} support. "
                               "On macOS: brew reinstall ffmpeg",
            },
        )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _validate_input(path: str) -> None:
    if not os.path.isfile(path):
        raise InputFileError(path)


_CSS_COLOR_NAMES = frozenset({
    "white", "black", "red", "green", "blue", "yellow", "cyan", "magenta",
    "orange", "purple", "pink", "brown", "gray", "grey", "silver", "gold",
    "navy", "teal", "maroon", "olive", "lime", "aqua", "fuchsia", "indigo",
    "violet", "coral", "salmon", "tomato", "khaki", "lavender", "turquoise",
    "tan", "wheat", "ivory", "beige", "linen", "snow", "mintcream", "azure",
    "aliceblue", "ghostwhite", "honeydew", "seashell", "whitesmoke",
    "oldlace", "floralwhite", "cornsilk", "lemonchiffon", "lightyellow",
    "lightcyan", "paleturquoise", "powderblue", "lightblue", "skyblue",
    "lightskyblue", "steelblue", "dodgerblue", "deepskyblue",
    "cornflowerblue", "royalblue", "mediumblue", "darkblue", "midnightblue",
    "slateblue", "darkslateblue", "mediumpurple", "blueviolet", "darkviolet",
    "darkorchid", "mediumorchid", "orchid", "plum", "mediumvioletred",
    "palevioletred", "hotpink", "deeppink", "lightpink", "rosybrown",
    "indianred", "firebrick", "darkred", "crimson", "orangered",
    "lightsalmon", "darksalmon", "lightcoral", "peachpuff", "bisque",
    "moccasin", "navajowhite", "sandybrown", "chocolate", "saddlebrown",
    "sienna", "burlywood", "peru", "darkgoldenrod", "goldenrod",
    "lightgoldenrod", "darkkhaki", "chartreuse", "greenyellow",
    "springgreen", "mediumspringgreen", "lawngreen", "darkgreen",
    "forestgreen", "seagreen", "darkseagreen", "lightgreen", "palegreen",
    "limegreen", "greenyellow",
})

_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
_FFMPEG_SPECIAL_CHARS = set(":=;'[]\\")


def _validate_color(color: str) -> None:
    """Validate a color value to prevent FFmpeg filter injection.

    Accepts CSS named colors (whitelist) and hex colors (#RGB, #RRGGBB, #RRGGBBAA).
    Rejects anything containing FFmpeg special characters.
    """
    if not isinstance(color, str):
        raise MCPVideoError(
            "invalid_color: value must be a string",
            error_type="validation_error", code="invalid_color",
        )
    if any(c in _FFMPEG_SPECIAL_CHARS for c in color):
        raise MCPVideoError(
            "invalid_color: contains FFmpeg special characters",
            error_type="validation_error", code="invalid_color",
        )
    if color.lower() in _CSS_COLOR_NAMES:
        return
    if _HEX_COLOR_RE.match(color):
        return
    raise MCPVideoError(
        "invalid_color: not a recognized CSS name or hex color",
        error_type="validation_error", code="invalid_color",
    )


def _auto_output(input_path: str, suffix: str = "edited", ext: str | None = None) -> str:
    base, original_ext = os.path.splitext(input_path)
    ext = ext or original_ext or ".mp4"
    # Sanitize colons in base path — they break FFmpeg filter syntax
    # and are problematic on Windows
    safe_base = base.replace(":", "_")
    return f"{safe_base}_{suffix}{ext}"


def _validate_path(path: str, must_exist: bool = True) -> str:
    """Validate and resolve a file path. Rejects null bytes and resolves to absolute."""
    if "\x00" in path:
        raise InputFileError(path, "Path contains null bytes")
    resolved = os.path.realpath(path)
    if must_exist and not os.path.isfile(resolved):
        raise InputFileError(resolved)
    return resolved


def _escape_ffmpeg_filter_value(value: str) -> str:
    """Escape special characters for FFmpeg filter expressions (subtitles, drawtext, etc.)."""
    return (
        value.replace("\\", "/")
        .replace("'", "'\\''")
        .replace(":", "\\:")
        .replace("[", "\\[")
        .replace("]", "\\]")
        .replace(",", "\\,")
    )


def _auto_output_dir(input_path: str, suffix: str = "output") -> str:
    base, _ = os.path.splitext(input_path)
    safe_base = base.replace(":", "_")
    return f"{safe_base}_{suffix}"


def _run_ffmpeg(args: list[str]) -> subprocess.CompletedProcess[str]:
    cmd = [_ffmpeg(), "-y"] + args
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        timeout=600,  # 10-minute max
    )
    if proc.returncode != 0:
        raise parse_ffmpeg_error(proc.stderr)
    return proc


def _parse_ffmpeg_time(time_str: str) -> float:
    """Parse FFmpeg time= value (HH:MM:SS.xx) to seconds."""
    m = re.match(r"(\d+):(\d+):(\d+)\.(\d+)", time_str)
    if not m:
        return 0.0
    return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) + int(m.group(4)) / 100


_TIME_RE = re.compile(r"time=(\d+:\d+:\d+\.\d+)")


def _run_ffmpeg_with_progress(
    args: list[str],
    estimated_duration: float | None = None,
    on_progress: Callable[[float], None] | None = None,
) -> subprocess.CompletedProcess[str]:
    """Run FFmpeg with real-time progress reporting.

    Parses FFmpeg stderr for time= output and calls on_progress(percent).
    Falls back to _run_ffmpeg if estimated_duration is not provided.
    """
    if estimated_duration is None or estimated_duration <= 0 or on_progress is None:
        return _run_ffmpeg(args)

    cmd = [_ffmpeg(), "-y"] + args
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )

    stderr_lines: list[str] = []
    try:
        while True:
            line = proc.stderr.readline()
            if not line:
                break
            stderr_lines.append(line)

            match = _TIME_RE.search(line)
            if match:
                current_time = _parse_ffmpeg_time(match.group(1))
                pct = min(100.0, (current_time / estimated_duration) * 100)
                on_progress(pct)
    finally:
        proc.wait()

    stderr = "".join(stderr_lines)
    if proc.returncode != 0:
        raise parse_ffmpeg_error(stderr)

    # Report 100% on success
    on_progress(100.0)

    return subprocess.CompletedProcess(
        cmd, proc.returncode, proc.stdout.read(), stderr,
    )


def _generate_thumbnail_base64(video_path: str) -> str | None:
    """Generate a base64-encoded JPEG thumbnail from the first frame of a video.

    Returns base64 string or None if generation fails.
    """
    try:
        tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
        tmp_path = tmp.name
        tmp.close()

        proc = subprocess.run(
            [
                _ffmpeg(), "-y",
                "-i", video_path,
                "-vframes", "1",
                "-q:v", "5",
                "-vf", "scale=320:-1",
                tmp_path,
            ],
            capture_output=True,
            text=True,
            timeout=15,
        )

        if proc.returncode != 0 or not os.path.isfile(tmp_path):
            return None

        with open(tmp_path, "rb") as f:
            data = f.read()
        os.unlink(tmp_path)
        return base64.b64encode(data).decode("ascii")
    except Exception:
        return None


def _run_ffprobe_json(path: str) -> dict[str, Any]:
    cmd = [
        _ffprobe(),
        "-v", "quiet",
        "-print_format", "json",
        "-show_format",
        "-show_streams",
        path,
    ]
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    if proc.returncode != 0:
        raise InputFileError(path, f"FFprobe failed: {proc.stderr[:200]}")
    return json.loads(proc.stdout)


def _movflags_args(output_path: str) -> list[str]:
    """Return -movflags +faststart only for MP4/MOV containers."""
    ext = os.path.splitext(output_path)[1].lower()
    if ext in (".mp4", ".mov"):
        return ["-movflags", "+faststart"]
    return []


def _quality_args(
    crf: int | None = None,
    preset: str | None = None,
    default_crf: int = 23,
    default_preset: str = "fast",
) -> list[str]:
    """Build FFmpeg quality args [-preset, X, -crf, Y].

    If crf or preset are provided, they override the defaults.
    """
    return ["-preset", preset or default_preset, "-crf", str(crf if crf is not None else default_crf)]


def _validate_position(position: Position) -> None:
    """Validate position dict values to prevent FFmpeg filter injection.

    Only validates when position is a dict; named strings are safe by design.
    """
    if not isinstance(position, dict):
        return
    if "x_pct" in position and "y_pct" in position:
        for key in ("x_pct", "y_pct"):
            val = position[key]
            if not isinstance(val, (int, float)) or isinstance(val, bool):
                raise InputFileError(path="", reason="Invalid position value")
            if not (0.0 <= float(val) <= 1.0):
                raise InputFileError(path="", reason="Invalid position value")
    elif "x" in position and "y" in position:
        for key in ("x", "y"):
            val = position[key]
            if not isinstance(val, (int, float)) or isinstance(val, bool):
                raise InputFileError(path="", reason="Invalid position value")
    else:
        raise MCPVideoError(
            "Position dict must have 'x'+'y' (pixels) or 'x_pct'+'y_pct' (percentage)",
            code="invalid_position_dict",
        )


def _position_coords(position: Position, width: int = 0, height: int = 0) -> str:
    """Return drawtext x,y expression for a named position or dict coords.

    Accepts:
    - Named positions: "top-left", "top-center", etc.
    - Pixel coordinates: {"x": 100, "y": 50}
    - Percentage: {"x_pct": 0.5, "y_pct": 0.5}
    """
    _validate_position(position)
    if isinstance(position, dict):
        if "x_pct" in position and "y_pct" in position:
            x_pct = position["x_pct"]
            y_pct = position["y_pct"]
            return f"x=w*{x_pct}-text_w/2:y=h*{y_pct}-text_h/2"
        elif "x" in position and "y" in position:
            return f"x={position['x']}:y={position['y']}"
        else:
            raise MCPVideoError(
                "Position dict must have 'x'+'y' (pixels) or 'x_pct'+'y_pct' (percentage)",
                code="invalid_position_dict",
            )

    # These expressions use FFmpeg's text_w/text_h variables
    mapping: dict[NamedPosition, str] = {
        "top-left": "x=10:y=10",
        "top-center": "x=(w-text_w)/2:y=10",
        "top-right": f"x=w-text_w-10:y=10",
        "center-left": "x=10:y=(h-text_h)/2",
        "center": "x=(w-text_w)/2:y=(h-text_h)/2",
        "center-right": "x=w-text_w-10:y=(h-text_h)/2",
        "bottom-left": "x=10:y=h-text_h-10",
        "bottom-center": "x=(w-text_w)/2:y=h-text_h-10",
        "bottom-right": "x=w-text_w-10:y=h-text_h-10",
    }
    return mapping.get(position, mapping["center"])


def _resolve_position(
    position: Position,
    position_map: dict[NamedPosition, str],
    default: NamedPosition = "center",
) -> str:
    """Resolve a Position (named or dict) to an FFmpeg overlay coordinate string.

    Used by watermark, overlay_video, and similar overlay-based operations.
    """
    _validate_position(position)
    if isinstance(position, dict):
        if "x_pct" in position and "y_pct" in position:
            x_pct = position["x_pct"]
            y_pct = position["y_pct"]
            return f"(main_w*{x_pct}-overlay_w/2):(main_h*{y_pct}-overlay_h/2)"
        elif "x" in position and "y" in position:
            return f"{position['x']}:{position['y']}"
        else:
            raise MCPVideoError(
                "Position dict must have 'x'+'y' (pixels) or 'x_pct'+'y_pct' (percentage)",
                code="invalid_position_dict",
            )
    return position_map.get(position, position_map[default])


def _default_font() -> str:
    """Return a sensible default font path for the current OS."""
    import platform
    system = platform.system()
    if system == "Darwin":
        return "/System/Library/Fonts/Helvetica.ttc"
    elif system == "Windows":
        return "C\\:/Windows/Fonts/arial.ttf"
    else:
        # Linux — check common locations
        for p in [
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
            "/usr/share/fonts/TTF/DejaVuSans.ttf",
            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
        ]:
            if os.path.isfile(p):
                return p
        return "DejaVu Sans"


def _get_video_stream(data: dict) -> dict | None:
    for s in data.get("streams", []):
        if s.get("codec_type") == "video":
            return s
    return None


def _get_audio_stream(data: dict) -> dict | None:
    for s in data.get("streams", []):
        if s.get("codec_type") == "audio":
            return s
    return None


def _has_audio(data: dict) -> bool:
    return _get_audio_stream(data) is not None


# ---------------------------------------------------------------------------
# Probe
# ---------------------------------------------------------------------------

def probe(path: str) -> VideoInfo:
    """Get metadata about a video file using ffprobe."""
    _validate_input(path)
    data = _run_ffprobe_json(path)

    vs = _get_video_stream(data)
    if vs is None:
        raise InputFileError(path, "No video stream found")

    # Duration
    duration = float(data.get("format", {}).get("duration", 0) or vs.get("duration", 0))

    # Resolution
    width = int(vs.get("width", 0))
    height = int(vs.get("height", 0))

    # FPS — r_frame_rate is "num/den"
    rfr = vs.get("r_frame_rate", "30/1")
    try:
        if "/" in rfr:
            num, den = rfr.split("/")
            den_val = float(den)
            fps = float(num) / den_val if den_val != 0 else 30.0
        else:
            fps = float(rfr) if float(rfr) != 0 else 30.0
    except (ValueError, ZeroDivisionError):
        fps = 30.0

    # Codecs
    codec = vs.get("codec_name", "unknown")
    audio_s = _get_audio_stream(data)
    audio_codec = audio_s.get("codec_name") if audio_s else None
    audio_sr = int(audio_s.get("sample_rate", 0)) if audio_s else None

    # Bitrate / size
    fmt = data.get("format", {})
    bitrate = int(fmt.get("bit_rate", 0)) or None
    size_bytes = int(fmt.get("size", 0)) or None
    fmt_name = fmt.get("format_name")

    return VideoInfo(
        path=path,
        duration=duration,
        width=width,
        height=height,
        fps=fps,
        codec=codec,
        audio_codec=audio_codec,
        audio_sample_rate=audio_sr,
        bitrate=bitrate,
        size_bytes=size_bytes,
        format=fmt_name,
    )


def get_duration(path: str) -> float:
    """Get duration of a video in seconds."""
    return probe(path).duration


# ---------------------------------------------------------------------------
# Normalize — convert to H.264/AAC for editing
# ---------------------------------------------------------------------------

def normalize(input_path: str, output_path: str | None = None) -> str:
    """Normalize a video to H.264 video + AAC audio for reliable editing."""
    _validate_input(input_path)
    output = output_path or _auto_output(input_path, "normalized")
    _run_ffmpeg([
        "-i", input_path,
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])
    return output


# ---------------------------------------------------------------------------
# Core operations
# ---------------------------------------------------------------------------

def trim(
    input_path: str,
    start: str | float = 0,
    duration: str | float | None = None,
    end: str | float | None = None,
    output_path: str | None = None,
) -> EditResult:
    """Trim a video by start time and duration or end time."""
    _validate_input(input_path)
    output = output_path or _auto_output(input_path, "trimmed")

    args = []
    if start:
        args.extend(["-ss", str(start)])
    args.extend(["-i", input_path])
    if duration:
        args.extend(["-t", str(duration)])
    elif end:
        args.extend(["-to", str(end)])
    args.extend([
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])
    _run_ffmpeg(args)

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="trim",
    )


def merge(
    clips: list[str],
    output_path: str | None = None,
    transition: str | None = None,
    transitions: list[str] | None = None,
    transition_duration: float = 1.0,
) -> EditResult:
    """Merge multiple clips into one video. Auto-normalizes if needed.

    Args:
        clips: List of video file paths.
        output_path: Output file path.
        transition: Single transition type for all clip pairs (backward compat).
        transitions: Per-pair transition types (one per boundary, len = len(clips)-1).
            If shorter than clip pairs, the last type is repeated.
        transition_duration: Duration of each transition in seconds.
    """
    if not clips:
        raise InputFileError("", "No clips provided for merge")

    for c in clips:
        _validate_input(c)

    # Check if all clips have same resolution — if not, normalize
    infos = [probe(c) for c in clips]
    resolutions = {i.resolution for i in infos}
    codecs = {i.codec for i in infos}

    needs_normalize = len(resolutions) > 1 or len(codecs) > 1
    target_w = max(i.width for i in infos)
    target_h = max(i.height for i in infos)

    working_clips: list[str] = []
    tmpdir = tempfile.mkdtemp(prefix="mcp_video_")

    try:
        if needs_normalize:
            for i, clip in enumerate(clips):
                norm_path = os.path.join(tmpdir, f"clip_{i:04d}.mp4")
                _run_ffmpeg([
                    "-i", clip,
                    "-vf", f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2",
                    "-c:v", "libx264", "-preset", "fast", "-crf", "23",
                    "-c:a", "aac", "-b:a", "128k",
                    "-r", "30",
                    "-ar", "44100", "-ac", "2",
                    norm_path,
                ])
                working_clips.append(norm_path)
        else:
            working_clips = list(clips)

        output = output_path or _auto_output(clips[0], "merged")

        # Resolve transition types list
        transition_types: list[str] | None = None
        if transitions and len(working_clips) > 1:
            transition_types = list(transitions)
        elif transition and len(working_clips) > 1:
            transition_types = [transition] * (len(working_clips) - 1)

        if transition_types and len(working_clips) > 1:
            # Use xfade filter for transitions
            _merge_with_transitions(working_clips, output, transition_types, transition_duration)
        else:
            # Simple concat
            concat_file = os.path.join(tmpdir, "concat.txt")
            with open(concat_file, "w") as f:
                for clip in working_clips:
                    # Escape single quotes for FFmpeg concat demuxer
                    abs_path = os.path.abspath(clip).replace("'", "'\\''")
                    f.write(f"file '{abs_path}'\n")
            _run_ffmpeg([
                "-f", "concat", "-safe", "0",
                "-i", concat_file,
                "-c", "copy",
            ] + _movflags_args(output) + [
                output,
            ])

    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="merge",
    )


def _merge_with_transitions(
    clips: list[str],
    output: str,
    transition_types: list[str],
    transition_duration: float,
) -> None:
    """Merge clips with xfade transitions between them.

    Args:
        transition_types: One transition type per clip pair (len = len(clips)-1).
            If shorter, the last type is repeated.
    """
    n = len(clips)
    if n < 2:
        _run_ffmpeg(["-i", clips[0], "-c", "copy", output])
        return

    # Pad transition_types if shorter than clip pairs
    pairs = n - 1
    if len(transition_types) < pairs:
        last = transition_types[-1] if transition_types else "fade"
        transition_types = transition_types + [last] * (pairs - len(transition_types))

    # xfade offset calculation
    offsets: list[float] = []
    cumulative = 0.0
    for i in range(pairs):
        clip_dur = get_duration(clips[i])
        if transition_duration >= clip_dur:
            raise MCPVideoError(
                f"Transition duration ({transition_duration}s) must be less than "
                f"clip {i+1} duration ({clip_dur:.1f}s)",
                code="transition_too_long",
            )
        cumulative += clip_dur - transition_duration
        offsets.append(cumulative)

    # Build complex filter
    inputs = []
    for clip in clips:
        inputs.extend(["-i", clip])

    # Build filter chain with per-pair transition types
    filter_parts = []
    labels: list[str] = []
    for i in range(n):
        labels.append(f"{i}:v")

    for i in range(pairs):
        in1 = labels[i]
        in2 = labels[i + 1]
        out = f"xt{i}" if i < pairs - 1 else "vout"
        xfade_type = transition_types[i].replace("-", "")
        filter_parts.append(f"[{in1}][{in2}]xfade=transition={xfade_type}:offset={offsets[i]:.3f}:duration={transition_duration:.3f}[{out}]")
        labels[i + 1] = out

    filter_str = ";".join(filter_parts)

    # Audio: only include if clips have audio streams
    has_audio = any(probe(c).audio_codec is not None for c in clips)
    if has_audio:
        audio_parts = []
        for i in range(n):
            audio_parts.append(f"[{i}:a]")
        audio_filter = "".join(audio_parts) + f"concat=n={n}:v=0:a=1[aout]"
        filter_complex = f"{filter_str};{audio_filter}"
        map_args = ["-map", "[vout]", "-map", "[aout]"]
        audio_codec_args = ["-c:a", "aac", "-b:a", "128k"]
    else:
        filter_complex = filter_str
        map_args = ["-map", "[vout]"]
        audio_codec_args = ["-an"]

    _run_ffmpeg(
        inputs + [
            "-filter_complex", filter_complex,
        ] + map_args + [
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        ] + audio_codec_args + _movflags_args(output) + [
            output,
        ]
    )


def add_text(
    input_path: str,
    text: str,
    position: Position = "top-center",
    font: str | None = None,
    size: int = 48,
    color: str = "white",
    shadow: bool = True,
    start_time: float | None = None,
    duration: float | None = None,
    output_path: str | None = None,
    crf: int | None = None,
    preset: str | None = None,
) -> EditResult:
    """Overlay text on a video."""
    _validate_input(input_path)
    _require_filter("drawtext", "Text overlay")
    _validate_color(color)
    output = output_path or _auto_output(input_path, "titled")

    coords = _position_coords(position)
    fontfile = font or _default_font()

    # Escape FFmpeg drawtext special characters
    # Colons and backslashes must be escaped even inside single quotes
    # because FFmpeg parses filter options as key=value pairs with : delimiters
    escaped_text = text.replace("\\", "\\\\").replace("'", "'\\''").replace(":", "\\:")

    filter_parts = [
        f"drawtext=text='{escaped_text}'",
        f"fontsize={size}",
        f"fontcolor={color}",
        f"fontfile={fontfile}",
        coords,
    ]

    if shadow:
        filter_parts.append("shadowcolor=black@0.5")
        filter_parts.append("shadowx=2")
        filter_parts.append("shadowy=2")

    if start_time is not None and duration is not None:
        filter_parts.append(f"enable='between(t\\,{start_time}\\,{start_time + duration})'")
    elif start_time is not None:
        filter_parts.append(f"enable='gte(t\\,{start_time})'")

    vf = ":".join(filter_parts)

    _run_ffmpeg([
        "-i", input_path,
        "-vf", vf,
        "-c:v", "libx264",
    ] + _quality_args(crf=crf, preset=preset) + [
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="add_text",
    )


def add_audio(
    video_path: str,
    audio_path: str,
    volume: float = 1.0,
    fade_in: float = 0.0,
    fade_out: float = 0.0,
    mix: bool = False,
    start_time: float | None = None,
    output_path: str | None = None,
) -> EditResult:
    """Add or replace audio track on a video."""
    _validate_input(video_path)
    _validate_input(audio_path)
    output = output_path or _auto_output(video_path, "audio")

    video_info = probe(video_path)

    if mix and _has_audio(_run_ffprobe_json(video_path)):
        # Mix new audio with existing audio
        audio_filters: list[str] = []
        if volume != 1.0:
            audio_filters.append(f"volume={volume}")
        if fade_in > 0:
            audio_filters.append(f"afade=t=in:st=0:d={fade_in}")
        if fade_out > 0:
            audio_filters.append(f"afade=t=out:st={video_info.duration - fade_out}:d={fade_out}")

        af = ",".join(audio_filters) if audio_filters else "anull"

        delay = ""
        if start_time:
            delay = f"[1:a]adelay={int(start_time * 1000)}|{int(start_time * 1000)},"

        filter_complex = (
            f"[0:a]anull[a0];"
            f"{delay}[1:a]{af}[a1];"
            f"[a0][a1]amix=inputs=2:duration=longest[aout]"
        )

        _run_ffmpeg([
            "-i", video_path, "-i", audio_path,
            "-filter_complex", filter_complex,
            "-map", "0:v", "-map", "[aout]",
            "-c:v", "copy",
            "-c:a", "aac", "-b:a", "128k",
        ] + _movflags_args(output) + [
            output,
        ])
    else:
        # Replace audio (or add if no existing audio)
        args = ["-i", video_path, "-i", audio_path]

        if start_time:
            args.extend(["-filter_complex", f"[1:a]adelay={int(start_time * 1000)}|{int(start_time * 1000)}[a]"])
            args.extend(["-map", "0:v:0", "-map", "[a]"])
        else:
            args.extend(["-map", "0:v:0", "-map", "1:a:0"])

        audio_filters = []
        if volume != 1.0:
            audio_filters.append(f"volume={volume}")
        if fade_in > 0:
            audio_filters.append(f"afade=t=in:st=0:d={fade_in}")
        if fade_out > 0:
            audio_filters.append(f"afade=t=out:st={video_info.duration - fade_out}:d={fade_out}")

        if audio_filters:
            args.extend(["-af", ",".join(audio_filters)])

        args.extend([
            "-c:v", "copy",
            "-c:a", "aac", "-b:a", "128k",
            "-shortest",
        ] + _movflags_args(output) + [
            output,
        ])
        _run_ffmpeg(args)

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="add_audio",
    )


def resize(
    input_path: str,
    width: int | None = None,
    height: int | None = None,
    aspect_ratio: str | None = None,
    quality: QualityLevel = "high",
    output_path: str | None = None,
) -> EditResult:
    """Resize a video. Use aspect_ratio for preset sizes (e.g. '9:16')."""
    _validate_input(input_path)

    if aspect_ratio and aspect_ratio in ASPECT_RATIOS:
        w, h = ASPECT_RATIOS[aspect_ratio]
    elif aspect_ratio:
        raise MCPVideoError(
            f"Unknown aspect ratio: {aspect_ratio}. "
            f"Available: {', '.join(ASPECT_RATIOS.keys())}",
            error_type="input_error",
            code="invalid_aspect_ratio",
        )
    elif width and height:
        w, h = width, height
    elif width:
        info = probe(input_path)
        ratio = info.height / info.width
        w, h = width, int(width * ratio)
    elif height:
        info = probe(input_path)
        ratio = info.width / info.height
        w, h = int(height * ratio), height
    else:
        raise MCPVideoError("resize requires width+height, aspect_ratio, or single dimension")

    preset = QUALITY_PRESETS[quality]
    output = output_path or _auto_output(input_path, f"{w}x{h}")

    # Scale to fit within target, then pad to exact dimensions
    vf = (
        f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
        f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:black"
    )

    _run_ffmpeg([
        "-i", input_path,
        "-vf", vf,
        "-c:v", "libx264",
        "-crf", str(preset["crf"]),
        "-preset", preset["preset"],
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="resize",
    )


def convert(
    input_path: str,
    format: ExportFormat = "mp4",
    quality: QualityLevel = "high",
    output_path: str | None = None,
    on_progress: Callable[[float], None] | None = None,
    two_pass: bool = False,
    target_bitrate: int | None = None,
) -> EditResult:
    """Convert video to a different format."""
    _validate_input(input_path)

    if two_pass and format not in ("mp4", "mov"):
        raise MCPVideoError(
            f"Two-pass encoding is only supported for mp4 and mov formats, got '{format}'",
            error_type="validation_error",
            code="two_pass_unsupported_format",
        )
    if two_pass and target_bitrate is None:
        raise MCPVideoError(
            "Two-pass encoding requires target_bitrate to be set",
            error_type="validation_error",
            code="two_pass_needs_bitrate",
        )

    preset = QUALITY_PRESETS[quality]
    ext = f".{format}" if not format.startswith(".") else format
    output = output_path or _auto_output(input_path, format, ext=ext)

    # Get input duration for progress estimation
    input_info = probe(input_path)

    if two_pass and target_bitrate:
        # Two-pass encoding for better quality at target bitrate
        passlogdir = tempfile.mkdtemp(prefix="mcp_video_2pass_")
        try:
            passlogfile = os.path.join(passlogdir, "pass")
            _run_ffmpeg([
                "-i", input_path,
                "-c:v", "libx264",
                "-b:v", f"{target_bitrate}k",
                "-pass", "1",
                "-passlogfile", passlogfile,
                "-an", "-f", "null", "/dev/null",
            ])
            _run_ffmpeg([
                "-i", input_path,
                "-c:v", "libx264",
                "-b:v", f"{target_bitrate}k",
                "-pass", "2",
                "-passlogfile", passlogfile,
                "-preset", preset["preset"],
                "-c:a", "aac", "-b:a", "128k",
            ] + _movflags_args(output) + [
                output,
            ])
        finally:
            shutil.rmtree(passlogdir, ignore_errors=True)
    elif format == "mp4":
        _run_ffmpeg_with_progress([
            "-i", input_path,
            "-c:v", "libx264",
            "-crf", str(preset["crf"]),
            "-preset", preset["preset"],
            "-c:a", "aac", "-b:a", "128k",
            "-movflags", "+faststart",
            output,
        ], estimated_duration=input_info.duration, on_progress=on_progress)
    elif format == "webm":
        _run_ffmpeg_with_progress([
            "-i", input_path,
            "-c:v", "libvpx-vp9",
            "-crf", str(preset["crf"]),
            "-b:v", "0",
            "-c:a", "libopus",
            output,
        ], estimated_duration=input_info.duration, on_progress=on_progress)
    elif format == "mov":
        _run_ffmpeg_with_progress([
            "-i", input_path,
            "-c:v", "libx264",
            "-crf", str(preset["crf"]),
            "-preset", preset["preset"],
            "-c:a", "pcm_s16le",
            output,
        ], estimated_duration=input_info.duration, on_progress=on_progress)
    elif format == "gif":
        # Two-pass palette-based GIF generation for quality
        # Scale by quality level: low=320, medium=480, high=640, ultra=800
        gif_scale = {"low": 320, "medium": 480, "high": 640, "ultra": 800}
        width = gif_scale.get(quality, 480)
        tmpdir = tempfile.mkdtemp(prefix="mcp_video_gif_")
        try:
            palette = os.path.join(tmpdir, "palette.png")
            _run_ffmpeg([
                "-i", input_path,
                "-vf", f"fps=15,scale={width}:-1:flags=lanczos,palettegen",
                "-y", palette,
            ])
            _run_ffmpeg_with_progress([
                "-i", input_path,
                "-i", palette,
                "-lavfi", f"fps=15,scale={width}:-1:flags=lanczos [x]; [x][1:v] paletteuse",
                "-y", output,
            ], estimated_duration=input_info.duration, on_progress=on_progress)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)
    else:
        raise MCPVideoError(f"Unsupported format: {format}", code="unsupported_format")

    thumb_b64 = _generate_thumbnail_base64(output) if format != "gif" else None

    if os.path.isfile(output):
        size_mb = os.path.getsize(output) / (1024 * 1024)
        if format != "gif":
            info = probe(output)
            return EditResult(
                output_path=output,
                duration=info.duration,
                resolution=info.resolution,
                size_mb=round(size_mb, 2),
                format=format,
                operation="convert",
                progress=100.0,
                thumbnail_base64=thumb_b64,
            )
    else:
        size_mb = None

    return EditResult(
        output_path=output,
        size_mb=round(size_mb, 2) if size_mb else None,
        format=format,
        operation="convert",
        progress=100.0,
        thumbnail_base64=thumb_b64,
    )


def speed(
    input_path: str,
    factor: float = 1.0,
    output_path: str | None = None,
) -> EditResult:
    """Change playback speed. factor > 1 = faster, < 1 = slower."""
    _validate_input(input_path)
    if factor <= 0:
        raise MCPVideoError("Speed factor must be positive")

    output = output_path or _auto_output(input_path, f"speed_{factor}x")

    # Use setpts for video, atempo for audio
    video_filter = f"setpts={1/factor}*PTS"
    audio_filter = f"atempo={factor}"

    # atempo only supports 0.5 to 100.0; chain if needed
    if factor < 0.5:
        chain_count = 2
        while factor ** (1 / chain_count) < 0.5:
            chain_count += 1
        tempo_val = factor ** (1 / chain_count)
        audio_filter = ",".join([f"atempo={tempo_val}"] * chain_count)
    elif factor > 100:
        chain_count = 2
        while factor ** (1 / chain_count) > 100:
            chain_count += 1
        tempo_val = factor ** (1 / chain_count)
        audio_filter = ",".join([f"atempo={tempo_val}"] * chain_count)

    # Check if input has audio
    info = probe(input_path)
    has_audio = info.audio_codec is not None

    if has_audio:
        _run_ffmpeg([
            "-i", input_path,
            "-filter_complex", f"[0:v]{video_filter}[v];[0:a]{audio_filter}[a]",
            "-map", "[v]", "-map", "[a]",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-c:a", "aac", "-b:a", "128k",
        ] + _movflags_args(output) + [
            output,
        ])
    else:
        _run_ffmpeg([
            "-i", input_path,
            "-vf", video_filter,
            "-an",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        ] + _movflags_args(output) + [
            output,
        ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="speed",
    )


def thumbnail(
    input_path: str,
    timestamp: float | None = None,
    output_path: str | None = None,
) -> ThumbnailResult:
    """Extract a single frame from a video."""
    _validate_input(input_path)

    if timestamp is None:
        # Grab frame at 10% of video duration
        dur = get_duration(input_path)
        timestamp = dur * 0.1
    else:
        # Clamp to valid range
        dur = get_duration(input_path)
        timestamp = min(timestamp, dur * 0.99)

    output = output_path or _auto_output(input_path, f"frame_{timestamp:.1f}s", ext=".jpg")

    _run_ffmpeg([
        "-ss", str(timestamp),
        "-i", input_path,
        "-vframes", "1",
        "-q:v", "2",
        "-y", output,
    ])

    return ThumbnailResult(
        frame_path=output,
        timestamp=timestamp,
    )


def preview(
    input_path: str,
    output_path: str | None = None,
    scale_factor: int = 4,
) -> EditResult:
    """Generate a fast low-resolution preview for quick review."""
    _validate_input(input_path)
    if scale_factor < 1:
        raise MCPVideoError("scale_factor must be at least 1", code="invalid_scale_factor")
    info = probe(input_path)

    w = max(info.width // scale_factor, 320)
    h = max(info.height // scale_factor, 240)

    output = output_path or _auto_output(input_path, "preview")

    _run_ffmpeg([
        "-i", input_path,
        "-vf", f"scale={w}:{h}",
        "-c:v", "libx264",
        "-crf", str(PREVIEW_PRESETS["crf"]),
        "-preset", PREVIEW_PRESETS["preset"],
        "-c:a", "aac", "-b:a", "64k", "-ac", "2",
    ] + _movflags_args(output) + [
        output,
    ])

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="preview",
    )


def storyboard(
    input_path: str,
    output_dir: str | None = None,
    frame_count: int = 8,
) -> StoryboardResult:
    """Extract key frames and create a storyboard grid for human review."""
    _validate_input(input_path)
    if frame_count < 1:
        raise MCPVideoError("frame_count must be at least 1", code="invalid_frame_count")
    dur = get_duration(input_path)

    out_dir = output_dir or _auto_output_dir(input_path, "storyboard")
    os.makedirs(out_dir, exist_ok=True)

    tmpdir = tempfile.mkdtemp(prefix="mcp_video_sb_")
    try:
        frame_paths: list[str] = []
        interval = dur / (frame_count + 1)

        for i in range(frame_count):
            ts = interval * (i + 1)
            frame_name = f"frame_{i + 1:02d}_{ts:.1f}s.jpg"
            frame_path = os.path.join(out_dir, frame_name)

            _run_ffmpeg([
                "-ss", str(ts),
                "-i", input_path,
                "-vframes", "1",
                "-q:v", "2",
                "-y", frame_path,
            ])
            frame_paths.append(frame_path)

        # Create storyboard grid using FFmpeg
        grid_path = os.path.join(out_dir, "storyboard_grid.jpg")
        if len(frame_paths) >= 2:
            # Create a grid of frames
            cols = min(4, len(frame_paths))
            rows = (len(frame_paths) + cols - 1) // cols

            # Use FFmpeg to tile the images
            # Build a complex filter for the grid
            inputs = []
            for fp in frame_paths:
                inputs.extend(["-i", fp])

            # Normalize all frames to same size
            filter_parts = []
            for i, fp in enumerate(frame_paths):
                filter_parts.append(f"[{i}:v]scale=480:270:force_original_aspect_ratio=decrease,pad=480:270:(ow-iw)/2:(oh-ih)/2[s{i}]")

            # Stack horizontally first, then vertically
            # Row 0: [s0][s1][s2][s3]hstack=inputs=4[r0]
            # Row 1: [s4][s5][s6][s7]hstack=inputs=4[r1]
            # Final: [r0][r1]vstack=inputs=2[vout]

            row_labels: list[str] = []
            for row in range(rows):
                start = row * cols
                end = min(start + cols, len(frame_paths))
                actual_cols = end - start
                input_labels = "".join(f"[s{j}]" for j in range(start, end))
                row_label = f"r{row}"
                filter_parts.append(f"{input_labels}hstack=inputs={actual_cols}[{row_label}]")
                row_labels.append(f"[{row_label}]")

            vstack_inputs = "".join(row_labels)
            filter_parts.append(f"{vstack_inputs}vstack=inputs={rows}[vout]")

            filter_str = ";".join(filter_parts)

            try:
                _run_ffmpeg(
                    inputs + [
                        "-filter_complex", filter_str,
                        "-map", "[vout]",
                        "-q:v", "2",
                        "-y", grid_path,
                    ]
                )
            except ProcessingError:
                # Grid creation failed — frames are still useful individually
                grid_path = None
        elif len(frame_paths) == 1:
            shutil.copy2(frame_paths[0], grid_path)
        else:
            grid_path = None

    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    return StoryboardResult(
        frames=frame_paths,
        grid=grid_path,
        count=len(frame_paths),
    )


def subtitles(
    input_path: str,
    subtitle_path: str,
    output_path: str | None = None,
    style: str = "FontSize=22,PrimaryColour=&Hffffff&,OutlineColour=&H000000&,Outline=2,Shadow=1",
) -> EditResult:
    """Burn subtitles (SRT/VTT) into a video."""
    _validate_input(input_path)
    _validate_input(subtitle_path)
    _require_filter("subtitles", "Subtitle burn-in")
    output = output_path or _auto_output(input_path, "subtitled")

    # Escape special characters for FFmpeg subtitle filter path
    escaped_sub_path = _escape_ffmpeg_filter_value(subtitle_path)
    escaped_style = style.replace("'", "\\'").replace(":", "\\:")

    _run_ffmpeg([
        "-i", input_path,
        "-vf", f"subtitles='{escaped_sub_path}':force_style='{escaped_style}'",
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="subtitles",
    )


def watermark(
    input_path: str,
    image_path: str,
    position: Position = "bottom-right",
    opacity: float = 0.7,
    margin: int = 20,
    output_path: str | None = None,
    crf: int | None = None,
    preset: str | None = None,
) -> EditResult:
    """Add an image watermark to a video."""
    _validate_input(input_path)
    _validate_input(image_path)
    output = output_path or _auto_output(input_path, "watermarked")

    # Position expressions for the overlay
    position_map: dict[NamedPosition, str] = {
        "top-left": f"{margin}:{margin}",
        "top-center": "(main_w-overlay_w)/2:{margin}",
        "top-right": f"main_w-overlay_w-{margin}:{margin}",
        "center-left": f"{margin}:(main_h-overlay_h)/2",
        "center": "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
        "center-right": f"main_w-overlay_w-{margin}:(main_h-overlay_h)/2",
        "bottom-left": f"{margin}:main_h-overlay_h-{margin}",
        "bottom-center": "(main_w-overlay_w)/2:main_h-overlay_h-{margin}",
        "bottom-right": f"main_w-overlay_w-{margin}:main_h-overlay_h-{margin}",
    }

    overlay_pos = _resolve_position(position, position_map, "bottom-right")
    # Format opacity for FFmpeg (0.0 to 1.0)
    opacity_fmt = f"{opacity:.2f}"

    _run_ffmpeg([
        "-i", input_path, "-i", image_path,
        "-filter_complex",
        f"[1:v]format=rgba,colorchannelmixer=aa={opacity_fmt}[wm];[0:v][wm]overlay={overlay_pos}",
        "-c:v", "libx264",
    ] + _quality_args(crf=crf, preset=preset) + [
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="watermark",
    )


def crop(
    input_path: str,
    width: int,
    height: int,
    x: int | None = None,
    y: int | None = None,
    output_path: str | None = None,
) -> EditResult:
    """Crop a video to a rectangular region."""
    _validate_input(input_path)
    if width <= 0 or height <= 0:
        raise MCPVideoError("Crop dimensions must be positive", code="invalid_crop")

    info = probe(input_path)
    if width > info.width or height > info.height:
        raise MCPVideoError(
            f"Crop size ({width}x{height}) larger than video ({info.width}x{info.height})",
            code="crop_too_large",
        )

    if x is None:
        x = (info.width - width) // 2
    if y is None:
        y = (info.height - height) // 2

    output = output_path or _auto_output(input_path, f"crop_{width}x{height}")

    _run_ffmpeg([
        "-i", input_path,
        "-vf", f"crop={width}:{height}:{x}:{y}",
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="crop",
    )


def rotate(
    input_path: str,
    angle: int = 0,
    flip_horizontal: bool = False,
    flip_vertical: bool = False,
    output_path: str | None = None,
) -> EditResult:
    """Rotate and/or flip a video.

    Args:
        angle: Rotation angle (0, 90, 180, 270).
        flip_horizontal: Mirror horizontally.
        flip_vertical: Mirror vertically.
    """
    _validate_input(input_path)

    if angle not in (0, 90, 180, 270):
        raise MCPVideoError("angle must be 0, 90, 180, or 270", code="invalid_angle")
    if angle == 0 and not flip_horizontal and not flip_vertical:
        raise MCPVideoError("No rotation or flip specified", code="no_transform")

    filters: list[str] = []
    if flip_horizontal:
        filters.append("hflip")
    if flip_vertical:
        filters.append("vflip")
    if angle == 90:
        filters.append("transpose=1")
    elif angle == 180:
        filters.append("transpose=1,transpose=1")
    elif angle == 270:
        filters.append("transpose=2")

    vf = ",".join(filters)
    output = output_path or _auto_output(input_path, f"rotated_{angle}")

    _run_ffmpeg([
        "-i", input_path,
        "-vf", vf,
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="rotate",
    )


def fade(
    input_path: str,
    fade_in: float = 0.0,
    fade_out: float = 0.0,
    output_path: str | None = None,
    crf: int | None = None,
    preset: str | None = None,
) -> EditResult:
    """Add fade in/out effect to a video."""
    _validate_input(input_path)
    if fade_in <= 0 and fade_out <= 0:
        raise MCPVideoError("Specify fade_in and/or fade_out > 0", code="no_fade")

    output = output_path or _auto_output(input_path, "faded")
    info = probe(input_path)

    vf_parts: list[str] = []
    if fade_in > 0:
        vf_parts.append(f"fade=t=in:st=0:d={fade_in}")
    if fade_out > 0:
        fade_start = max(0, info.duration - fade_out)
        vf_parts.append(f"fade=t=out:st={fade_start:.3f}:d={fade_out}")

    vf = ",".join(vf_parts)

    _run_ffmpeg([
        "-i", input_path,
        "-vf", vf,
        "-c:v", "libx264",
    ] + _quality_args(crf=crf, preset=preset) + [
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="fade",
    )


def export_video(
    input_path: str,
    output_path: str | None = None,
    quality: QualityLevel = "high",
    format: ExportFormat = "mp4",
    on_progress: Callable[[float], None] | None = None,
    two_pass: bool = False,
    target_bitrate: int | None = None,
) -> EditResult:
    """Export a video with specified quality and format settings."""
    _validate_input(input_path)
    result = convert(
        input_path, format=format, quality=quality, output_path=output_path,
        on_progress=on_progress, two_pass=two_pass, target_bitrate=target_bitrate,
    )
    result.operation = "export"
    return result


# ---------------------------------------------------------------------------
# Timeline-based edit (composite operation)
# ---------------------------------------------------------------------------

def edit_timeline(timeline: Timeline | dict, output_path: str | None = None) -> EditResult:
    """Execute a full timeline-based edit described in JSON."""
    if isinstance(timeline, dict):
        timeline = Timeline.model_validate(timeline)
    tmpdir = tempfile.mkdtemp(prefix="mcp_video_timeline_")
    try:
        video_clips: list[str] = []
        audio_clips: list[str] = []
        text_elements: list = []
        image_overlays: list[TimelineImageOverlay] = []

        # Collect all elements from tracks
        for track in timeline.tracks:
            if track.type == "video":
                for clip in track.clips:
                    _validate_input(clip.source)
                    # Trim clip if needed
                    if clip.trim_start > 0 or clip.trim_end:
                        trimmed = os.path.join(tmpdir, f"v_{len(video_clips):04d}.mp4")
                        trim_kwargs = {"start": clip.trim_start}
                        if clip.duration:
                            trim_kwargs["duration"] = clip.duration
                        elif clip.trim_end:
                            trim_kwargs["end"] = clip.trim_end
                        result = trim(clip.source, output_path=trimmed, **trim_kwargs)
                        video_clips.append(result.output_path)
                    else:
                        video_clips.append(clip.source)
                text_elements.extend(track.elements)

            elif track.type == "audio":
                for clip in track.clips:
                    _validate_input(clip.source)
                    audio_clips.append(clip.source)

            elif track.type == "text":
                text_elements.extend(track.elements)

            elif track.type == "image":
                for img in track.images:
                    _validate_input(img.source)
                    image_overlays.append(img)

            # Also collect images from any track that has them
            if track.images:
                for img in track.images:
                    _validate_input(img.source)
                    if img not in image_overlays:
                        image_overlays.append(img)

        if not video_clips:
            raise MCPVideoError("Timeline must have at least one video clip")

        # Merge video clips
        if len(video_clips) == 1:
            merged = video_clips[0]
        else:
            merged = os.path.join(tmpdir, "merged.mp4")
            transition_list = None
            trans_duration = 1.0
            for track in timeline.tracks:
                if track.type == "video" and track.transitions:
                    # Sort by after_clip to get correct order
                    sorted_trans = sorted(track.transitions, key=lambda t: t.after_clip)
                    transition_list = [t.type.value for t in sorted_trans]
                    trans_duration = sorted_trans[0].duration
                    break
            merge(video_clips, output_path=merged, transitions=transition_list, transition_duration=trans_duration)

        # Apply text overlays and image overlays in a single filtergraph pass
        # to avoid multiple re-encodes
        current = merged
        if text_elements or image_overlays:
            composited = os.path.join(tmpdir, "composited.mp4")
            _apply_composite_overlays(
                merged, composited, text_elements, image_overlays,
            )
            current = composited

        # Add audio
        if audio_clips:
            final = os.path.join(tmpdir, "with_audio.mp4")
            add_audio(current, audio_clips[0], output_path=final)
            current = final

        # Resize to timeline dimensions
        if timeline.width and timeline.height:
            info = probe(current)
            if info.width != timeline.width or info.height != timeline.height:
                resized = os.path.join(tmpdir, "resized.mp4")
                resize(current, width=timeline.width, height=timeline.height, output_path=resized)
                current = resized

        # Export — write to a safe location outside tmpdir
        if output_path:
            output = output_path
        else:
            # Use the original video's directory, not tmpdir
            original_source = video_clips[0]
            output = _auto_output(original_source, "timeline", ext=f".{timeline.export.format}")
        result = export_video(
            current,
            output_path=output,
            quality=timeline.export.quality,
            format=timeline.export.format,
        )

        return result

    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)


def _apply_composite_overlays(
    input_path: str,
    output_path: str,
    text_elements: list,
    image_overlays: list[TimelineImageOverlay],
) -> None:
    """Apply text and image overlays in a single FFmpeg filtergraph pass.

    This avoids multiple re-encodes when applying multiple overlays.
    """
    info = probe(input_path)

    # Build filter_complex
    inputs: list[str] = ["-i", input_path]
    filter_parts: list[str] = []
    input_idx = 1  # Next input index (0 is the main video)

    # Named overlay position map (no margin for timeline overlays)
    overlay_position_map: dict[NamedPosition, str] = {
        "top-left": "0:0",
        "top-center": "(main_w-overlay_w)/2:0",
        "top-right": "main_w-overlay_w:0",
        "center-left": "0:(main_h-overlay_h)/2",
        "center": "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
        "center-right": "main_w-overlay_w:(main_h-overlay_h)/2",
        "bottom-left": "0:main_h-overlay_h",
        "bottom-center": "(main_w-overlay_w)/2:main_h-overlay_h",
        "bottom-right": "main_w-overlay_w:main_h-overlay_h",
    }

    # Process image overlays first — they use FFmpeg overlay filter
    prev_label = "0:v"
    for i, img in enumerate(image_overlays):
        img_label = f"img{i}"
        ov_label = f"ov{i}"

        inputs.extend(["-i", img.source])

        # Build scale + opacity chain for this image
        chain_parts: list[str] = []
        if img.width and img.height:
            chain_parts.append(f"scale={img.width}:{img.height}")
        elif img.width:
            chain_parts.append(f"scale={img.width}:-1")
        elif img.height:
            chain_parts.append(f"scale=-1:{img.height}")

        if img.opacity < 1.0:
            chain_parts.append("format=rgba")
            chain_parts.append(f"colorchannelmixer=aa={img.opacity:.2f}")

        chain = ",".join(chain_parts) if chain_parts else "null"
        filter_parts.append(f"[{input_idx}:v]{chain}[{img_label}]")

        # Resolve position
        if isinstance(img.position, dict):
            if "x_pct" in img.position and "y_pct" in img.position:
                pos = f"(main_w*{img.position['x_pct']}-overlay_w/2):(main_h*{img.position['y_pct']}-overlay_h/2)"
            elif "x" in img.position and "y" in img.position:
                pos = f"{img.position['x']}:{img.position['y']}"
            else:
                pos = overlay_position_map["center"]
        elif img.x is not None and img.y is not None:
            pos = f"{img.x}:{img.y}"
        else:
            pos = overlay_position_map.get(img.position, overlay_position_map["center"])

        # Enable expression for timing
        enable_expr = ""
        if img.start is not None or img.duration is not None:
            parts = []
            if img.start is not None and img.duration is not None:
                end = img.start + img.duration
                parts.append(f"between(t,{img.start},{end})")
            elif img.start is not None:
                parts.append(f"gte(t,{img.start})")
            elif img.duration is not None:
                parts.append(f"lte(t,{img.duration})")
            enable_expr = f":enable='{parts[0]}'"

        filter_parts.append(f"[{prev_label}][{img_label}]overlay={pos}{enable_expr}[{ov_label}]")
        prev_label = ov_label
        input_idx += 1

    # Process text overlays — use drawtext on the final video
    # If there were image overlays, we append drawtext to the last overlay output
    # Otherwise we apply directly to the base video
    vf_parts: list[str] = []
    for elem in text_elements:
        fontfile = elem.style.get("font") or _default_font()
        size = elem.style.get("size", 48)
        color = elem.style.get("color", "white")
        _validate_color(color)
        coords = _position_coords(elem.position, info.width, info.height)

        escaped_text = elem.text.replace("\\", "\\\\").replace("'", "'\\''").replace(":", "\\:")

        drawtext_parts = [
            f"drawtext=text='{escaped_text}'",
            f"fontsize={size}",
            f"fontcolor={color}",
            f"fontfile={fontfile}",
            coords,
        ]

        if elem.style.get("shadow", True):
            drawtext_parts.append("shadowcolor=black@0.5")
            drawtext_parts.append("shadowx=2")
            drawtext_parts.append("shadowy=2")

        if elem.start is not None and elem.duration is not None:
            drawtext_parts.append(f"enable='between(t\\,{elem.start}\\,{elem.start + elem.duration})'")
        elif elem.start is not None:
            drawtext_parts.append(f"enable='gte(t\\,{elem.start})'")

        vf_parts.append(":".join(drawtext_parts))

    # Combine: filter_complex for overlays + vf for drawtext
    if image_overlays and vf_parts:
        # Apply drawtext to the last overlay output
        last_label = prev_label
        for vf in vf_parts:
            filter_parts.append(f"[{last_label}]{vf}[vout]")
            last_label = "vout"

        # Final map
        cmd = inputs + [
            "-filter_complex", ";".join(filter_parts),
            "-map", f"[{last_label}]",
            "-map", "0:a?",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-c:a", "copy",
        ] + _movflags_args(output_path) + [output_path]
        _run_ffmpeg(cmd)

    elif image_overlays:
        # Only image overlays, no text
        cmd = inputs + [
            "-filter_complex", ";".join(filter_parts),
            "-map", f"[{prev_label}]",
            "-map", "0:a?",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-c:a", "copy",
        ] + _movflags_args(output_path) + [output_path]
        _run_ffmpeg(cmd)

    elif text_elements:
        # Only text overlays (no images) — use -vf
        vf = ",".join(vf_parts)
        _run_ffmpeg(
            inputs + [
                "-vf", vf,
                "-c:v", "libx264", "-preset", "fast", "-crf", "23",
                "-c:a", "copy",
            ] + _movflags_args(output_path) + [output_path]
        )


def extract_audio(
    input_path: str,
    output_path: str | None = None,
    format: str = "mp3",
) -> str:
    """Extract audio track from a video file."""
    _validate_input(input_path)
    ext = f".{format}" if not format.startswith(".") else format
    output = output_path or _auto_output(input_path, "audio", ext=ext)

    codec_map = {
        "mp3": "libmp3lame",
        "aac": "aac",
        "wav": "pcm_s16le",
        "ogg": "libvorbis",
        "flac": "flac",
    }
    codec = codec_map.get(format, "libmp3lame")

    _run_ffmpeg([
        "-i", input_path,
        "-vn",
        "-c:a", codec,
        "-b:a", "192k" if format != "wav" else "0",
        "-y", output,
    ])

    return output


# ---------------------------------------------------------------------------
# Video filters & effects
# ---------------------------------------------------------------------------

def _get_color_preset_filter(preset: ColorPreset) -> str:
    """Return FFmpeg eq filter string for a named color preset."""
    preset_filters: dict[ColorPreset, str] = {
        "warm": "eq=brightness=0.05:saturation=1.3:contrast=1.05",
        "cool": "eq=brightness=0.02:saturation=0.9:contrast=1.05",
        "vintage": "eq=contrast=1.1:brightness=-0.02:saturation=0.7",
        "cinematic": "eq=contrast=1.15:brightness=-0.03:saturation=0.85",
        "noir": "eq=contrast=1.3:brightness=-0.05:saturation=0.0",
    }
    if preset not in preset_filters:
        valid = ", ".join(sorted(preset_filters))
        raise MCPVideoError(
            f"Unknown color preset '{preset}'. Valid presets: {valid}",
            error_type="validation_error",
            code="invalid_color_preset",
        )
    return preset_filters[preset]


def _build_pitch_shift_filter(semitones: float = 0) -> str:
    """Build FFmpeg audio filter string for pitch shifting.

    Args:
        semitones: Number of semitones to shift. Positive = higher, negative = lower.
            Each semitone is a 2^(1/12) ~ 1.0595x multiplier on sample rate.
    """
    rate_mult = 2 ** (semitones / 12)
    new_rate = 44100 * rate_mult
    # atempo compensates for the tempo change caused by sample rate shift,
    # restoring original playback speed (avoids A/V desync)
    tempo = 1.0 / rate_mult
    # atempo supports 0.5-100.0; chain if needed
    atempo_str = f"atempo={tempo}"
    return f"asetrate={new_rate},aresample=44100,{atempo_str}"


def apply_filter(
    input_path: str,
    filter_type: FilterType,
    params: dict[str, Any] | None = None,
    output_path: str | None = None,
    crf: int | None = None,
    preset: str | None = None,
) -> EditResult:
    """Apply a visual filter to a video.

    Args:
        input_path: Path to the input video.
        filter_type: One of the supported filter types.
        params: Optional parameters for the filter.
        output_path: Where to save the output.
    """
    _validate_input(input_path)
    params = params or {}
    output = output_path or _auto_output(input_path, f"filter_{filter_type}")

    # Probe video dimensions for ken_burns filter
    info = probe(input_path)

    # Build the -vf filter string
    # Audio filters use -af; video filters use -vf.
    # filter_map entries: (filter_name, filter_string, is_audio)
    filter_map: dict[FilterType, tuple[str, str, bool]] = {
        "blur": ("boxblur", f"boxblur={params.get('radius', 5)}:{params.get('strength', 1)}", False),
        "sharpen": ("unsharp", f"unsharp=5:5:{params.get('amount', 1.0)}:5:5:0.0", False),
        "brightness": ("eq", f"eq=brightness={params.get('level', 0.1)}", False),
        "contrast": ("eq", f"eq=contrast={params.get('level', 1.5)}", False),
        "saturation": ("eq", f"eq=saturation={params.get('level', 1.5)}", False),
        "grayscale": ("hue", "hue=s=0", False),
        "sepia": ("colorchannelmixer", "colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131", False),
        "invert": ("negate", "negate", False),
        "vignette": ("vignette", f"vignette=angle={params.get('angle', 'PI/4')}", False),
        "color_preset": ("eq", _get_color_preset_filter(params.get("preset", "warm")), False),
        "denoise": ("hqdn3d", f"hqdn3d={params.get('luma_spatial', 4)}:{params.get('chroma_spatial', 3)}:{params.get('luma_tmp', 6)}:{params.get('chroma_tmp', 4.5)}", False),
        "deinterlace": ("yadif", "yadif=0:-1:0", False),
        "ken_burns": ("zoompan", f"zoompan=z='min(zoom+{params.get('zoom_speed', 0.0015)},1.5)':d={params.get('duration', 150)}:x='iw/2-(iw/zoom)/2':y='ih/2-(ih/zoom)/2':s={info.width}x{info.height}", False),
        "reverb": ("aecho", f"aecho={params.get('in_gain', 0.8)}:{params.get('out_gain', 0.9)}:{params.get('delays', 60)}:{params.get('decay', 0.2)}", True),
        "compressor": ("acompressor", f"acompressor=threshold={params.get('threshold_db', -20)}dB:ratio={params.get('ratio', 4)}:attack={params.get('attack', 5)}:release={params.get('release', 50)}", True),
        "pitch_shift": ("asetrate", _build_pitch_shift_filter(params.get("semitones", 0)), True),
        "noise_reduction": ("afftdn", f"afftdn=nf={params.get('noise_level', -25)}", True),
    }

    if filter_type not in filter_map:
        valid = ", ".join(sorted(filter_map))
        raise MCPVideoError(
            f"Unknown filter type '{filter_type}'. Valid types: {valid}",
            error_type="validation_error",
            code="invalid_filter_type",
        )
    filter_name, filter_string, is_audio = filter_map[filter_type]
    _require_filter(filter_name, f"Filter '{filter_type}'")

    # Audio filters require an audio stream and use -af instead of -vf
    if is_audio:
        input_info = probe(input_path)
        if input_info.audio_codec is None:
            raise MCPVideoError(
                f"Audio filter '{filter_type}' requires an audio stream, but this video has none",
                error_type="validation_error",
                code="audio_filter_no_audio",
            )
        _run_ffmpeg([
            "-i", input_path,
            "-af", filter_string,
            "-c:v", "copy",
            "-c:a", "aac", "-b:a", "128k",
        ] + _movflags_args(output) + [
            output,
        ])
    else:
        _run_ffmpeg([
            "-i", input_path,
            "-vf", filter_string,
            "-c:v", "libx264",
        ] + _quality_args(crf=crf, preset=preset) + [
            "-c:a", "copy",
        ] + _movflags_args(output) + [
            output,
        ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation=f"filter_{filter_type}",
    )


# ---------------------------------------------------------------------------
# Audio normalization
# ---------------------------------------------------------------------------

def normalize_audio(
    input_path: str,
    target_lufs: float = -16.0,
    lra: float = 11.0,
    output_path: str | None = None,
) -> EditResult:
    """Normalize audio loudness to a target LUFS level.

    Args:
        input_path: Path to the input video.
        target_lufs: Target integrated loudness in LUFS. Common values:
            -16 (YouTube), -23 (EBU R128/broadcast), -14 (Apple/Spotify).
        lra: Loudness range target in LU. Default 11.0.
        output_path: Where to save the output.
    """
    _validate_input(input_path)
    _require_filter("loudnorm", "Audio normalization")
    output = output_path or _auto_output(input_path, "normalized")

    # loudnorm parameters: I=integrated loudness, TP=true peak, LRA=loudness range
    # TP (true peak) should be a fixed value near -1.5 dBTP regardless of target LUFS.
    tp = -1.5

    _run_ffmpeg([
        "-i", input_path,
        "-af", f"loudnorm=I={target_lufs}:TP={tp}:LRA={lra}",
        "-c:v", "copy",
        "-c:a", "aac", "-b:a", "192k",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="normalize_audio",
    )


# ---------------------------------------------------------------------------
# Compositing & overlays
# ---------------------------------------------------------------------------

def overlay_video(
    background_path: str,
    overlay_path: str,
    position: Position = "top-right",
    width: int | None = None,
    height: int | None = None,
    opacity: float = 0.8,
    start_time: float | None = None,
    duration: float | None = None,
    output_path: str | None = None,
    crf: int | None = None,
    preset: str | None = None,
) -> EditResult:
    """Picture-in-picture: overlay a video on top of another.

    Args:
        background_path: Path to the background video.
        overlay_path: Path to the overlay video.
        position: Position of the overlay on screen.
        width: Width to scale the overlay to.
        height: Height to scale the overlay to.
        opacity: Opacity of the overlay (0.0 to 1.0).
        start_time: When the overlay appears (seconds).
        duration: How long the overlay is visible (seconds).
        output_path: Where to save the output.
    """
    _validate_input(background_path)
    _validate_input(overlay_path)
    _require_filter("overlay", "Video overlay")
    output = output_path or _auto_output(background_path, "overlay")

    # Build scale filter for overlay
    scale_parts = []
    if width and height:
        scale_parts.append(f"scale={width}:{height}")
    elif width:
        scale_parts.append(f"scale={width}:-1")
    elif height:
        scale_parts.append(f"scale=-1:{height}")
    scale_filter = ",".join(scale_parts) if scale_parts else ""

    # Build the overlay filter chain
    opacity_fmt = f"{opacity:.2f}"
    overlay_chain_parts = ["format=rgba", f"colorchannelmixer=aa={opacity_fmt}"]
    if scale_filter:
        overlay_chain_parts.insert(0, scale_filter)
    overlay_chain = ",".join(overlay_chain_parts)

    # Position map (same as watermark but without margin)
    position_map: dict[NamedPosition, str] = {
        "top-left": "0:0",
        "top-center": "(main_w-overlay_w)/2:0",
        "top-right": "main_w-overlay_w:0",
        "center-left": "0:(main_h-overlay_h)/2",
        "center": "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
        "center-right": "main_w-overlay_w:(main_h-overlay_h)/2",
        "bottom-left": "0:main_h-overlay_h",
        "bottom-center": "(main_w-overlay_w)/2:main_h-overlay_h",
        "bottom-right": "main_w-overlay_w:main_h-overlay_h",
    }
    overlay_pos = _resolve_position(position, position_map, "top-right")

    # Optional enable expression for timing
    enable_expr = ""
    if start_time is not None or duration is not None:
        parts = []
        if start_time is not None and duration is not None:
            end = start_time + duration
            parts.append(f"between(t,{start_time},{end})")
        elif start_time is not None:
            parts.append(f"gte(t,{start_time})")
        elif duration is not None:
            parts.append(f"lte(t,{duration})")
        enable_expr = f":enable='{parts[0]}'"

    filter_complex = f"[1:v]{overlay_chain}[ov];[0:v][ov]overlay={overlay_pos}{enable_expr}"

    _run_ffmpeg([
        "-i", background_path, "-i", overlay_path,
        "-filter_complex", filter_complex,
        "-c:v", "libx264",
    ] + _quality_args(crf=crf, preset=preset) + [
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="overlay_video",
    )


def split_screen(
    left_path: str,
    right_path: str,
    layout: SplitLayout = "side-by-side",
    output_path: str | None = None,
) -> EditResult:
    """Place two videos side by side or top/bottom.

    Args:
        left_path: Path to the first video.
        right_path: Path to the second video.
        layout: 'side-by-side' or 'top-bottom'.
        output_path: Where to save the output.
    """
    _validate_input(left_path)
    _validate_input(right_path)
    output = output_path or _auto_output(left_path, f"split_{layout}")

    # Get info about both videos to check if resizing is needed
    left_info = probe(left_path)
    right_info = probe(right_path)

    # Build filter_complex to normalize heights (side-by-side) or widths (top-bottom)
    # Use max dimensions to avoid losing quality when one video is larger
    if layout == "side-by-side":
        target_h = max(left_info.height, right_info.height)
        if left_info.height != right_info.height:
            filter_complex = (
                f"[0:v]scale=-1:{target_h},setsar=1[left];"
                f"[1:v]scale=-1:{target_h},setsar=1[right];"
                f"[left][right]hstack=inputs=2[v]"
            )
        else:
            filter_complex = "[0:v][1:v]hstack=inputs=2[v]"
    else:
        target_w = max(left_info.width, right_info.width)
        if left_info.width != right_info.width:
            filter_complex = (
                f"[0:v]scale={target_w}:-1,setsar=1[top];"
                f"[1:v]scale={target_w}:-1,setsar=1[bottom];"
                f"[top][bottom]vstack=inputs=2[v]"
            )
        else:
            filter_complex = "[0:v][1:v]vstack=inputs=2[v]"

    _run_ffmpeg([
        "-i", left_path, "-i", right_path,
        "-filter_complex", filter_complex,
        "-map", "[v]",
        "-map", "0:a?",
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
    ] + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation=f"split_screen_{layout}",
    )


def reverse(
    input_path: str,
    output_path: str | None = None,
) -> EditResult:
    """Reverse video and audio playback.

    Args:
        input_path: Path to the input video.
        output_path: Where to save the output. Auto-generated if omitted.
    """
    _validate_input(input_path)
    output = output_path or _auto_output(input_path, "reversed")

    input_info = probe(input_path)

    args = ["-i", input_path, "-vf", "reverse"]
    # Only reverse audio if the input has an audio stream
    if input_info.audio_codec:
        args += ["-af", "areverse", "-c:a", "aac", "-b:a", "128k"]
    else:
        args += ["-an"]
    args += ["-c:v", "libx264", "-preset", "fast", "-crf", "23"]

    _run_ffmpeg(args + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="reverse",
    )


def chroma_key(
    input_path: str,
    color: str = "0x00FF00",
    similarity: float = 0.01,
    blend: float = 0.0,
    output_path: str | None = None,
) -> EditResult:
    """Remove a solid color background (green screen / chroma key).

    Args:
        input_path: Path to the input video.
        color: Color to make transparent (default green: 0x00FF00).
        similarity: How similar colors need to be to be keyed out (default 0.01).
        blend: How much to blend the keyed color (default 0.0).
        output_path: Where to save the output. Auto-generated if omitted.

    Note: Use a .mov output path to preserve the alpha channel (transparent
    background). Non-MOV outputs will encode with libx264 which does not
    support transparency.
    """
    _validate_input(input_path)
    output = output_path or _auto_output(input_path, "chromakey")

    _require_filter("chromakey", "Chroma key filter")

    # Use MOV with prores_ks (supports alpha) when outputting with transparency
    is_mov = output.lower().endswith(".mov")

    if is_mov:
        vf = f"chromakey=color={color}:similarity={similarity}:blend={blend},format=yuva444p16le"
        codec_args = ["-c:v", "prores_ks", "-pix_fmt", "yuva444p12le"]
    else:
        vf = f"chromakey=color={color}:similarity={similarity}:blend={blend}"
        codec_args = ["-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k"]

    _run_ffmpeg([
        "-i", input_path,
        "-vf", vf,
    ] + codec_args + _movflags_args(output) + [
        output,
    ])

    info = probe(output)
    return EditResult(
        output_path=output,
        duration=info.duration,
        resolution=info.resolution,
        size_mb=info.size_mb,
        format="mp4",
        operation="chroma_key",
    )


# ---------------------------------------------------------------------------
# Subtitle generation
# ---------------------------------------------------------------------------

def generate_subtitles(
    entries: list[dict],
    input_path: str,
    output_path: str | None = None,
    burn: bool = False,
) -> SubtitleResult:
    """Generate SRT subtitles from text entries and optionally burn into video.

    Args:
        entries: List of dicts with keys: start (float), end (float), text (str).
        input_path: Path to the input video.
        output_path: Base path for output files.
        burn: If True, burn subtitles into the video.
    """
    _validate_input(input_path)
    if not entries:
        raise MCPVideoError(
            "entries cannot be empty",
            error_type="validation_error",
            code="empty_entries",
        )
    for i, entry in enumerate(entries):
        start = entry.get("start", 0)
        end = entry.get("end", 0)
        if start >= end:
            raise MCPVideoError(
                f"Entry {i}: start ({start}) must be less than end ({end})",
                error_type="validation_error",
                code="invalid_entry_range",
            )

    # Build SRT content
    srt_lines: list[str] = []
    for i, entry in enumerate(entries, 1):
        start = entry["start"]
        end = entry["end"]
        text = entry["text"]
        srt_lines.append(str(i))
        srt_lines.append(_seconds_to_srt_time(start) + " --> " + _seconds_to_srt_time(end))
        srt_lines.append(text)
        srt_lines.append("")

    srt_content = "\n".join(srt_lines)

    # Write SRT file
    if output_path:
        srt_dir = output_path if os.path.isdir(output_path) else os.path.dirname(output_path) or "."
        os.makedirs(srt_dir, exist_ok=True)
    else:
        srt_dir = _auto_output_dir(input_path, "subtitles")
        os.makedirs(srt_dir, exist_ok=True)

    srt_filename = "subtitles.srt"
    srt_file = os.path.join(srt_dir, srt_filename)
    with open(srt_file, "w", encoding="utf-8") as f:
        f.write(srt_content)

    if burn:
        _require_filter("subtitles", "Subtitle burn-in")
        video_out = os.path.join(srt_dir, "subtitled.mp4")
        escaped_srt = _escape_ffmpeg_filter_value(srt_file)
        _run_ffmpeg([
            "-i", input_path,
            "-vf", f"subtitles={escaped_srt}",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-c:a", "copy",
        ] + _movflags_args(video_out) + [
            video_out,
        ])
        return SubtitleResult(
            srt_path=srt_file,
            video_path=video_out,
            entry_count=len(entries),
        )

    return SubtitleResult(
        srt_path=srt_file,
        entry_count=len(entries),
    )


def _seconds_to_srt_time(seconds: float) -> str:
    """Convert seconds to SRT time format HH:MM:SS,mmm."""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    secs = int(seconds % 60)
    millis = int((seconds % 1) * 1000)
    return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"


# ---------------------------------------------------------------------------
# Audio waveform extraction
# ---------------------------------------------------------------------------

def audio_waveform(
    input_path: str,
    bins: int = 50,
) -> WaveformResult:
    """Extract audio waveform data (peaks and silence regions).

    Args:
        input_path: Path to the input video/audio file.
        bins: Number of time segments to analyze (default 50).
    """
    _validate_input(input_path)

    input_info = probe(input_path)
    if input_info.audio_codec is None:
        raise MCPVideoError(
            "Audio waveform extraction requires an audio stream, but this video has none",
            error_type="validation_error",
            code="waveform_no_audio",
        )

    duration = input_info.duration
    segment_duration = duration / bins

    # Use astats filter to get per-segment audio levels
    filter_str = f"astats=metadata=1:reset=0,ametadata=1"
    proc = subprocess.run(
        [_ffmpeg(), "-i", input_path, "-af", filter_str, "-f", "null", "-"],
        capture_output=True, text=True, timeout=120,
    )
    # Check return code - if FFmpeg failed, fall through to silencedetect fallback
    if proc.returncode != 0:
        levels = []  # Force fallback to silencedetect

    # Parse astats output for DC_offset and RMS level
    peaks: list[dict] = []
    levels: list[float] = []

    for line in proc.stderr.split("\n"):
        line = line.strip()
        if "Parsed_dc" in line or "n_samples" in line:
            continue
        if "RMS_level_dB" in line:
            try:
                # Format: [Parsed_astats_...] RMS_level_dB=...
                parts = line.split("RMS_level_dB=")
                if len(parts) >= 2:
                    val = float(parts[1].split()[0])
                    levels.append(val)
            except (ValueError, IndexError):
                continue

    # If astats didn't produce usable data, use ffprobe + silence detect
    if not levels:
        # Fallback: use silencedetect to find silence regions
        proc2 = subprocess.run(
            [
                _ffprobe(), "-v", "quiet",
                "-f", "lavfi",
                f"ametadata=mode=print:silence",
                "-i", input_path,
            ],
            capture_output=True, text=True, timeout=120,
        )

        # Build a simple waveform from silence detection
        silence_regions: list[dict] = []
        for line in proc2.stdout.split("\n"):
            if "silence_start" in line and "silence_end" in line:
                try:
                    start = float(line.split("silence_start=")[1].split()[0])
                    end = float(line.split("silence_end=")[1].split()[0])
                    silence_regions.append({"start": start, "end": end})
                except (ValueError, IndexError):
                    continue

        # Generate simple peak estimates (sine wave as placeholder when real data unavailable)
        for i in range(bins):
            t = (i + 0.5) * segment_duration
            in_silence = any(s.get("start", 0) <= t <= s.get("end", 0) for s in silence_regions)
            level = -60.0 if in_silence else -20.0
            peaks.append({"time": round(t, 2), "level": level})
            levels.append(level)

        mean_level = sum(levels) / len(levels) if levels else -60.0
        max_level = max(levels) if levels else -60.0
        min_level = min(levels) if levels else -60.0

        return WaveformResult(
            duration=duration,
            peaks=peaks,
            mean_level=mean_level,
            max_level=max_level,
            min_level=min_level,
            silence_regions=silence_regions,
            synthetic=True,
        )

    # Use the RMS levels from astats
    # astats outputs one line per audio frame, we need to bin them
    # Typically there are ~90 lines per 3s video at 30fps
    if len(levels) > 0:
        # Bin the levels into the requested number of segments
        samples_per_bin = max(1, len(levels) // bins)
        binned: list[float] = []
        for i in range(bins):
            start_idx = i * samples_per_bin
            end_idx = min((i + 1) * samples_per_bin, len(levels))
            if start_idx < len(levels):
                bin_avg = sum(levels[start_idx:end_idx]) / (end_idx - start_idx)
                binned.append(bin_avg)

        peaks = []
        for i, level in enumerate(binned):
            t = (i + 0.5) * segment_duration
            peaks.append({"time": round(t, 2), "level": round(level, 1)})

        # Detect silence (below -50 dB)
        silence_threshold = -50.0
        silence_regions: list[dict] = []
        in_silence = False
        silence_start = 0.0
        for i, level in enumerate(binned):
            t = (i + 0.5) * segment_duration
            if level < silence_threshold and not in_silence:
                in_silence = True
                silence_start = t
            elif level >= silence_threshold and in_silence:
                in_silence = False
                silence_regions.append({"start": round(silence_start, 2), "end": round(t, 2)})
        if in_silence:
            silence_regions.append({"start": round(silence_start, 2), "end": round(duration, 2)})

        mean_level = sum(binned) / len(binned) if binned else -60.0
        max_level = max(binned) if binned else -60.0
        min_level = min(binned) if binned else -60.0
    else:
        peaks = []
        silence_regions = []
        mean_level = -60.0
        max_level = -60.0
        min_level = -60.0

    return WaveformResult(
        duration=duration,
        peaks=peaks,
        mean_level=round(mean_level, 1),
        max_level=round(max_level, 1),
        min_level=round(min_level, 1),
        silence_regions=silence_regions,
    )


# ---------------------------------------------------------------------------
# Scene detection
# ---------------------------------------------------------------------------

def detect_scenes(
    input_path: str,
    threshold: float = 0.3,
    min_scene_duration: float = 1.0,
) -> SceneDetectionResult:
    """Detect scene changes in a video.

    Args:
        input_path: Path to the input video.
        threshold: Scene detection sensitivity (0.0-1.0, lower = more sensitive).
        min_scene_duration: Minimum duration of a scene in seconds.
    """
    _validate_input(input_path)
    info = probe(input_path)
    duration = info.duration

    # Use FFmpeg select filter with scene detection
    proc = subprocess.run(
        [
            _ffmpeg(), "-i", input_path,
            "-vf", f"select='gt(scene,{threshold})',showinfo",
            "-f", "null", "-",
        ],
        capture_output=True, text=True, timeout=300,
    )
    if proc.returncode != 0:
        raise parse_ffmpeg_error(proc.stderr)

    # Parse showinfo output for scene change timestamps
    scene_times: list[float] = []
    for line in proc.stderr.split("\n"):
        if "showinfo" in line and "pts_time:" in line:
            try:
                # Format: [showinfo @ ...] pts_time:X ...
                pts_match = re.search(r"pts_time:(\d+\.?\d*)", line)
                if pts_match:
                    scene_times.append(float(pts_match.group(1)))
            except (ValueError, IndexError):
                continue

    # Build scene list from timestamps
    scenes: list[dict] = []
    prev_time = 0.0
    for t in scene_times:
        if t - prev_time >= min_scene_duration:
            scenes.append({
                "start": round(prev_time, 2),
                "end": round(t, 2),
                "start_frame": round(prev_time * info.fps),
                "end_frame": round(t * info.fps),
            })
            prev_time = t

    # Add final scene
    if duration - prev_time >= 0.1:
        scenes.append({
            "start": round(prev_time, 2),
            "end": round(duration, 2),
            "start_frame": round(prev_time * info.fps),
            "end_frame": round(duration * info.fps),
        })

    return SceneDetectionResult(
        scenes=scenes,
        scene_count=len(scenes),
        duration=duration,
    )


# ---------------------------------------------------------------------------
# Image sequences
# ---------------------------------------------------------------------------

def create_from_images(
    images: list[str],
    output_path: str | None = None,
    fps: float = 30.0,
) -> EditResult:
    """Create a video from a sequence of images.

    Args:
        images: List of image file paths.
        output_path: Where to save the output video.
        fps: Frames per second for the output video.
    """
    if not images:
        raise MCPVideoError(
            "No images provided",
            error_type="validation_error",
            code="empty_images",
        )
    for img in images:
        if not os.path.isfile(img):
            raise InputFileError(img)

    output = output_path or _auto_output(images[0], "from_images")
    tmpdir = tempfile.mkdtemp(prefix="mcp_video_imgseq_")
    try:
        # Detect if any input is PNG (has alpha channel)
        has_png = any(img.lower().endswith(".png") for img in images)
        img_format = "png" if has_png else "jpg"
        ext = f".{img_format}"

        # Normalize all images to same dimensions first
        normalized: list[str] = []
        for i, img in enumerate(images):
            norm_path = os.path.join(tmpdir, f"img_{i:04d}{ext}")
            if img_format == "png":
                _run_ffmpeg([
                    "-y", "-i", img,
                    "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
                    "-c:v", "png",
                    norm_path,
                ])
            else:
                _run_ffmpeg([
                    "-y", "-i", img,
                    "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
                    "-q:v", "2",
                    norm_path,
                ])
            normalized.append(norm_path)

        # Build concat file
        concat_file = os.path.join(tmpdir, "concat.txt")
        img_duration = 1.0 / fps
        with open(concat_file, "w") as f:
            for img in normalized:
                abs_path = os.path.abspath(img).replace("'", "'\\''")
                f.write(f"file '{abs_path}'\n")
                f.write(f"duration {img_duration}\n")
            abs_last = os.path.abspath(normalized[-1]).replace("'", "'\\''")
            f.write(f"file '{abs_last}'\n")

        _run_ffmpeg([
            "-f", "concat", "-safe", "0",
            "-i", concat_file,
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-pix_fmt", "yuv420p",
        ] + _movflags_args(output) + [
            output,
        ])
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="create_from_images",
    )


def export_frames(
    input_path: str,
    output_dir: str | None = None,
    fps: float = 1.0,
    format: str = "jpg",
) -> ImageSequenceResult:
    """Export frames from a video as individual images.

    Args:
        input_path: Path to the input video.
        output_dir: Directory for extracted frames.
        fps: Frames per second to extract (1.0 = 1 frame per second).
        format: Output image format (jpg, png).
    """
    _validate_input(input_path)
    if format not in ("jpg", "png"):
        raise MCPVideoError(
            f"Invalid format '{format}': must be 'jpg' or 'png'",
            error_type="validation_error",
            code="invalid_format",
        )
    info = probe(input_path)

    out_dir = output_dir or _auto_output_dir(input_path, "frames")
    os.makedirs(out_dir, exist_ok=True)

    ext = format if format.startswith(".") else f".{format}"
    pattern = os.path.join(out_dir, f"frame_%04d{ext}")

    _run_ffmpeg([
        "-i", input_path,
        "-vf", f"fps={fps}",
        "-q:v", "2",
        "-y",
        pattern,
    ])

    # Collect generated frame paths
    frame_paths = sorted([
        os.path.join(out_dir, f)
        for f in os.listdir(out_dir)
        if f.startswith("frame_") and f.endswith(ext)
    ])

    return ImageSequenceResult(
        frame_paths=frame_paths,
        frame_count=len(frame_paths),
        fps=fps,
    )


# ---------------------------------------------------------------------------
# Quality metrics
# ---------------------------------------------------------------------------

def compare_quality(
    original_path: str,
    distorted_path: str,
    metrics: list[str] | None = None,
) -> QualityMetricsResult:
    """Compare video quality between original and distorted versions.

    Args:
        original_path: Path to the original/reference video.
        distorted_path: Path to the distorted/processed video.
        metrics: List of metrics to compute (default: ["psnr", "ssim"]).
    """
    _validate_input(original_path)
    _validate_input(distorted_path)
    metrics = metrics or ["psnr", "ssim"]

    computed: dict[str, float] = {}

    for metric in metrics:
        metric_lower = metric.lower()
        if metric_lower not in ("psnr", "ssim"):
            continue

        try:
            # Get original resolution to scale distorted video to match
            orig_info = probe(original_path)
            target_w = orig_info.width
            target_h = orig_info.height
            
            # Scale distorted to match original resolution, then compare
            filter_str = f"[1:v]scale={target_w}:{target_h}[scaled];[0:v][scaled]{metric_lower}"
            proc = subprocess.run(
                [
                    _ffmpeg(), "-i", original_path, "-i", distorted_path,
                    "-lavfi", filter_str,
                    "-f", "null", "-",
                ],
                capture_output=True, text=True, timeout=300,
            )
            if proc.returncode != 0:
                raise ProcessingError(
                    f"ffmpeg -i {original_path} -i {distorted_path} -lavfi {metric_lower}",
                    proc.returncode,
                    proc.stderr[:500],
                )

            # Parse metric value from stderr
            for line in proc.stderr.split("\n"):
                if metric_lower == "psnr" and "average:" in line.lower():
                    try:
                        # Format: [Parsed_psnr ...] average:XX.XX
                        val_match = re.search(r"average:\s*([0-9.]+)", line, re.IGNORECASE)
                        if val_match:
                            computed["psnr"] = float(val_match.group(1))
                    except (ValueError, IndexError):
                        continue
                elif metric_lower == "ssim" and "All:" in line:
                    try:
                        # Format: [Parsed_ssim ...] All:X.XXXXXX
                        val_match = re.search(r"All[:\s]+([0-9.]+)", line)
                        if val_match:
                            computed["ssim"] = float(val_match.group(1))
                    except (ValueError, IndexError):
                        continue
        except Exception as e:
            if isinstance(e, ProcessingError):
                raise
            raise ProcessingError(
                f"ffmpeg -i {original_path} -i {distorted_path} -lavfi {metric_lower}",
                1,
                str(e)[:500],
            ) from e

    # Determine overall quality
    if "ssim" in computed:
        ssim_val = computed["ssim"]
        if ssim_val >= 0.95:
            overall = "high"
        elif ssim_val >= 0.80:
            overall = "medium"
        else:
            overall = "low"
    elif "psnr" in computed:
        psnr_val = computed["psnr"]
        if psnr_val >= 40:
            overall = "high"
        elif psnr_val >= 30:
            overall = "medium"
        else:
            overall = "low"
    else:
        overall = "unknown"

    return QualityMetricsResult(
        metrics=computed,
        overall_quality=overall,
    )


# ---------------------------------------------------------------------------
# Metadata editing
# ---------------------------------------------------------------------------

def read_metadata(input_path: str) -> MetadataResult:
    """Read metadata tags from a video/audio file.

    Args:
        input_path: Path to the input file.
    """
    _validate_input(input_path)
    data = _run_ffprobe_json(input_path)

    # Extract tags from format
    fmt_tags = data.get("format", {}).get("tags", {})
    # Also check stream tags
    stream_tags: dict[str, str] = {}
    for stream in data.get("streams", []):
        for k, v in stream.get("tags", {}).items():
            if k not in stream_tags:
                stream_tags[k] = v

    all_tags = {**stream_tags, **fmt_tags}

    return MetadataResult(
        title=all_tags.pop("title", None),
        artist=all_tags.pop("artist", None),
        album=all_tags.pop("album", None),
        comment=all_tags.pop("comment", None),
        date=all_tags.pop("date", None) or all_tags.pop("creation_time", None),
        tags=all_tags,
    )


def write_metadata(
    input_path: str,
    metadata: dict[str, str],
    output_path: str | None = None,
) -> EditResult:
    """Write metadata tags to a video/audio file.

    Args:
        input_path: Path to the input file.
        metadata: Dict of tag key-value pairs (e.g. {"title": "My Video", "artist": "Me"}).
        output_path: Where to save the output. If None, overwrites in place with a temp file.
    """
    _validate_input(input_path)
    if not metadata:
        raise MCPVideoError(
            "No metadata provided",
            error_type="validation_error",
            code="empty_metadata",
        )

    # Validate metadata keys and values don't contain '=' or newline which would break the command
    for key, value in metadata.items():
        if "=" in key or "\n" in key:
            raise MCPVideoError(
                f"Invalid metadata key '{key}': keys cannot contain '=' or newline characters",
                error_type="validation_error",
                code="invalid_metadata_key",
            )
        if "=" in str(value) or "\n" in str(value):
            raise MCPVideoError(
                f"Invalid metadata value for '{key}': values cannot contain '=' or newline characters",
                error_type="validation_error",
                code="invalid_metadata_value",
            )

    output = output_path or _auto_output(input_path, "tagged")

    args = ["-i", input_path]
    for key, value in metadata.items():
        args.extend(["-metadata", f"{key}={value}"])
    args.extend([
        "-c:v", "copy", "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])
    _run_ffmpeg(args)

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format=result_info.format,
        operation="write_metadata",
    )


# ---------------------------------------------------------------------------
# Video stabilization
# ---------------------------------------------------------------------------

def stabilize(
    input_path: str,
    smoothing: float = 15,
    zooming: float = 0,
    output_path: str | None = None,
) -> EditResult:
    """Stabilize a shaky video using motion vector analysis.

    Uses vidstab filter (two-pass: detect then transform).

    Args:
        input_path: Path to the input video.
        smoothing: Smoothing strength (default 15, higher = more stable).
        zooming: Zoom percentage to avoid black borders (default 0).
        output_path: Where to save the output.
    """
    _validate_input(input_path)
    _require_filter("vidstabdetect", "Video stabilization")
    output = output_path or _auto_output(input_path, "stabilized")

    tmpdir = tempfile.mkdtemp(prefix="mcp_video_stab_")
    try:
        # Pass 1: detect motion vectors
        vectors_file = os.path.join(tmpdir, "vectors.trf")
        result = subprocess.run(
            [
                _ffmpeg(), "-y",
                "-i", input_path,
                "-vf", "vidstabdetect=shakiness=10:accuracy=15:result=" + vectors_file,
                "-f", "null", "-",
            ],
            capture_output=True, text=True, timeout=600,
        )
        if result.returncode != 0:
            raise parse_ffmpeg_error(result.stderr)

        # Pass 2: apply stabilization
        _run_ffmpeg([
            "-i", input_path,
            "-vf", f"vidstabtransform=input={vectors_file}:smoothing={smoothing}:zoom={zooming}:crop=black",
            "-c:v", "libx264", "-preset", "fast", "-crf", "23",
            "-c:a", "aac", "-b:a", "128k",
        ] + _movflags_args(output) + [
            output,
        ])
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="stabilize",
    )


# ---------------------------------------------------------------------------
# Advanced masking
# ---------------------------------------------------------------------------

def apply_mask(
    input_path: str,
    mask_path: str,
    feather: int = 5,
    output_path: str | None = None,
) -> EditResult:
    """Apply an image mask to a video with edge feathering.

    Uses alphamerge filter to composite the mask as an alpha channel.

    Args:
        input_path: Path to the input video.
        mask_path: Path to the mask image (white = visible, black = transparent).
        feather: Feather/blur amount at mask edges in pixels (default 5).
        output_path: Where to save the output.
    """
    _validate_input(input_path)
    _validate_input(mask_path)
    _require_filter("alphamerge", "Advanced masking")
    output = output_path or _auto_output(input_path, "masked")

    # Get video dimensions to scale mask
    info = probe(input_path)
    w, h = info.width, info.height

    # Scale mask to video dimensions, convert to alpha, and alphamerge
    if feather > 0:
        filter_complex = (
            f"[1:v]format=gray,scale={w}:{h},colorchannelmixer=aa=1.0,boxblur={feather}[alpha];"
            f"[0:v][alpha]alphamerge,format=yuv420p[out]"
        )
    else:
        filter_complex = (
            f"[1:v]format=gray,scale={w}:{h},colorchannelmixer=aa=1.0[alpha];"
            f"[0:v][alpha]alphamerge,format=yuv420p[out]"
        )

    _run_ffmpeg([
        "-i", input_path, "-i", mask_path,
        "-filter_complex", filter_complex,
        "-map", "[out]", "-map", "0:a?",
        "-c:v", "libx264", "-preset", "fast", "-crf", "23",
        "-c:a", "copy",
    ] + _movflags_args(output) + [
        output,
    ])

    result_info = probe(output)
    return EditResult(
        output_path=output,
        duration=result_info.duration,
        resolution=result_info.resolution,
        size_mb=result_info.size_mb,
        format="mp4",
        operation="apply_mask",
    )


# ---------------------------------------------------------------------------
# Batch processing
# ---------------------------------------------------------------------------

def video_batch(
    inputs: list[str],
    operation: str,
    params: dict[str, Any] | None = None,
    output_dir: str | None = None,
) -> dict[str, Any]:
    """Apply the same operation to multiple video files.

    Args:
        inputs: List of absolute paths to input video files.
        operation: Operation (trim, resize, convert, filter, blur, color_grade, watermark, speed, fade, normalize_audio).
        params: Parameters for the operation.
        output_dir: Directory for output files. Auto-generated if omitted.
    """
    if not inputs:
        return {"success": False, "error": {"type": "input_error", "code": "empty_inputs", "message": "No input files provided"}}

    params = params or {}
    results = []
    succeeded = 0
    failed = 0

    for input_path in inputs:
        try:
            if output_dir:
                os.makedirs(output_dir, exist_ok=True)

            def _batch_output(ext: str | None = None) -> str:
                """Generate output path in output_dir, or auto-generate."""
                if output_dir:
                    name = os.path.splitext(os.path.basename(input_path))[0]
                    ext = ext or ".mp4"
                    return os.path.join(output_dir, f"{name}_{operation}{ext}")
                return None  # let the engine auto-generate

            if operation == "trim":
                result = trim(input_path, start=params.get("start", "0"), duration=params.get("duration"), end=params.get("end"), output_path=_batch_output())
            elif operation == "resize":
                result = resize(input_path, width=params.get("width"), height=params.get("height"), aspect_ratio=params.get("aspect_ratio"), quality=params.get("quality", "high"), output_path=_batch_output())
            elif operation == "convert":
                out_ext = f".{params.get('format', 'mp4')}"
                result = convert(input_path, format=params.get("format", "mp4"), quality=params.get("quality", "high"), output_path=_batch_output(out_ext))
            elif operation == "filter":
                result = apply_filter(input_path, filter_type=params.get("filter_type", "blur"), params=params.get("filter_params", {}), output_path=_batch_output())
            elif operation == "blur":
                result = apply_filter(input_path, filter_type="blur", params=params.get("filter_params", {}), output_path=_batch_output())
            elif operation == "color_grade":
                result = apply_filter(input_path, filter_type="color_preset", params={"preset": params.get("preset", "warm")}, output_path=_batch_output())
            elif operation == "watermark":
                result = watermark(input_path, image_path=params.get("image_path", ""), position=params.get("position", "bottom-right"), opacity=params.get("opacity", 0.7), output_path=_batch_output())
            elif operation == "speed":
                result = speed(input_path, factor=params.get("factor", 1.0), output_path=_batch_output())
            elif operation == "fade":
                result = fade(input_path, fade_in=params.get("fade_in", 0.5), fade_out=params.get("fade_out", 0.5), output_path=_batch_output())
            elif operation == "normalize_audio":
                result = normalize_audio(input_path, target_lufs=params.get("target_lufs", -16.0), output_path=_batch_output())
            else:
                results.append({"input": input_path, "success": False, "error": f"Unknown operation: {operation}"})
                failed += 1
                continue

            results.append({"input": input_path, "success": True, "output_path": result.output_path})
            succeeded += 1
        except Exception as e:
            results.append({"input": input_path, "success": False, "error": str(e)})
            failed += 1

    return {
        "success": failed == 0,
        "total": len(inputs),
        "succeeded": succeeded,
        "failed": failed,
        "results": results,
    }
