# tools/cli/ffmpeg.py - Dedicated ffmpeg/ffprobe tools for media inspection, conversion, and editing.
# Moved from tools/ffmpeg_tools.py. All path resolution via utils.path_safety.
# Requires ffmpeg (and ffprobe for get_media_info) installed via Mugi Homebrew or system.
from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

from langchain_core.tools import tool

from tools._logging import (
    _debug_log,
    _emit_input_status,
    register_cancellable_subprocess,
    should_stop_tool,
    unregister_cancellable_subprocess,
)
from tools.job_stream_schema import (
    close_job_stream,
    error_job_stream,
    make_capabilities,
    metric_def,
    open_job_stream,
    update_job_stream,
)
from tools.live_view_visuals import emit_live_view_preview, image_to_preview_base64
from tools.plugin_stream_controls import clear_stream_state, get_action_count
from utils.path_safety import (
    get_user_output_dir,
    is_dangerous_path,
    resolve_path_with_vertical_bar_variants,
    safe_resolve_path,
)
from tools.cli.runner import cli_rich_path

_DEFAULT_WORKSPACE = os.path.expanduser("~/.mugi/workspace")
_FFMPEG_TIMEOUT = 600  # 10 minutes for long encodes
_FFMPEG_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+(?:\.\d+)?)")


def _resolve_read(requested: str) -> tuple[str | None, str]:
    """Resolve path for read; return (abs_path, error_msg)."""
    abs_path, err = safe_resolve_path(
        _DEFAULT_WORKSPACE, requested or "", allow_write=False, create_parents=False
    )
    if err or not abs_path:
        return abs_path, err
    return resolve_path_with_vertical_bar_variants(abs_path), ""


def _resolve_write(requested: str) -> tuple[str | None, str]:
    """Resolve path for write; return (abs_path, error_msg)."""
    return safe_resolve_path(_DEFAULT_WORKSPACE, requested or "", allow_write=True)


def _find_ffmpeg() -> tuple[str | None, str]:
    """Return (ffmpeg_path, error_msg). Empty error if found. Checks Mugi Homebrew first, then rich PATH."""
    from persistence import MUGI_DIR
    mugi_bin = os.path.join(MUGI_DIR, "homebrew", "bin", "ffmpeg")
    if os.path.isfile(mugi_bin):
        return mugi_bin, ""
    path = shutil.which("ffmpeg", path=cli_rich_path())
    if path:
        return path, ""
    return None, "ffmpeg not found. Use ensure_mugi_homebrew then mugi_brew_install('ffmpeg') to install, or install ffmpeg on your system."


def _find_ffprobe() -> tuple[str | None, str]:
    """Return (ffprobe_path, error_msg). Empty error if found. Checks Mugi Homebrew first, then rich PATH."""
    from persistence import MUGI_DIR
    mugi_bin = os.path.join(MUGI_DIR, "homebrew", "bin", "ffprobe")
    if os.path.isfile(mugi_bin):
        return mugi_bin, ""
    path = shutil.which("ffprobe", path=cli_rich_path())
    if path:
        return path, ""
    return None, "ffprobe not found. Install ffmpeg (includes ffprobe) via Mugi Homebrew or system."


