//! FFmpeg / FFprobe discovery, video metadata extraction, and ffmpeg downloading.

use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// CREATE_NO_WINDOW flag for Windows subprocess creation.
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;

/// Timeout for ffprobe / ffmpeg subprocess calls.
const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(20);

/// Timeout for ffprobe metadata calls (slightly longer for large files).
const METADATA_TIMEOUT: Duration = Duration::from_secs(25);

/// FFmpeg download URL (Windows 64-bit GPL build).
const FFMPEG_DOWNLOAD_URL: &str =
    "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip";

// ---------------------------------------------------------------------------
// Regex patterns
// ---------------------------------------------------------------------------

/// Matches "Duration: HH:MM:SS.ss" in ffmpeg stderr output.
static DURATION_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)").unwrap());

// ---------------------------------------------------------------------------
// Structs
// ---------------------------------------------------------------------------

/// Paths to discovered ffprobe and ffmpeg executables.
pub struct FfmpegPaths {
    pub ffprobe: Option<PathBuf>,
    pub ffmpeg: Option<PathBuf>,
}

/// Detailed video metadata extracted via ffprobe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoMetadata {
    pub v_codec: Option<String>,
    pub width: Option<u32>,
    pub height: Option<u32>,
    pub fps: Option<f64>,
    pub v_bitrate: Option<u64>,
    pub pix_fmt: Option<String>,
    pub color_space: Option<String>,
    pub a_codec: Option<String>,
    pub channels: Option<u32>,
    pub sample_rate: Option<String>,
    pub a_bitrate: Option<u64>,
    pub subtitle_tracks: Vec<SubtitleTrack>,
    pub container_bitrate: Option<u64>,
    pub duration: Option<f64>,
    pub format_name: Option<String>,
    pub container_title: Option<String>,
    pub encoder: Option<String>,
}

impl Default for VideoMetadata {
    fn default() -> Self {
        Self {
            v_codec: None,
            width: None,
            height: None,
            fps: None,
            v_bitrate: None,
            pix_fmt: None,
            color_space: None,
            a_codec: None,
            channels: None,
            sample_rate: None,
            a_bitrate: None,
            subtitle_tracks: Vec::new(),
            container_bitrate: None,
            duration: None,
            format_name: None,
            container_title: None,
            encoder: None,
        }
    }
}

/// Information about a single subtitle track.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubtitleTrack {
    pub index: u32,
    pub codec: String,
    pub language: String,
    pub title: String,
}

/// Progress information emitted during ffmpeg download.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress {
    pub percent: f64,
    pub downloaded_bytes: u64,
    pub total_bytes: u64,
}

// ---------------------------------------------------------------------------
// 1. Platform-specific command setup
// ---------------------------------------------------------------------------

/// Apply platform-specific flags to a `Command` (hide console window on Windows).
fn apply_no_window(_cmd: &mut Command) {
    #[cfg(target_os = "windows")]
    {
        _cmd.creation_flags(CREATE_NO_WINDOW);
    }
}

// ---------------------------------------------------------------------------
// 2. discover
// ---------------------------------------------------------------------------

/// Discover ffmpeg and ffprobe executables.
///
/// Search order:
/// 1. System PATH via `which`
/// 2. Alongside the application executable (`exe_dir`)
/// 3. Inside `state_dir/ffmpeg/`
pub fn discover(exe_dir: &Path, state_dir: &Path) -> FfmpegPaths {
    let ffprobe = discover_one("ffprobe", exe_dir, state_dir);
    let ffmpeg = discover_one("ffmpeg", exe_dir, state_dir);
    FfmpegPaths { ffprobe, ffmpeg }
}

/// Discover a single executable by name.
fn discover_one(name: &str, exe_dir: &Path, state_dir: &Path) -> Option<PathBuf> {
    // 1. System PATH
    if let Ok(p) = which::which(name) {
        return Some(p);
    }

    // Platform-specific executable name
    let exe_name = if cfg!(target_os = "windows") {
        format!("{}.exe", name)
    } else {
        name.to_string()
    };

    // 2. Beside the application executable
    let candidate = exe_dir.join(&exe_name);
    if candidate.is_file() {
        return Some(candidate);
    }

    // 3. Inside state_dir/ffmpeg/
    let candidate = state_dir.join("ffmpeg").join(&exe_name);
    if candidate.is_file() {
        return Some(candidate);
    }

    None
}

