use std::path::Path;
use std::process::Stdio;

use tempfile::NamedTempFile;
use tokio::process::Command;
use tracing::debug;

#[derive(Debug)]
pub enum FfmpegError {
    Spawn(std::io::Error),
    NonZeroExit { code: Option<i32>, stderr: String },
    TempFile(std::io::Error),
    Parse(String),
}

impl std::fmt::Display for FfmpegError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Spawn(e) => write!(f, "failed to spawn ffmpeg: {e}"),
            Self::NonZeroExit { code, stderr } => {
                write!(f, "ffmpeg exited with {code:?}: {stderr}")
            }
            Self::TempFile(e) => write!(f, "failed to create temp file: {e}"),
            Self::Parse(raw) => write!(f, "failed to parse ffprobe output: {raw:?}"),
        }
    }
}

impl std::error::Error for FfmpegError {}

/// Remux an m4a file with `-movflags faststart` so the `moov` atom sits at the
/// start of the file. Returns a NamedTempFile that is auto-deleted on drop.
///
/// Lossless — just a container-level rewrite, typically <1s for dictation-sized files.
pub async fn remux_faststart(input: &Path) -> Result<NamedTempFile, FfmpegError> {
    let output = tempfile::Builder::new()
        .prefix("doctate-remux-")
        .suffix(".m4a")
        .tempfile()
        .map_err(FfmpegError::TempFile)?;

    let output_path = output.path().to_owned();

    let result = Command::new("ffmpeg")
        .arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-y")
        .arg("-i")
        .arg(input)
        .arg("-c")
        .arg("copy")
        .arg("-movflags")
        .arg("faststart")
        .arg(&output_path)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .await
        .map_err(FfmpegError::Spawn)?;

    if !result.status.success() {
        return Err(FfmpegError::NonZeroExit {
            code: result.status.code(),
            stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
        });
    }

    debug!(?output_path, "ffmpeg remux completed");
    Ok(output)
}