def _run_ffmpeg(
    args: list[str],
    timeout: int = _FFMPEG_TIMEOUT,
    *,
    job_type: str = "ffmpeg_task",
    title: str = "FFmpeg Task",
) -> tuple[bool, str]:
    """Run ffmpeg with args (without the 'ffmpeg' binary name). Return (success, output_or_error)."""
    ffmpeg_path, err = _find_ffmpeg()
    if err:
        return False, err
    cmd = [ffmpeg_path, "-y"] + args  # -y overwrite output
    _debug_log(f"[ffmpeg] Running: {' '.join(cmd)}")
    stream_id = f"media_transcode:{job_type}:{int(time.time() * 1000)}"
    open_job_stream(
        plugin_id="media_transcode",
        plugin_name="Media Transcode",
        title=title,
        job_type=job_type,
        stream_id=stream_id,
        controls=[
            {
                "id": "stop_task",
                "type": "button",
                "label": "Stop Task",
                "action_id": "stop_task",
                "style": "destructive",
            }
        ],
        capabilities=make_capabilities(
            cancel=True,
            progress=True,
            metrics=True,
            phases=True,
            stream_logs=True,
            eta=True,
            artifacts=True,
        ),
        metric_schema=[
            metric_def("progress_pct", "Progress", unit="%", format="percent", priority=1),
            metric_def("processed_seconds", "Processed Time", unit="s", format="duration_s", priority=3),
        ],
        metadata={"command": " ".join(cmd[:12]) + (" ..." if len(cmd) > 12 else "")},
    )
    try:
        update_job_stream(
            plugin_id="media_transcode",
            plugin_name="Media Transcode",
            stream_id=stream_id,
            phase="run",
            status="running",
            progress_pct=0,
            message=f"Running {title}...",
        )
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
        )
        register_cancellable_subprocess(proc)
        try:
            stderr_lines: list[str] = []
            stdout_lines: list[str] = []
            last_emit = 0.0
            while True:
                if should_stop_tool() or get_action_count(stream_id, "stop_task") > 0:
                    try:
                        proc.terminate()
                    except Exception:
                        pass
                    close_job_stream(
                        plugin_id="media_transcode",
                        plugin_name="Media Transcode",
                        stream_id=stream_id,
                        status="stopped",
                        message=f"{title} stopped by user.",
                    )
                    return False, f"{title} stopped by user."
                line = proc.stderr.readline() if proc.stderr else ""
                if line:
                    txt = line.rstrip("\n")
                    stderr_lines.append(txt)
                    m = _FFMPEG_TIME_RE.search(txt)
                    progress = None
                    proc_seconds = None
                    if m:
                        try:
                            proc_seconds = (int(m.group(1)) * 3600) + (int(m.group(2)) * 60) + float(m.group(3))
                        except Exception:
                            proc_seconds = None
                    now = time.time()
                    if now - last_emit > 1.2:
                        metrics = {}
                        if proc_seconds is not None:
                            metrics["processed_seconds"] = float(proc_seconds)
                        update_job_stream(
                            plugin_id="media_transcode",
                            plugin_name="Media Transcode",
                            stream_id=stream_id,
                            phase="run",
                            status="running",
                            progress_pct=progress,
                            message=txt[:140] or f"{title} in progress...",
                            metrics=metrics,
                            extra={"log_line": txt[-400:]},
                        )
                        last_emit = now
                else:
                    if proc.poll() is not None:
                        break
                    time.sleep(0.05)
            out = (proc.stdout.read() if proc.stdout else "").strip()
            if out:
                stdout_lines.append(out)
            err_out = "\n".join(stderr_lines).strip()
            if proc.returncode != 0:
                msg = err_out or out or f"Exit code {proc.returncode}"
                error_job_stream(
                    plugin_id="media_transcode",
                    plugin_name="Media Transcode",
                    stream_id=stream_id,
                    phase="run",
                    message=msg,
                )
                return False, msg
            close_job_stream(
                plugin_id="media_transcode",
                plugin_name="Media Transcode",
                stream_id=stream_id,
                status="completed",
                message=f"{title} completed.",
                metrics={"progress_pct": 100.0},
            )
            return True, out or "Done."
        finally:
            unregister_cancellable_subprocess(proc)
    except subprocess.TimeoutExpired:
        error_job_stream(
            plugin_id="media_transcode",
            plugin_name="Media Transcode",
            stream_id=stream_id,
            phase="run",
            message=f"ffmpeg timed out after {timeout} seconds.",
        )
        return False, f"ffmpeg timed out after {timeout} seconds."
    except Exception as e:
        error_job_stream(
            plugin_id="media_transcode",
            plugin_name="Media Transcode",
            stream_id=stream_id,
            phase="run",
            message=str(e),
        )
        return False, str(e)
    finally:
        clear_stream_state(stream_id)


def _run_ffprobe(args: list[str], timeout: int = 30) -> tuple[bool, str]:
    """Run ffprobe with args (without the 'ffprobe' binary name). Return (success, output_or_error)."""
    ffprobe_path, err = _find_ffprobe()
    if err:
        return False, err
    cmd = [ffprobe_path] + args
    _debug_log(f"[ffprobe] Running: {' '.join(cmd)}")
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        out = (result.stdout or "").strip()
        err_out = (result.stderr or "").strip()
        if result.returncode != 0:
            return False, err_out or out or f"Exit code {result.returncode}"
        return True, out
    except subprocess.TimeoutExpired:
        return False, f"ffprobe timed out after {timeout} seconds."
    except Exception as e:
        return False, str(e)