// ---------------------------------------------------------------------------
// 3. duration_seconds
// ---------------------------------------------------------------------------

/// Get video duration in seconds using ffprobe (primary) or ffmpeg stderr (fallback).
///
/// Returns `None` if neither method succeeds or duration is not positive.
pub fn duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option<f64> {
    // Try ffprobe first
    if let Some(ref ffprobe) = paths.ffprobe {
        if let Some(d) = duration_via_ffprobe(path, ffprobe) {
            return Some(d);
        }
    }

    // Fallback: parse ffmpeg stderr
    if let Some(ref ffmpeg) = paths.ffmpeg {
        if let Some(d) = duration_via_ffmpeg(path, ffmpeg) {
            return Some(d);
        }
    }

    None
}

/// Extract duration using ffprobe's format=duration output.
fn duration_via_ffprobe(path: &Path, ffprobe: &Path) -> Option<f64> {
    let mut cmd = Command::new(ffprobe);
    cmd.args([
        "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=nw=1:nk=1",
    ])
    .arg(path)
    .stdout(std::process::Stdio::piped())
    .stderr(std::process::Stdio::piped());
    apply_no_window(&mut cmd);

    let child = cmd.spawn().ok()?;
    let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
    let text = String::from_utf8_lossy(&output.stdout);
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }
    let d: f64 = trimmed.parse().ok()?;
    if d > 0.0 { Some(d) } else { None }
}