/// Probe the duration (in whole seconds, rounded) of an audio file via `ffprobe`.
///
/// Cheap — ffprobe only reads container headers, no decoding. On a faststart-remuxed
/// file this is single-digit milliseconds. Uses the `format=duration` entry which
/// is the canonical container-level duration for m4a/MP4.
pub async fn probe_duration_seconds(path: &Path) -> Result<u32, FfmpegError> {
    let output = Command::new("ffprobe")
        .arg("-v")
        .arg("error")
        .arg("-show_entries")
        .arg("format=duration")
        .arg("-of")
        .arg("default=noprint_wrappers=1:nokey=1")
        .arg(path)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .map_err(FfmpegError::Spawn)?;

    if !output.status.success() {
        return Err(FfmpegError::NonZeroExit {
            code: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    let trimmed = raw.trim();
    let secs: f64 = trimmed
        .parse()
        .map_err(|_| FfmpegError::Parse(trimmed.to_string()))?;
    if !secs.is_finite() || secs < 0.0 {
        return Err(FfmpegError::Parse(trimmed.to_string()));
    }
    Ok(secs.round() as u32)
}

/// Result of one `ffmpeg -af volumedetect` pass on an audio file:
/// duration plus mean and peak volume in a single decode.
///
/// `mean_db` and `max_db` may be `f64::NEG_INFINITY` for fully silent
/// (zero-sample) input — `f64::from_str` accepts `"-inf"` natively.
/// Callers that build a `Loudness` value must gate on `is_finite()`
/// to avoid feeding `+Inf` gain into Web Audio.
#[derive(Debug, Clone, PartialEq)]
pub struct AudioAnalysis {
    pub duration_seconds: u32,
    pub mean_db: f64,
    pub max_db: f64,
}

/// Run `ffmpeg ... -af volumedetect -f null -` against `path` and
/// extract duration plus mean/peak volume from its stderr in a single
/// decode pass.
///
/// Decode-bound: O(audio length) wall-time, ~100-500 ms for typical
/// 10-60 s dictations on one CPU core. Worker code overlaps this with
/// Whisper via `tokio::join!`, so on the pipeline wallclock the cost
/// is effectively hidden behind the Whisper call.
pub async fn analyze_audio(path: &Path) -> Result<AudioAnalysis, FfmpegError> {
    let output = Command::new("ffmpeg")
        .arg("-hide_banner")
        .arg("-nostats")
        .arg("-i")
        .arg(path)
        .arg("-af")
        .arg("volumedetect")
        .arg("-f")
        .arg("null")
        .arg("-")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output()
        .await
        .map_err(FfmpegError::Spawn)?;

    if !output.status.success() {
        return Err(FfmpegError::NonZeroExit {
            code: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }

    let stderr = String::from_utf8_lossy(&output.stderr);
    parse_analysis(&stderr)
}

/// Pure-string parser split out from [`analyze_audio`] so unit tests
/// can target the parsing rules without spawning a real ffmpeg.
fn parse_analysis(stderr: &str) -> Result<AudioAnalysis, FfmpegError> {
    let duration_seconds = parse_duration_seconds(stderr)
        .ok_or_else(|| FfmpegError::Parse("missing or unparseable Duration line".into()))?;
    let mean_db = parse_volume_db(stderr, "mean_volume")
        .ok_or_else(|| FfmpegError::Parse("missing or unparseable mean_volume line".into()))?;
    let max_db = parse_volume_db(stderr, "max_volume")
        .ok_or_else(|| FfmpegError::Parse("missing or unparseable max_volume line".into()))?;
    Ok(AudioAnalysis {
        duration_seconds,
        mean_db,
        max_db,
    })
}

/// Locate the first `Duration: HH:MM:SS.cs, ...` line in ffmpeg's
/// stderr and convert it to whole rounded seconds.
fn parse_duration_seconds(stderr: &str) -> Option<u32> {
    let line = stderr
        .lines()
        .find(|l| l.trim_start().starts_with("Duration: "))?;
    let timecode = line
        .trim_start()
        .strip_prefix("Duration: ")?
        .split(',')
        .next()?
        .trim();
    let mut parts = timecode.split(':');
    let hours: f64 = parts.next()?.parse().ok()?;
    let minutes: f64 = parts.next()?.parse().ok()?;
    let seconds: f64 = parts.next()?.parse().ok()?;
    let total = hours * 3600.0 + minutes * 60.0 + seconds;
    if !total.is_finite() || total < 0.0 {
        return None;
    }
    Some(total.round() as u32)
}

/// Find a `<key>: <value> dB` entry in stderr and return `<value>` as
/// `f64`. `f64::from_str` accepts `-inf` / `inf` natively, which is
/// what we need for fully silent input.
fn parse_volume_db(stderr: &str, key: &str) -> Option<f64> {
    let needle = format!("{key}: ");
    let line = stderr.lines().find(|l| l.contains(&needle))?;
    let after = &line[line.find(&needle)? + needle.len()..];
    let token = after.split_whitespace().next()?;
    token.parse().ok()
}

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

    /// Realistic ffmpeg stderr for a successful volumedetect pass on a
    /// ~42-second mono dictation, trimmed to the lines our parser cares
    /// about. Format mirrors a real run; lifted verbatim modulo the
    /// memory-address tokens.
    const REALISTIC_STDERR: &str = "\
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/tmp/x.m4a':
  Metadata:
    major_brand     : mp42
  Duration: 00:00:42.31, start: 0.000000, bitrate: 64 kb/s
    Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 16000 Hz, mono, fltp, 64 kb/s
[Parsed_volumedetect_0 @ 0x55] mean_volume: -27.3 dB
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
[Parsed_volumedetect_0 @ 0x55] histogram_0db: 5
";

    #[test]
    fn parse_realistic_stderr() {
        let got = parse_analysis(REALISTIC_STDERR).expect("Ok");
        assert_eq!(
            got,
            AudioAnalysis {
                duration_seconds: 42,
                mean_db: -27.3,
                max_db: -3.1,
            }
        );
    }

    #[test]
    fn silent_file_yields_neg_infinity() {
        let stderr = "\
Input #0, ...:
  Duration: 00:00:05.00, start: 0.000000, bitrate: 64 kb/s
[Parsed_volumedetect_0 @ 0x55] mean_volume: -inf dB
[Parsed_volumedetect_0 @ 0x55] max_volume: -inf dB
";
        let got = parse_analysis(stderr).expect("Ok");
        assert_eq!(got.duration_seconds, 5);
        assert!(got.mean_db.is_infinite() && got.mean_db.is_sign_negative());
        assert!(got.max_db.is_infinite() && got.max_db.is_sign_negative());
    }

    #[test]
    fn missing_duration_is_parse_error() {
        let stderr = "\
[Parsed_volumedetect_0 @ 0x55] mean_volume: -27.3 dB
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
";
        match parse_analysis(stderr) {
            Err(FfmpegError::Parse(_)) => {}
            other => panic!("expected Parse error, got {other:?}"),
        }
    }

    #[test]
    fn missing_mean_volume_is_parse_error() {
        let stderr = "\
  Duration: 00:00:42.31, start: 0.000000, bitrate: 64 kb/s
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
";
        match parse_analysis(stderr) {
            Err(FfmpegError::Parse(_)) => {}
            other => panic!("expected Parse error, got {other:?}"),
        }
    }
}