def _emit_video_frame_preview(
    video_path: str,
    tool_name: str,
    message: str,
) -> None:
    """Extract one frame from a video file and emit as Live View preview. No-op on any failure."""
    if not video_path or not os.path.isfile(video_path):
        return
    ext = (os.path.splitext(video_path)[1] or "").lower()
    if ext not in (".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"):
        return
    ffmpeg_path, err = _find_ffmpeg()
    if err:
        return
    try:
        fd, frame_path = tempfile.mkstemp(suffix=".jpg", prefix="lv_frame_")
        os.close(fd)
        try:
            args = ["-i", video_path, "-vframes", "1", "-q:v", "2", "-y", frame_path]
            result = subprocess.run(
                [ffmpeg_path, "-y"] + args,
                capture_output=True,
                text=True,
                timeout=15,
            )
            if result.returncode != 0 or not os.path.isfile(frame_path):
                return
            with open(frame_path, "rb") as f:
                b64 = image_to_preview_base64(f.read(), max_width=400)
            if b64:
                emit_live_view_preview(
                    message,
                    tool_name=tool_name,
                    preview_image_base64=b64,
                )
        finally:
            try:
                os.unlink(frame_path)
            except OSError:
                pass
    except Exception:
        pass


# ---------------------------------------------------------------------------
# 1. get_media_info
# ---------------------------------------------------------------------------
@tool
def get_media_info(filepath: str) -> str:
    """Get metadata for a media file (duration, codecs, resolution, bitrate, streams). Uses ffprobe. Input: path to video or audio file."""
    _debug_log(f"[DEBUG get_media_info] {filepath!r}")
    _emit_input_status("Inspecting media…")
    emit_live_view_preview("Inspecting media…", tool_name="get_media_info")
    abs_path, err = _resolve_read(filepath)
    if err:
        return err
    if not abs_path or not os.path.isfile(abs_path):
        return f"File not found: {filepath}"
    ok, out = _run_ffprobe([
        "-v", "quiet", "-print_format", "json",
        "-show_format", "-show_streams",
        abs_path,
    ])
    if not ok:
        return out
    try:
        data = json.loads(out)
        lines = []
        fmt = data.get("format", {})
        if fmt:
            lines.append("Format:")
            lines.append(f"  duration: {fmt.get('duration', 'N/A')} s")
            lines.append(f"  size: {fmt.get('size', 'N/A')} bytes")
            lines.append(f"  bit_rate: {fmt.get('bit_rate', 'N/A')}")
            lines.append(f"  format_name: {fmt.get('format_name', 'N/A')}")
        for i, s in enumerate(data.get("streams", [])):
            codec = s.get("codec_type")
            lines.append(f"Stream {i} ({codec}):")
            lines.append(f"  codec: {s.get('codec_name', 'N/A')}")
            if codec == "video":
                lines.append(f"  width x height: {s.get('width', '?')} x {s.get('height', '?')}")
                lines.append(f"  r_frame_rate: {s.get('r_frame_rate', 'N/A')}")
            if codec == "audio":
                lines.append(f"  sample_rate: {s.get('sample_rate', 'N/A')}")
                lines.append(f"  channels: {s.get('channels', 'N/A')}")
        return "\n".join(lines) if lines else out
    except json.JSONDecodeError:
        return out


# ---------------------------------------------------------------------------
# 2. convert_video
# ---------------------------------------------------------------------------
@tool
def convert_video(
    input_path: str,
    output_path: str,
    output_format: str = "mp4",
    resolution: str = "",
    quality_crf: int = 23,
) -> str:
    """Convert or transcode a video to another format/codec/resolution. Inputs: input_path, output_path, optional output_format (e.g. mp4, webm), resolution (e.g. 1280x720), quality_crf (18-28, lower=better)."""
    _debug_log(f"[DEBUG convert_video] in={input_path!r} out={output_path!r}")
    _emit_input_status("Converting video…")
    emit_live_view_preview("Converting video…", tool_name="convert_video", progress_pct=0)
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    ext = output_format.lower().replace(".", "")
    args = ["-i", inp]
    if resolution:
        args.extend(["-vf", f"scale={resolution}"])
    if ext == "webm":
        args.extend(["-c:v", "libvpx-vp9", "-crf", str(max(0, min(63, quality_crf)))])
        args.extend(["-c:a", "libopus"])
    else:
        args.extend(["-c:v", "libx264", "-crf", str(max(0, min(51, quality_crf)))])
        args.extend(["-c:a", "aac"])
    args.append(out)
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        emit_live_view_preview("Video converted.", tool_name="convert_video", progress_pct=100)
        _emit_video_frame_preview(out, "convert_video", "Video converted")
    return msg if ok else f"convert_video failed: {msg}"