/// Extract duration by parsing "Duration: HH:MM:SS.ss" from ffmpeg stderr.
fn duration_via_ffmpeg(path: &Path, ffmpeg: &Path) -> Option<f64> {
    let mut cmd = Command::new(ffmpeg);
    cmd.args(["-hide_banner", "-i"])
        .arg(path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    apply_no_window(&mut cmd);

    let child = cmd.spawn().ok()?;
    let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
    let stderr = String::from_utf8_lossy(&output.stderr);
    parse_ffmpeg_duration(&stderr)
}

/// Parse "Duration: HH:MM:SS.ss" from ffmpeg stderr output.
fn parse_ffmpeg_duration(stderr: &str) -> Option<f64> {
    let caps = DURATION_RE.captures(stderr)?;
    let hh: f64 = caps.get(1)?.as_str().parse().ok()?;
    let mm: f64 = caps.get(2)?.as_str().parse().ok()?;
    let ss: f64 = caps.get(3)?.as_str().parse().ok()?;
    let total = hh * 3600.0 + mm * 60.0 + ss;
    if total > 0.0 { Some(total) } else { None }
}

/// Wait for a child process with a timeout, killing it if exceeded.
fn wait_with_timeout(
    child: std::process::Child,
    timeout: Duration,
) -> Option<std::process::Output> {
    // Convert Child into a form we can wait on with a timeout.
    // std::process::Child::wait_with_output blocks, so we use a thread.
    let (tx, rx) = std::sync::mpsc::channel();
    let handle = std::thread::spawn(move || {
        let result = child.wait_with_output();
        let _ = tx.send(result);
    });

    match rx.recv_timeout(timeout) {
        Ok(Ok(output)) => {
            let _ = handle.join();
            Some(output)
        }
        _ => {
            // Timeout or error -- the thread owns the child and will clean up
            let _ = handle.join();
            None
        }
    }
}

// ---------------------------------------------------------------------------
// 4. ffprobe_video_metadata
// ---------------------------------------------------------------------------

/// Extract detailed video metadata using ffprobe JSON output.
///
/// Runs ffprobe with `-print_format json -show_streams -show_format` and parses
/// the resulting JSON to populate a `VideoMetadata` struct.
pub fn ffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option<VideoMetadata> {
    let mut cmd = Command::new(ffprobe);
    cmd.args([
        "-v", "error",
        "-print_format", "json",
        "-show_streams", "-show_format",
    ])
    .arg(path)
    .stdout(std::process::Stdio::piped())
    .stderr(std::process::Stdio::piped());
    apply_no_window(&mut cmd);

    let child = cmd.spawn().ok()?;
    let output = wait_with_timeout(child, METADATA_TIMEOUT)?;
    let text = String::from_utf8_lossy(&output.stdout);
    let data: serde_json::Value = serde_json::from_str(&text).ok()?;

    let streams = data.get("streams").and_then(|v| v.as_array());
    let fmt = data.get("format").and_then(|v| v.as_object());

    let mut meta = VideoMetadata::default();
    let mut found_video = false;
    let mut found_audio = false;

    // Iterate streams: first video, first audio, all subtitles
    if let Some(streams) = streams {
        for (idx, s) in streams.iter().enumerate() {
            let obj = match s.as_object() {
                Some(o) => o,
                None => continue,
            };
            let codec_type = obj
                .get("codec_type")
                .and_then(|v| v.as_str())
                .unwrap_or("");

            match codec_type {
                "video" if !found_video => {
                    found_video = true;
                    meta.v_codec = json_str(obj, "codec_name");
                    meta.width = json_u32(obj, "width");
                    meta.height = json_u32(obj, "height");
                    meta.pix_fmt = json_str(obj, "pix_fmt");
                    meta.color_space = json_str(obj, "color_space");

                    // Parse frame rate ("num/den")
                    let frame_rate = json_str(obj, "r_frame_rate")
                        .or_else(|| json_str(obj, "avg_frame_rate"));
                    if let Some(ref fr) = frame_rate {
                        meta.fps = parse_frame_rate(fr);
                    }

                    // Video bitrate
                    meta.v_bitrate = json_str(obj, "bit_rate")
                        .and_then(|s| s.parse::<u64>().ok());
                }
                "audio" if !found_audio => {
                    found_audio = true;
                    meta.a_codec = json_str(obj, "codec_name");
                    meta.channels = json_u32(obj, "channels");
                    meta.sample_rate = json_str(obj, "sample_rate");
                    meta.a_bitrate = json_str(obj, "bit_rate")
                        .and_then(|s| s.parse::<u64>().ok());
                }
                "subtitle" => {
                    let tags = obj
                        .get("tags")
                        .and_then(|v| v.as_object());

                    let language = tags
                        .and_then(|t| {
                            json_str_from(t, "language")
                                .or_else(|| json_str_from(t, "LANGUAGE"))
                        })
                        .unwrap_or_default();

                    let title = tags
                        .and_then(|t| {
                            json_str_from(t, "title")
                                .or_else(|| json_str_from(t, "TITLE"))
                        })
                        .unwrap_or_default();

                    let stream_index = obj
                        .get("index")
                        .and_then(|v| v.as_u64())
                        .unwrap_or(idx as u64) as u32;

                    let codec = json_str(obj, "codec_name")
                        .unwrap_or_else(|| "unknown".to_string());

                    meta.subtitle_tracks.push(SubtitleTrack {
                        index: stream_index,
                        codec,
                        language,
                        title,
                    });
                }
                _ => {}
            }
        }
    }

    // Format-level metadata
    if let Some(fmt) = fmt {
        meta.container_bitrate = json_str_from(fmt, "bit_rate")
            .and_then(|s| s.parse::<u64>().ok());

        meta.duration = json_str_from(fmt, "duration")
            .and_then(|s| s.parse::<f64>().ok());

        meta.format_name = json_str_from(fmt, "format_name");

        // Container tags
        if let Some(ftags) = fmt.get("tags").and_then(|v| v.as_object()) {
            let ct = json_str_from(ftags, "title");
            if ct.as_deref().map_or(false, |s| !s.is_empty()) {
                meta.container_title = ct;
            }
            let enc = json_str_from(ftags, "encoder");
            if enc.as_deref().map_or(false, |s| !s.is_empty()) {
                meta.encoder = enc;
            }
        }
    }

    // Return None if we extracted nothing useful
    let has_data = meta.v_codec.is_some()
        || meta.a_codec.is_some()
        || !meta.subtitle_tracks.is_empty()
        || meta.duration.is_some()
        || meta.format_name.is_some();

    if has_data { Some(meta) } else { None }
}

// ---------------------------------------------------------------------------
// JSON helper functions
// ---------------------------------------------------------------------------

/// Extract a string value from a JSON object by key.
fn json_str(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
    json_str_from(obj, key)
}

/// Extract a string value from a JSON map by key.
fn json_str_from(
    obj: &serde_json::Map<String, serde_json::Value>,
    key: &str,
) -> Option<String> {
    obj.get(key).and_then(|v| match v {
        serde_json::Value::String(s) => Some(s.clone()),
        serde_json::Value::Number(n) => Some(n.to_string()),
        _ => None,
    })
}

/// Extract a u32 value from a JSON object by key.
fn json_u32(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<u32> {
    obj.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
}

/// Parse a frame rate string like "30000/1001" into an f64.
fn parse_frame_rate(fr: &str) -> Option<f64> {
    if let Some((num_str, den_str)) = fr.split_once('/') {
        let num: f64 = num_str.trim().parse().ok()?;
        let den: f64 = den_str.trim().parse().ok()?;
        if den == 0.0 {
            return None;
        }
        let fps = num / den;
        if fps > 0.0 { Some(fps) } else { None }
    } else {
        // Plain number
        let fps: f64 = fr.trim().parse().ok()?;
        if fps > 0.0 { Some(fps) } else { None }
    }
}

// ---------------------------------------------------------------------------
// 5. download_ffmpeg (async)
// ---------------------------------------------------------------------------

/// Download ffmpeg from GitHub, extract `ffmpeg.exe` and `ffprobe.exe`, and
/// place them in `state_dir/ffmpeg/`.
///
/// Reports progress via the provided `tokio::sync::mpsc::Sender`.
pub async fn download_ffmpeg(
    state_dir: &Path,
    progress_tx: tokio::sync::mpsc::Sender<DownloadProgress>,
) -> Result<FfmpegPaths, String> {
    let dest_dir = state_dir.join("ffmpeg");
    std::fs::create_dir_all(&dest_dir)
        .map_err(|e| format!("Failed to create ffmpeg directory: {}", e))?;

    let zip_path = dest_dir.join("ffmpeg-download.zip");

    // Start the download
    let client = reqwest::Client::new();
    let response = client
        .get(FFMPEG_DOWNLOAD_URL)
        .send()
        .await
        .map_err(|e| format!("Failed to start download: {}", e))?;

    if !response.status().is_success() {
        return Err(format!("Download failed with status: {}", response.status()));
    }

    let total_bytes = response.content_length().unwrap_or(0);
    let mut downloaded_bytes: u64 = 0;

    // Stream the response body to disk chunk by chunk using reqwest's chunk()
    let mut file = std::fs::File::create(&zip_path)
        .map_err(|e| format!("Failed to create zip file: {}", e))?;

    let mut response = response;
    while let Some(chunk) = response
        .chunk()
        .await
        .map_err(|e| format!("Download stream error: {}", e))?
    {
        std::io::Write::write_all(&mut file, &chunk)
            .map_err(|e| format!("Failed to write chunk: {}", e))?;

        downloaded_bytes += chunk.len() as u64;

        let percent = if total_bytes > 0 {
            (downloaded_bytes as f64 / total_bytes as f64) * 100.0
        } else {
            0.0
        };

        // Send progress update (ignore send errors if receiver dropped)
        let _ = progress_tx
            .send(DownloadProgress {
                percent,
                downloaded_bytes,
                total_bytes,
            })
            .await;
    }
    drop(file);

    // Extract ffmpeg.exe and ffprobe.exe from the zip
    extract_ffmpeg_from_zip(&zip_path, &dest_dir)?;

    // Clean up the zip file
    std::fs::remove_file(&zip_path).ok();

    // Verify the extracted files exist
    let ffmpeg_exe = if cfg!(target_os = "windows") {
        "ffmpeg.exe"
    } else {
        "ffmpeg"
    };
    let ffprobe_exe = if cfg!(target_os = "windows") {
        "ffprobe.exe"
    } else {
        "ffprobe"
    };

    let ffmpeg_path = dest_dir.join(ffmpeg_exe);
    let ffprobe_path = dest_dir.join(ffprobe_exe);

    Ok(FfmpegPaths {
        ffprobe: if ffprobe_path.is_file() {
            Some(ffprobe_path)
        } else {
            None
        },
        ffmpeg: if ffmpeg_path.is_file() {
            Some(ffmpeg_path)
        } else {
            None
        },
    })
}

/// Extract only `ffmpeg.exe` and `ffprobe.exe` from a downloaded zip archive.
///
/// The BtbN builds have files nested inside a directory like
/// `ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe`, so we search for any entry
/// whose filename ends with the target name.
fn extract_ffmpeg_from_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> {
    let file = std::fs::File::open(zip_path)
        .map_err(|e| format!("Failed to open zip: {}", e))?;
    let mut archive = zip::ZipArchive::new(file)
        .map_err(|e| format!("Failed to read zip archive: {}", e))?;

    let targets: &[&str] = if cfg!(target_os = "windows") {
        &["ffmpeg.exe", "ffprobe.exe"]
    } else {
        &["ffmpeg", "ffprobe"]
    };

    for i in 0..archive.len() {
        let mut entry = archive
            .by_index(i)
            .map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?;

        let entry_name = entry.name().to_string();

        // Check if this entry matches one of our target filenames
        for target in targets {
            if entry_name.ends_with(&format!("/{}", target))
                || entry_name == *target
            {
                let out_path = dest_dir.join(target);
                let mut out_file = std::fs::File::create(&out_path)
                    .map_err(|e| format!("Failed to create {}: {}", target, e))?;
                std::io::copy(&mut entry, &mut out_file)
                    .map_err(|e| format!("Failed to extract {}: {}", target, e))?;
                break;
            }
        }
    }

    Ok(())
}

// ===========================================================================
// Tests
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;

    // -- parse_ffmpeg_duration -----------------------------------------------

    #[test]
    fn test_parse_ffmpeg_duration_standard() {
        let stderr = r#"
Input #0, matroska,webm, from 'video.mkv':
  Duration: 01:23:45.67, start: 0.000000, bitrate: 5000 kb/s
  Stream #0:0: Video: h264
"#;
        let d = parse_ffmpeg_duration(stderr).unwrap();
        let expected = 1.0 * 3600.0 + 23.0 * 60.0 + 45.67;
        assert!((d - expected).abs() < 0.001, "got {}, expected {}", d, expected);
    }

    #[test]
    fn test_parse_ffmpeg_duration_short_video() {
        let stderr = "  Duration: 00:00:30.50, start: 0.000000, bitrate: 1200 kb/s\n";
        let d = parse_ffmpeg_duration(stderr).unwrap();
        assert!((d - 30.5).abs() < 0.001);
    }

    #[test]
    fn test_parse_ffmpeg_duration_whole_seconds() {
        let stderr = "  Duration: 00:05:00.00, start: 0.0\n";
        let d = parse_ffmpeg_duration(stderr).unwrap();
        assert!((d - 300.0).abs() < 0.001);
    }

    #[test]
    fn test_parse_ffmpeg_duration_zero() {
        // Zero duration should return None (not positive)
        let stderr = "  Duration: 00:00:00.00, start: 0.0\n";
        assert!(parse_ffmpeg_duration(stderr).is_none());
    }

    #[test]
    fn test_parse_ffmpeg_duration_no_match() {
        let stderr = "some random output without duration info\n";
        assert!(parse_ffmpeg_duration(stderr).is_none());
    }

    #[test]
    fn test_parse_ffmpeg_duration_empty() {
        assert!(parse_ffmpeg_duration("").is_none());
    }

    // -- discover_not_found --------------------------------------------------

    #[test]
    fn test_discover_not_found() {
        // Use non-existent directories -- should return None for both paths
        let exe_dir = Path::new("/nonexistent/path/that/does/not/exist/exe");
        let state_dir = Path::new("/nonexistent/path/that/does/not/exist/state");
        let paths = discover(exe_dir, state_dir);

        // On a system without ffmpeg in PATH these will be None;
        // on a system with ffmpeg installed they may be Some.
        // We simply verify the function does not panic.
        let _ = paths.ffprobe;
        let _ = paths.ffmpeg;
    }

    // -- video_metadata_default ----------------------------------------------

    #[test]
    fn test_video_metadata_default() {
        let meta = VideoMetadata::default();
        assert!(meta.v_codec.is_none());
        assert!(meta.width.is_none());
        assert!(meta.height.is_none());
        assert!(meta.fps.is_none());
        assert!(meta.v_bitrate.is_none());
        assert!(meta.pix_fmt.is_none());
        assert!(meta.color_space.is_none());
        assert!(meta.a_codec.is_none());
        assert!(meta.channels.is_none());
        assert!(meta.sample_rate.is_none());
        assert!(meta.a_bitrate.is_none());
        assert!(meta.subtitle_tracks.is_empty());
        assert!(meta.container_bitrate.is_none());
        assert!(meta.duration.is_none());
        assert!(meta.format_name.is_none());
        assert!(meta.container_title.is_none());
        assert!(meta.encoder.is_none());
    }

    // -- download_progress_serialization -------------------------------------

    #[test]
    fn test_download_progress_serialization() {
        let progress = DownloadProgress {
            percent: 50.5,
            downloaded_bytes: 1024,
            total_bytes: 2048,
        };

        let json = serde_json::to_string(&progress).unwrap();
        let parsed: DownloadProgress = serde_json::from_str(&json).unwrap();

        assert!((parsed.percent - 50.5).abs() < f64::EPSILON);
        assert_eq!(parsed.downloaded_bytes, 1024);
        assert_eq!(parsed.total_bytes, 2048);
    }

    #[test]
    fn test_download_progress_json_fields() {
        let progress = DownloadProgress {
            percent: 100.0,
            downloaded_bytes: 5000,
            total_bytes: 5000,
        };

        let value: serde_json::Value = serde_json::to_value(&progress).unwrap();
        assert_eq!(value["percent"], 100.0);
        assert_eq!(value["downloaded_bytes"], 5000);
        assert_eq!(value["total_bytes"], 5000);
    }

    // -- parse_frame_rate ----------------------------------------------------

    #[test]
    fn test_parse_frame_rate_fraction() {
        let fps = parse_frame_rate("30000/1001").unwrap();
        assert!((fps - 29.97).abs() < 0.01);
    }

    #[test]
    fn test_parse_frame_rate_integer() {
        let fps = parse_frame_rate("24/1").unwrap();
        assert!((fps - 24.0).abs() < 0.001);
    }

    #[test]
    fn test_parse_frame_rate_zero_denominator() {
        assert!(parse_frame_rate("24/0").is_none());
    }

    #[test]
    fn test_parse_frame_rate_plain_number() {
        let fps = parse_frame_rate("60").unwrap();
        assert!((fps - 60.0).abs() < 0.001);
    }

    // -- subtitle_track_serialization ----------------------------------------

    #[test]
    fn test_subtitle_track_serialization() {
        let track = SubtitleTrack {
            index: 2,
            codec: "srt".to_string(),
            language: "eng".to_string(),
            title: "English".to_string(),
        };

        let json = serde_json::to_string(&track).unwrap();
        let parsed: SubtitleTrack = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed.index, 2);
        assert_eq!(parsed.codec, "srt");
        assert_eq!(parsed.language, "eng");
        assert_eq!(parsed.title, "English");
    }

    // -- integration tests (require actual ffprobe/ffmpeg) -------------------

    #[test]
    #[ignore]
    fn test_duration_seconds_with_real_ffprobe() {
        // This test requires ffprobe and ffmpeg to be installed and a sample
        // video file at the given path.
        let exe_dir = Path::new(".");
        let state_dir = Path::new(".");
        let paths = discover(exe_dir, state_dir);

        if paths.ffprobe.is_none() && paths.ffmpeg.is_none() {
            eprintln!("Skipping: no ffprobe or ffmpeg found");
            return;
        }

        // Would need a real video file here
        // let d = duration_seconds(Path::new("sample.mp4"), &paths);
        // assert!(d.is_some());
    }

    #[test]
    #[ignore]
    fn test_ffprobe_video_metadata_with_real_ffprobe() {
        let exe_dir = Path::new(".");
        let state_dir = Path::new(".");
        let paths = discover(exe_dir, state_dir);

        if let Some(ref ffprobe) = paths.ffprobe {
            // Would need a real video file here
            // let meta = ffprobe_video_metadata(Path::new("sample.mp4"), ffprobe);
            // assert!(meta.is_some());
            let _ = ffprobe;
        } else {
            eprintln!("Skipping: no ffprobe found");
        }
    }
}