# ---------------------------------------------------------------------------
# 3. extract_audio
# ---------------------------------------------------------------------------
@tool
def extract_audio(
    input_path: str,
    output_path: str,
    format: str = "mp3",
    bitrate: str = "192k",
) -> str:
    """Extract audio from a video (or audio) file and save as an audio file. Inputs: input_path, output_path, optional format (mp3, m4a, wav, etc.), bitrate (e.g. 192k)."""
    _debug_log(f"[DEBUG extract_audio] in={input_path!r} out={output_path!r}")
    _emit_input_status("Extracting audio…")
    emit_live_view_preview("Extracting audio…", tool_name="extract_audio", progress_pct=0)
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    fmt = format.lower().replace(".", "")
    args = ["-i", inp, "-vn"]
    if fmt == "mp3":
        args.extend(["-acodec", "libmp3lame", "-b:a", bitrate])
    elif fmt in ("m4a", "aac"):
        args.extend(["-acodec", "aac", "-b:a", bitrate])
    else:
        args.extend(["-acodec", "pcm_s16le" if fmt == "wav" else "copy"])
    args.append(out)
    ok, msg = _run_ffmpeg(args)
    if ok:
        emit_live_view_preview("Audio extracted.", tool_name="extract_audio", progress_pct=100)
    return msg if ok else f"extract_audio failed: {msg}"


# ---------------------------------------------------------------------------
# 4. convert_audio
# ---------------------------------------------------------------------------
@tool
def convert_audio(
    input_path: str,
    output_path: str,
    output_format: str = "mp3",
    bitrate: str = "192k",
) -> str:
    """Convert an audio file to another audio format (e.g. WAV to MP3, FLAC to M4A). Inputs: input_path, output_path, optional output_format, bitrate."""
    _debug_log(f"[DEBUG convert_audio] in={input_path!r} out={output_path!r}")
    _emit_input_status("Converting audio…")
    emit_live_view_preview("Converting audio…", tool_name="convert_audio", progress_pct=0)
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    fmt = output_format.lower().replace(".", "")
    args = ["-i", inp, "-vn"]
    if fmt == "mp3":
        args.extend(["-acodec", "libmp3lame", "-b:a", bitrate])
    elif fmt in ("m4a", "aac"):
        args.extend(["-acodec", "aac", "-b:a", bitrate])
    else:
        args.extend(["-acodec", "pcm_s16le" if fmt == "wav" else "copy"])
    args.append(out)
    ok, msg = _run_ffmpeg(args)
    if ok:
        emit_live_view_preview("Audio converted.", tool_name="convert_audio", progress_pct=100)
    return msg if ok else f"convert_audio failed: {msg}"


# ---------------------------------------------------------------------------
# 5. trim_media
# ---------------------------------------------------------------------------
@tool
def trim_media(
    input_path: str,
    output_path: str,
    start_seconds: float = 0.0,
    duration_seconds: float = 0.0,
    end_seconds: float = 0.0,
) -> str:
    """Trim a segment from a video or audio file. Give start_seconds and either duration_seconds or end_seconds. Inputs: input_path, output_path, start_seconds, duration_seconds (or end_seconds)."""
    _debug_log(f"[DEBUG trim_media] in={input_path!r} out={output_path!r} start={start_seconds} dur={duration_seconds} end={end_seconds}")
    _emit_input_status("Trimming media…")
    emit_live_view_preview("Trimming media…", tool_name="trim_media", progress_pct=0)
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    duration = duration_seconds
    if duration <= 0 and end_seconds > start_seconds:
        duration = end_seconds - start_seconds
    if duration <= 0:
        return "Must specify either duration_seconds or end_seconds > start_seconds."
    args = ["-ss", str(start_seconds), "-i", inp, "-t", str(duration), "-c", "copy", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        emit_live_view_preview("Media trimmed.", tool_name="trim_media", progress_pct=100)
        _emit_video_frame_preview(out, "trim_media", "Media trimmed")
    return msg if ok else f"trim_media failed: {msg}"


# ---------------------------------------------------------------------------
# 6. concat_media
# ---------------------------------------------------------------------------
@tool
def concat_media(input_paths: str, output_path: str, reencode: bool = False) -> str:
    """Concatenate multiple media files in order into one file. Input paths as comma- or newline-separated list. Set reencode=True if codecs differ. Inputs: input_paths (comma/newline separated), output_path, optional reencode (default False)."""
    _debug_log(f"[DEBUG concat_media] paths={input_paths!r} out={output_path!r}")
    _emit_input_status("Concatenating media…")
    emit_live_view_preview("Concatenating media…", tool_name="concat_media", progress_pct=0)
    out, e_out = _resolve_write(output_path)
    if e_out:
        return e_out
    paths = [p.strip().strip('"') for p in input_paths.replace("\n", ",").split(",") if p.strip()]
    if not paths:
        return "No input paths provided."
    resolved = []
    for p in paths:
        abs_p, err = _resolve_read(p)
        if err:
            return err
        if not abs_p or not os.path.isfile(abs_p):
            return f"Input file not found: {p}"
        resolved.append(abs_p)
    try:
        fd, concat_list = tempfile.mkstemp(suffix=".txt", prefix="ffmpeg_concat_")
        try:
            with os.fdopen(fd, "w") as f:
                for p in resolved:
                    esc = p.replace("\\", "\\\\").replace("'", "'\\''")
                    f.write(f"file '{esc}'\n")
            args = ["-f", "concat", "-safe", "0", "-i", concat_list]
            if reencode:
                args.extend(["-c:v", "libx264", "-c:a", "aac"])
            else:
                args.extend(["-c", "copy"])
            args.append(out)
            ok, msg = _run_ffmpeg(args)
            if ok and out:
                emit_live_view_preview("Media concatenated.", tool_name="concat_media", progress_pct=100)
                _emit_video_frame_preview(out, "concat_media", "Media concatenated")
            return msg if ok else f"concat_media failed: {msg}"
        finally:
            try:
                os.unlink(concat_list)
            except OSError:
                pass
    except Exception as e:
        return f"concat_media failed: {e}"


# ---------------------------------------------------------------------------
# 7. extract_frame
# ---------------------------------------------------------------------------
@tool
def extract_frame(
    input_path: str,
    output_path: str,
    time_seconds: float = 0.0,
) -> str:
    """Export a single frame from a video as an image (e.g. PNG or JPEG). Inputs: input_path, output_path, optional time_seconds (default 0 = first frame)."""
    _debug_log(f"[DEBUG extract_frame] in={input_path!r} out={output_path!r} t={time_seconds}")
    _emit_input_status("Extracting frame…")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    args = ["-ss", str(time_seconds), "-i", inp, "-vframes", "1", "-q:v", "2", out]
    ok, msg = _run_ffmpeg(args)
    if not ok:
        return f"extract_frame failed: {msg}"
    b64 = None
    if out and os.path.isfile(out):
        try:
            with open(out, "rb") as _f:
                b64 = image_to_preview_base64(_f.read(), max_width=400)
        except Exception:
            pass
    if b64:
        emit_live_view_preview(
            f"Frame at {time_seconds}s",
            tool_name="extract_frame",
            preview_image_base64=b64,
        )
    return msg


# ---------------------------------------------------------------------------
# 8. merge_audio_video
# ---------------------------------------------------------------------------
@tool
def merge_audio_video(video_path: str, audio_path: str, output_path: str) -> str:
    """Mux one video file and one audio file into a single video file. Inputs: video_path, audio_path, output_path."""
    _debug_log(f"[DEBUG merge_audio_video] video={video_path!r} audio={audio_path!r} out={output_path!r}")
    _emit_input_status("Merging audio & video…")
    emit_live_view_preview("Merging audio & video…", tool_name="merge_audio_video", progress_pct=0)
    vp, e1 = _resolve_read(video_path)
    ap, e2 = _resolve_read(audio_path)
    out, e3 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if e3:
        return e3
    if not vp or not os.path.isfile(vp):
        return f"Video file not found: {video_path}"
    if not ap or not os.path.isfile(ap):
        return f"Audio file not found: {audio_path}"
    args = ["-i", vp, "-i", ap, "-c:v", "copy", "-c:a", "aac", "-shortest", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        emit_live_view_preview("Audio & video merged.", tool_name="merge_audio_video", progress_pct=100)
        _emit_video_frame_preview(out, "merge_audio_video", "Audio & video merged")
    return msg if ok else f"merge_audio_video failed: {msg}"


# ---------------------------------------------------------------------------
# 9. remove_audio
# ---------------------------------------------------------------------------
@tool
def remove_audio(input_path: str, output_path: str) -> str:
    """Remove the audio stream from a video file. Inputs: input_path, output_path."""
    _debug_log(f"[DEBUG remove_audio] in={input_path!r} out={output_path!r}")
    _emit_input_status("Removing audio…")
    emit_live_view_preview("Removing audio…", tool_name="remove_audio")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    args = ["-i", inp, "-an", "-c:v", "copy", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        _emit_video_frame_preview(out, "remove_audio", "Audio removed")
    return msg if ok else f"remove_audio failed: {msg}"


# ---------------------------------------------------------------------------
# 10. extract_subtitles
# ---------------------------------------------------------------------------
@tool
def extract_subtitles(
    input_path: str,
    output_path: str,
    stream_index: int = 0,
    format: str = "srt",
) -> str:
    """Extract subtitle stream from a video to a file (e.g. .srt or .vtt). Inputs: input_path, output_path, optional stream_index, format (srt, vtt, ass)."""
    _debug_log(f"[DEBUG extract_subtitles] in={input_path!r} out={output_path!r}")
    _emit_input_status("Extracting subtitles…")
    emit_live_view_preview("Extracting subtitles…", tool_name="extract_subtitles")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    args = ["-i", inp, "-map", f"0:s:{stream_index}", out]
    ok, msg = _run_ffmpeg(args)
    return msg if ok else f"extract_subtitles failed: {msg}"


# ---------------------------------------------------------------------------
# 11. burn_subtitles
# ---------------------------------------------------------------------------
@tool
def burn_subtitles(input_path: str, subtitle_path: str, output_path: str) -> str:
    """Burn (hardcode) a subtitle file into the video. Inputs: input_path (video), subtitle_path (.srt etc.), output_path."""
    _debug_log(f"[DEBUG burn_subtitles] in={input_path!r} sub={subtitle_path!r} out={output_path!r}")
    _emit_input_status("Burning subtitles…")
    emit_live_view_preview("Burning subtitles…", tool_name="burn_subtitles")
    inp, e1 = _resolve_read(input_path)
    sub, e2 = _resolve_read(subtitle_path)
    out, e3 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if e3:
        return e3
    if not inp or not os.path.isfile(inp):
        return f"Video file not found: {input_path}"
    if not sub or not os.path.isfile(sub):
        return f"Subtitle file not found: {subtitle_path}"
    sub_esc = sub.replace("\\", "\\\\").replace("'", "'\\''")
    args = ["-i", inp, "-vf", f"subtitles='{sub_esc}'", "-c:a", "copy", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        _emit_video_frame_preview(out, "burn_subtitles", "Subtitles burned in")
    return msg if ok else f"burn_subtitles failed: {msg}"


# ---------------------------------------------------------------------------
# 12. split_media
# ---------------------------------------------------------------------------
@tool
def split_media(
    input_path: str,
    output_dir: str,
    segment_duration_seconds: float = 0.0,
    num_parts: int = 0,
) -> str:
    """Split a media file into segments. Use either segment_duration_seconds (e.g. 300 for 5 min) or num_parts (equal parts). Output files go to output_dir. Inputs: input_path, output_dir, segment_duration_seconds OR num_parts."""
    _debug_log(f"[DEBUG split_media] in={input_path!r} out_dir={output_dir!r}")
    _emit_input_status("Splitting media…")
    emit_live_view_preview("Splitting media…", tool_name="split_media")
    inp, e1 = _resolve_read(input_path)
    out_dir, e2 = _resolve_write(output_dir)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    if not out_dir:
        return "Invalid output_dir."
    os.makedirs(out_dir, exist_ok=True)
    base, ext = os.path.splitext(os.path.basename(inp))
    if num_parts > 0:
        ok, out = _run_ffprobe(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", inp])
        if not ok:
            return f"Could not get duration: {out}"
        try:
            duration = float(out.strip())
        except ValueError:
            return f"Could not parse duration: {out}"
        segment_duration_seconds = duration / num_parts
    if segment_duration_seconds <= 0:
        return "Specify either segment_duration_seconds or num_parts."
    out_pattern = os.path.join(out_dir, f"{base}_part%03d{ext}")
    args = ["-i", inp, "-f", "segment", "-segment_time", str(segment_duration_seconds), "-reset_timestamps", "1", "-c", "copy", out_pattern]
    ok, msg = _run_ffmpeg(args)
    return msg if ok else f"split_media failed: {msg}"


# ---------------------------------------------------------------------------
# 13. change_speed
# ---------------------------------------------------------------------------
@tool
def change_speed(input_path: str, output_path: str, speed_factor: float = 1.0) -> str:
    """Change playback speed of video/audio (e.g. 2.0 = 2x faster, 0.5 = half speed). Inputs: input_path, output_path, speed_factor."""
    _debug_log(f"[DEBUG change_speed] in={input_path!r} out={output_path!r} speed={speed_factor}")
    _emit_input_status("Changing speed…")
    emit_live_view_preview("Changing speed…", tool_name="change_speed")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    if speed_factor <= 0 or speed_factor > 10:
        return "speed_factor must be between 0 and 10 (e.g. 0.5 or 2.0)."
    atempo_vals = []
    v = speed_factor
    while v > 2.0:
        atempo_vals.append("2.0")
        v /= 2.0
    while v < 0.5:
        atempo_vals.append("0.5")
        v /= 0.5
    atempo_vals.append(f"{v:.4f}")
    af = ",".join([f"atempo={x}" for x in atempo_vals])
    args = ["-i", inp, "-filter:a", af, "-filter:v", f"setpts={1/speed_factor}*PTS", "-c:v", "libx264", "-c:a", "aac", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        _emit_video_frame_preview(out, "change_speed", "Speed changed")
    return msg if ok else f"change_speed failed: {msg}"


# ---------------------------------------------------------------------------
# 14. rotate_video
# ---------------------------------------------------------------------------
@tool
def rotate_video(input_path: str, output_path: str, rotation: int = 90) -> str:
    """Rotate video by 90, 180, or 270 degrees. Inputs: input_path, output_path, rotation (90, 180, or 270)."""
    _debug_log(f"[DEBUG rotate_video] in={input_path!r} out={output_path!r} rotation={rotation}")
    _emit_input_status("Rotating video…")
    emit_live_view_preview("Rotating video…", tool_name="rotate_video")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    if rotation not in (90, 180, 270):
        return "rotation must be 90, 180, or 270."
    if rotation == 90:
        vf = "transpose=1"
    elif rotation == 270:
        vf = "transpose=2"
    else:
        vf = "transpose=1,transpose=1"
    args = ["-i", inp, "-vf", vf, "-c:a", "copy", out]
    ok, msg = _run_ffmpeg(args)
    if ok and out:
        _emit_video_frame_preview(out, "rotate_video", "Video rotated")
    return msg if ok else f"rotate_video failed: {msg}"


# ---------------------------------------------------------------------------
# 15. images_to_video
# ---------------------------------------------------------------------------
@tool
def images_to_video(
    input_paths: str,
    output_path: str,
    fps: float = 24.0,
) -> str:
    """Create a video from a sequence of images. Input paths: comma- or newline-separated list of image paths in order. Inputs: input_paths, output_path, fps (default 24)."""
    _debug_log(f"[DEBUG images_to_video] paths={input_paths!r} out={output_path!r} fps={fps}")
    _emit_input_status("Creating video from images…")
    emit_live_view_preview("Creating video from images…", tool_name="images_to_video")
    out, e_out = _resolve_write(output_path)
    if e_out:
        return e_out
    paths = [p.strip().strip('"') for p in input_paths.replace("\n", ",").split(",") if p.strip()]
    if not paths:
        return "No input image paths provided."
    resolved = []
    for p in paths:
        abs_p, err = _resolve_read(p)
        if err:
            return err
        if not abs_p or not os.path.isfile(abs_p):
            return f"Input file not found: {p}"
        resolved.append(abs_p)
    try:
        fd, list_file = tempfile.mkstemp(suffix=".txt", prefix="ffmpeg_imgs_")
        try:
            with os.fdopen(fd, "w") as f:
                for p in resolved:
                    esc = p.replace("\\", "\\\\").replace("'", "'\\''")
                    f.write(f"file '{esc}'\n")
            args = ["-f", "concat", "-safe", "0", "-r", str(fps), "-i", list_file, "-c:v", "libx264", "-pix_fmt", "yuv420p", out]
            ok, msg = _run_ffmpeg(args)
            if ok and out:
                _emit_video_frame_preview(out, "images_to_video", "Video created from images")
            return msg if ok else f"images_to_video failed: {msg}"
        finally:
            try:
                os.unlink(list_file)
            except OSError:
                pass
    except Exception as e:
        return f"images_to_video failed: {e}"


# ---------------------------------------------------------------------------
# 16. extract_frames_interval
# ---------------------------------------------------------------------------
@tool
def extract_frames_interval(
    input_path: str,
    output_dir: str,
    interval_seconds: float = 10.0,
    image_format: str = "png",
) -> str:
    """Extract frames from a video at a fixed time interval (e.g. one frame every 10 seconds). Inputs: input_path, output_dir, interval_seconds, optional image_format (png, jpg)."""
    _debug_log(f"[DEBUG extract_frames_interval] in={input_path!r} out_dir={output_dir!r} interval={interval_seconds}")
    _emit_input_status("Extracting frames…")
    inp, e1 = _resolve_read(input_path)
    out_dir, e2 = _resolve_write(output_dir)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    if interval_seconds <= 0:
        return "interval_seconds must be positive."
    os.makedirs(out_dir, exist_ok=True)
    emit_live_view_preview(
        f"Extracting frames every {interval_seconds}s…",
        tool_name="extract_frames_interval",
        progress_pct=0,
    )
    base = os.path.splitext(os.path.basename(inp))[0]
    ext = image_format.replace(".", "").lower() or "png"
    out_pattern = os.path.join(out_dir, f"{base}_frame_%04d.{ext}")
    args = ["-i", inp, "-vf", f"fps=1/{interval_seconds}", "-q:v", "2", out_pattern]
    ok, msg = _run_ffmpeg(args)
    if not ok:
        return f"extract_frames_interval failed: {msg}"
    frames = [f for f in os.listdir(out_dir) if f.startswith(base + "_frame_") and f.endswith("." + ext)]
    frames.sort()
    b64 = None
    if frames:
        first_path = os.path.join(out_dir, frames[0])
        try:
            with open(first_path, "rb") as _f:
                b64 = image_to_preview_base64(_f.read(), max_width=400)
        except Exception:
            pass
    emit_live_view_preview(
        f"Extracted {len(frames)} frame(s) to {os.path.basename(out_dir)}",
        tool_name="extract_frames_interval",
        progress_pct=100,
        preview_image_base64=b64,
    )
    return msg


# ---------------------------------------------------------------------------
# 17. normalize_audio
# ---------------------------------------------------------------------------
@tool
def normalize_audio(input_path: str, output_path: str, target_lufs: float = -14.0) -> str:
    """Normalize audio volume to a target loudness (LUFS). Inputs: input_path, output_path, optional target_lufs (default -14)."""
    _debug_log(f"[DEBUG normalize_audio] in={input_path!r} out={output_path!r} lufs={target_lufs}")
    _emit_input_status("Normalizing audio…")
    emit_live_view_preview("Normalizing audio…", tool_name="normalize_audio")
    inp, e1 = _resolve_read(input_path)
    out, e2 = _resolve_write(output_path)
    if e1:
        return e1
    if e2:
        return e2
    if not inp or not os.path.isfile(inp):
        return f"Input file not found: {input_path}"
    args = ["-i", inp, "-af", f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11.0", "-ar", "48000", "-c:a", "aac", out]
    ok, msg = _run_ffmpeg(args)
    return msg if ok else f"normalize_audio failed: {msg}"
