//! Lydstudio — lydbehandling via FFmpeg subprocess.
//!
//! Ikke-destruktiv redigering: originalen i CAS røres aldri.
//! En EDL (Edit Decision List) beskriver operasjonene. Ved render
//! kjøres ffmpeg og resultatet lagres som ny CAS-entry.

use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

use crate::cas::CasStore;
use crate::jobs::JobRow;

// ─── EDL-datastrukturer ───────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdlDocument {
    pub source_hash: String,
    pub operations: Vec<EdlOperation>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EdlOperation {
    Cut {
        start_ms: i64,
        end_ms: i64,
    },
    Normalize {
        target_lufs: f64,
    },
    TrimSilence {
        threshold_db: f32,
        min_duration_ms: u32,
    },
    FadeIn {
        duration_ms: u32,
    },
    FadeOut {
        duration_ms: u32,
    },
    NoiseReduction {
        strength_db: f32,
    },
    Equalizer {
        low_gain: f32,
        mid_gain: f32,
        high_gain: f32,
    },
    Compressor {
        threshold_db: f32,
        ratio: f32,
    },
}

// ─── Analyse-resultat ─────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize)]
pub struct LoudnessInfo {
    pub input_i: f64,
    pub input_tp: f64,
    pub input_lra: f64,
    pub input_thresh: f64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SilenceRegion {
    pub start_ms: i64,
    pub end_ms: i64,
    pub duration_ms: i64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AudioInfo {
    pub duration_ms: i64,
    pub sample_rate: u32,
    pub channels: u32,
    pub codec: String,
    pub format: String,
    pub bit_rate: Option<u64>,
}

#[derive(Debug, Serialize)]
pub struct AnalyzeResult {
    pub loudness: LoudnessInfo,
    pub silence_regions: Vec<SilenceRegion>,
    pub info: AudioInfo,
}

// ─── FFmpeg-kommandoer ────────────────────────────────────────────

/// Hent metadata om en lydfil via ffprobe.
pub async fn get_audio_info(cas: &CasStore, hash: &str) -> Result<AudioInfo, String> {
    let path = cas.path_for(hash);
    if !path.exists() {
        return Err(format!("Filen finnes ikke i CAS: {hash}"));
    }

    let output = tokio::process::Command::new("ffprobe")
        .args([
            "-v", "quiet",
            "-print_format", "json",
            "-show_format",
            "-show_streams",
        ])
        .arg(&path)
        .output()
        .await
        .map_err(|e| format!("Kunne ikke kjøre ffprobe: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("ffprobe feilet: {stderr}"));
    }

    let json: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|e| format!("Kunne ikke parse ffprobe-output: {e}"))?;

    // Finn første audio-stream
    let stream = json["streams"]
        .as_array()
        .and_then(|streams| streams.iter().find(|s| s["codec_type"] == "audio"))
        .ok_or("Ingen audio-stream funnet")?;

    let format = &json["format"];

    let duration_secs: f64 = format["duration"]
        .as_str()
        .and_then(|s| s.parse().ok())
        .unwrap_or(0.0);

    Ok(AudioInfo {
        duration_ms: (duration_secs * 1000.0) as i64,
        sample_rate: stream["sample_rate"]
            .as_str()
            .and_then(|s| s.parse().ok())
            .unwrap_or(44100),
        channels: stream["channels"].as_u64().unwrap_or(2) as u32,
        codec: stream["codec_name"]
            .as_str()
            .unwrap_or("unknown")
            .to_string(),
        format: format["format_name"]
            .as_str()
            .unwrap_or("unknown")
            .to_string(),
        bit_rate: format["bit_rate"]
            .as_str()
            .and_then(|s| s.parse().ok()),
    })
}

/// Analyser loudness (EBU R128) via ffmpeg loudnorm.
pub async fn analyze_loudness(cas: &CasStore, hash: &str) -> Result<LoudnessInfo, String> {
    let path = cas.path_for(hash);
    if !path.exists() {
        return Err(format!("Filen finnes ikke i CAS: {hash}"));
    }

    let output = tokio::process::Command::new("ffmpeg")
        .args(["-i"])
        .arg(&path)
        .args([
            "-af", "loudnorm=print_format=json",
            "-f", "null", "-",
        ])
        .output()
        .await
        .map_err(|e| format!("Kunne ikke kjøre ffmpeg loudnorm: {e}"))?;

    // loudnorm skriver JSON til stderr
    let stderr = String::from_utf8_lossy(&output.stderr);

    // Finn JSON-blokken i stderr
    let json_start = stderr
        .find("{\n")
        .ok_or("Fant ikke loudnorm JSON i ffmpeg-output")?;
    let json_end = stderr[json_start..]
        .find("\n}")
        .map(|i| json_start + i + 2)
        .ok_or("Ufullstendig loudnorm JSON")?;

    let json_str = &stderr[json_start..json_end];
    let json: serde_json::Value = serde_json::from_str(json_str)
        .map_err(|e| format!("Kunne ikke parse loudnorm JSON: {e}\n{json_str}"))?;

    Ok(LoudnessInfo {
        input_i: parse_loudnorm_field(&json, "input_i")?,
        input_tp: parse_loudnorm_field(&json, "input_tp")?,
        input_lra: parse_loudnorm_field(&json, "input_lra")?,
        input_thresh: parse_loudnorm_field(&json, "input_thresh")?,
    })
}

fn parse_loudnorm_field(json: &serde_json::Value, field: &str) -> Result<f64, String> {
    json[field]
        .as_str()
        .and_then(|s| s.parse::<f64>().ok())
        .ok_or_else(|| format!("Mangler felt '{field}' i loudnorm-output"))
}

/// Detekter stille regioner i en lydfil.
pub async fn detect_silence(
    cas: &CasStore,
    hash: &str,
    threshold_db: f32,
    min_duration_ms: u32,
) -> Result<Vec<SilenceRegion>, String> {
    // Valider parametere før de interpoleres i filterstreng
    if !(-96.0..=0.0).contains(&threshold_db) {
        return Err(format!("threshold_db ({threshold_db}) må være mellom -96.0 og 0.0"));
    }
    if threshold_db.is_nan() || threshold_db.is_infinite() {
        return Err("threshold_db er ikke et gyldig tall".to_string());
    }
    if min_duration_ms == 0 {
        return Err("min_duration_ms må være større enn 0".to_string());
    }
    if min_duration_ms > 60_000 {
        return Err(format!("min_duration_ms ({min_duration_ms}) kan ikke overstige 60000 (60 sekunder)"));
    }

    let path = cas.path_for(hash);
    if !path.exists() {
        return Err(format!("Filen finnes ikke i CAS: {hash}"));
    }

    let min_duration_secs = min_duration_ms as f64 / 1000.0;
    let filter = format!("silencedetect=noise={threshold_db}dB:d={min_duration_secs}");

    let output = tokio::process::Command::new("ffmpeg")
        .args(["-i"])
        .arg(&path)
        .args(["-af", &filter, "-f", "null", "-"])
        .output()
        .await
        .map_err(|e| format!("Kunne ikke kjøre ffmpeg silencedetect: {e}"))?;

    let stderr = String::from_utf8_lossy(&output.stderr);
    let mut regions = Vec::new();
    let mut current_start: Option<f64> = None;

    for line in stderr.lines() {
        if let Some(pos) = line.find("silence_start: ") {
            let val_str = &line[pos + 15..];
            if let Some(secs) = val_str.split_whitespace().next().and_then(|s| s.parse::<f64>().ok()) {
                current_start = Some(secs);
            }
        }
        if let Some(pos) = line.find("silence_end: ") {
            let val_str = &line[pos + 13..];
            if let Some(end_secs) = val_str.split_whitespace().next().and_then(|s| s.parse::<f64>().ok()) {
                if let Some(start_secs) = current_start.take() {
                    regions.push(SilenceRegion {
                        start_ms: (start_secs * 1000.0) as i64,
                        end_ms: (end_secs * 1000.0) as i64,
                        duration_ms: ((end_secs - start_secs) * 1000.0) as i64,
                    });
                }
            }
        }
    }

    Ok(regions)
}

// ─── Parametervalidering ─────────────────────────────────────────

/// Valider at alle numeriske verdier i EDL-operasjoner er innenfor
/// sikre grenser før de interpoleres i FFmpeg-filterstrenger.
///
/// Returnerer liste med feilmeldinger (tom = gyldig).
pub fn validate_operations(ops: &[EdlOperation]) -> Result<(), String> {
    let mut errors: Vec<String> = Vec::new();

    for (i, op) in ops.iter().enumerate() {
        let idx = i + 1;
        match op {
            EdlOperation::Cut { start_ms, end_ms } => {
                if *start_ms < 0 {
                    errors.push(format!("Operasjon {idx} (cut): start_ms ({start_ms}) kan ikke være negativ"));
                }
                if *end_ms < 0 {
                    errors.push(format!("Operasjon {idx} (cut): end_ms ({end_ms}) kan ikke være negativ"));
                }
                if *end_ms <= *start_ms {
                    errors.push(format!("Operasjon {idx} (cut): end_ms ({end_ms}) må være større enn start_ms ({start_ms})"));
                }
            }
            EdlOperation::Normalize { target_lufs } => {
                // LUFS: -70 (svært stille) til 0 (maks)
                if !(-70.0..=0.0).contains(target_lufs) {
                    errors.push(format!("Operasjon {idx} (normalize): target_lufs ({target_lufs}) må være mellom -70.0 og 0.0"));
                }
            }
            EdlOperation::TrimSilence { threshold_db, min_duration_ms } => {
                // Threshold: -96 dB (nesten stille) til 0 dB
                if !(-96.0..=0.0).contains(threshold_db) {
                    errors.push(format!("Operasjon {idx} (trim_silence): threshold_db ({threshold_db}) må være mellom -96.0 og 0.0"));
                }
                if *min_duration_ms == 0 {
                    errors.push(format!("Operasjon {idx} (trim_silence): min_duration_ms må være større enn 0"));
                }
                if *min_duration_ms > 60_000 {
                    errors.push(format!("Operasjon {idx} (trim_silence): min_duration_ms ({min_duration_ms}) kan ikke overstige 60000 (60 sekunder)"));
                }
            }
            EdlOperation::FadeIn { duration_ms } => {
                if *duration_ms == 0 {
                    errors.push(format!("Operasjon {idx} (fade_in): duration_ms må være større enn 0"));
                }
                if *duration_ms > 300_000 {
                    errors.push(format!("Operasjon {idx} (fade_in): duration_ms ({duration_ms}) kan ikke overstige 300000 (5 minutter)"));
                }
            }
            EdlOperation::FadeOut { duration_ms } => {
                if *duration_ms == 0 {
                    errors.push(format!("Operasjon {idx} (fade_out): duration_ms må være større enn 0"));
                }
                if *duration_ms > 300_000 {
                    errors.push(format!("Operasjon {idx} (fade_out): duration_ms ({duration_ms}) kan ikke overstige 300000 (5 minutter)"));
                }
            }
            EdlOperation::NoiseReduction { strength_db } => {
                // afftdn nf: typisk -80 til 0 dB
                if !(-80.0..=0.0).contains(strength_db) {
                    errors.push(format!("Operasjon {idx} (noise_reduction): strength_db ({strength_db}) må være mellom -80.0 og 0.0"));
                }
            }
            EdlOperation::Equalizer { low_gain, mid_gain, high_gain } => {
                // EQ gain: -30 til +30 dB er rimelig
                for (name, val) in [("low_gain", low_gain), ("mid_gain", mid_gain), ("high_gain", high_gain)] {
                    if !(-30.0..=30.0).contains(val) {
                        errors.push(format!("Operasjon {idx} (equalizer): {name} ({val}) må være mellom -30.0 og 30.0"));
                    }
                    if val.is_nan() || val.is_infinite() {
                        errors.push(format!("Operasjon {idx} (equalizer): {name} er ikke et gyldig tall"));
                    }
                }
            }
            EdlOperation::Compressor { threshold_db, ratio } => {
                // Kompressor threshold: -60 til 0 dB
                if !(-60.0..=0.0).contains(threshold_db) {
                    errors.push(format!("Operasjon {idx} (compressor): threshold_db ({threshold_db}) må være mellom -60.0 og 0.0"));
                }
                // Ratio: 1.0 (ingen kompresjon) til 20.0 (limiter)
                if !(1.0..=20.0).contains(ratio) {
                    errors.push(format!("Operasjon {idx} (compressor): ratio ({ratio}) må være mellom 1.0 og 20.0"));
                }
            }
        }

        // Generell NaN/Inf-sjekk for alle f32/f64-verdier
        match op {
            EdlOperation::Normalize { target_lufs } => {
                if target_lufs.is_nan() || target_lufs.is_infinite() {
                    errors.push(format!("Operasjon {idx} (normalize): target_lufs er ikke et gyldig tall"));
                }
            }
            EdlOperation::TrimSilence { threshold_db, .. } => {
                if threshold_db.is_nan() || threshold_db.is_infinite() {
                    errors.push(format!("Operasjon {idx} (trim_silence): threshold_db er ikke et gyldig tall"));
                }
            }
            EdlOperation::NoiseReduction { strength_db } => {
                if strength_db.is_nan() || strength_db.is_infinite() {
                    errors.push(format!("Operasjon {idx} (noise_reduction): strength_db er ikke et gyldig tall"));
                }
            }
            EdlOperation::Compressor { threshold_db, ratio } => {
                if threshold_db.is_nan() || threshold_db.is_infinite() {
                    errors.push(format!("Operasjon {idx} (compressor): threshold_db er ikke et gyldig tall"));
                }
                if ratio.is_nan() || ratio.is_infinite() {
                    errors.push(format!("Operasjon {idx} (compressor): ratio er ikke et gyldig tall"));
                }
            }
            _ => {}
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(format!("Ugyldig EDL:\n- {}", errors.join("\n- ")))
    }
}

/// Valider fade-varigheter mot faktisk lydvarighet.
/// Kalles fra process_audio etter at vi kjenner varigheten.
pub fn validate_fade_durations(ops: &[EdlOperation], duration_ms: i64) -> Result<(), String> {
    let mut errors: Vec<String> = Vec::new();

    for (i, op) in ops.iter().enumerate() {
        let idx = i + 1;
        match op {
            EdlOperation::FadeIn { duration_ms: fade_ms } => {
                if *fade_ms as i64 > duration_ms {
                    errors.push(format!(
                        "Operasjon {idx} (fade_in): varighet ({fade_ms} ms) \
                         overstiger lydens varighet ({duration_ms} ms)"
                    ));
                }
            }
            EdlOperation::FadeOut { duration_ms: fade_ms } => {
                if *fade_ms as i64 > duration_ms {
                    errors.push(format!(
                        "Operasjon {idx} (fade_out): varighet ({fade_ms} ms) \
                         overstiger lydens varighet ({duration_ms} ms)"
                    ));
                }
            }
            _ => {}
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(format!("Ugyldig fade-varighet:\n- {}", errors.join("\n- ")))
    }
}

// ─── EDL → FFmpeg filtergraf ──────────────────────────────────────

/// Bygg ffmpeg-filtergraf fra EDL-operasjoner.
/// Returnerer (filter_string, trenger_to_pass).
///
/// Operasjonsrekkefølge:
/// 1. Cuts (aselect) — fjerner regioner
/// 2. Trim silence — konvertert til cuts
/// 3. Noise reduction (afftdn)
/// 4. EQ (equalizer)
/// 5. Compressor (acompressor)
/// 6. Normalize (loudnorm) — alltid sist før fades
/// 7. Fades (afade) — aller sist
pub fn build_filter_chain(
    ops: &[EdlOperation],
    duration_ms: i64,
    loudness_measured: Option<&LoudnessInfo>,
) -> String {
    let mut filters: Vec<String> = Vec::new();

    // Samle alle cuts (inkl. fra trim_silence)
    let mut cuts: Vec<(i64, i64)> = Vec::new();
    for op in ops {
        if let EdlOperation::Cut { start_ms, end_ms } = op {
            cuts.push((*start_ms, *end_ms));
        }
    }

    // Sorter cuts og bygg aselect-filter
    if !cuts.is_empty() {
        cuts.sort_by_key(|c| c.0);
        let conditions: Vec<String> = cuts
            .iter()
            .map(|(s, e)| {
                format!(
                    "between(t,{:.3},{:.3})",
                    *s as f64 / 1000.0,
                    *e as f64 / 1000.0
                )
            })
            .collect();
        filters.push(format!(
            "aselect='not({})',asetpts=N/SR/TB",
            conditions.join("+")
        ));
    }

    // Noise reduction
    for op in ops {
        if let EdlOperation::NoiseReduction { strength_db } = op {
            filters.push(format!("afftdn=nf={strength_db}"));
        }
    }

    // EQ — tre-bånds parametrisk
    for op in ops {
        if let EdlOperation::Equalizer {
            low_gain,
            mid_gain,
            high_gain,
        } = op
        {
            let mut eq_parts = Vec::new();
            if *low_gain != 0.0 {
                eq_parts.push(format!("equalizer=f=100:t=h:w=200:g={low_gain}"));
            }
            if *mid_gain != 0.0 {
                eq_parts.push(format!("equalizer=f=1000:t=h:w=1000:g={mid_gain}"));
            }
            if *high_gain != 0.0 {
                eq_parts.push(format!("equalizer=f=8000:t=h:w=4000:g={high_gain}"));
            }
            filters.extend(eq_parts);
        }
    }

    // Compressor
    for op in ops {
        if let EdlOperation::Compressor {
            threshold_db,
            ratio,
        } = op
        {
            filters.push(format!(
                "acompressor=threshold={threshold_db}dB:ratio={ratio}:attack=5:release=50"
            ));
        }
    }

    // Normalize (loudnorm) — to-pass hvis vi har målte verdier
    for op in ops {
        if let EdlOperation::Normalize { target_lufs } = op {
            if let Some(measured) = loudness_measured {
                filters.push(format!(
                    "loudnorm=I={target_lufs}:TP=-1.5:LRA=11:\
                     measured_I={:.1}:measured_TP={:.1}:measured_LRA={:.1}:\
                     measured_thresh={:.1}:linear=true",
                    measured.input_i,
                    measured.input_tp,
                    measured.input_lra,
                    measured.input_thresh,
                ));
            } else {
                // Enkeltpass (lavere kvalitet, men fungerer)
                filters.push(format!("loudnorm=I={target_lufs}:TP=-1.5:LRA=11"));
            }
        }
    }

    // Beregn varighet etter cuts for fade-out posisjonering
    let total_cut_ms: i64 = cuts.iter().map(|(s, e)| e - s).sum();
    let effective_duration_ms = duration_ms - total_cut_ms;

    // Fades — helt sist
    for op in ops {
        match op {
            EdlOperation::FadeIn { duration_ms } => {
                let d = *duration_ms as f64 / 1000.0;
                filters.push(format!("afade=t=in:d={d:.3}"));
            }
            EdlOperation::FadeOut { duration_ms: dur } => {
                let d = *dur as f64 / 1000.0;
                let start = ((effective_duration_ms as f64 / 1000.0) - d).max(0.0);
                filters.push(format!("afade=t=out:st={start:.3}:d={d:.3}"));
            }
            _ => {}
        }
    }

    filters.join(",")
}

// ─── Prosessering ─────────────────────────────────────────────────

/// Kjør ffmpeg med EDL-operasjoner og lagre resultatet i CAS.
pub async fn process_audio(
    cas: &CasStore,
    edl: &EdlDocument,
    output_format: &str,
) -> Result<(String, u64), String> {
    // Valider alle parametere før vi starter prosessering
    validate_operations(&edl.operations)?;

    let source_path = cas.path_for(&edl.source_hash);
    if !source_path.exists() {
        return Err(format!("Kildefil finnes ikke i CAS: {}", edl.source_hash));
    }

    // Hent info for fade-out beregning
    let info = get_audio_info(cas, &edl.source_hash).await?;

    // Valider fade-varigheter mot faktisk lydvarighet
    validate_fade_durations(&edl.operations, info.duration_ms)?;

    // Sjekk om vi trenger to-pass loudnorm
    let has_normalize = edl.operations.iter().any(|op| matches!(op, EdlOperation::Normalize { .. }));

    let loudness_measured = if has_normalize {
        // Kjør silence-detection for trim_silence operasjoner
        let silence_cuts = resolve_silence_cuts(cas, edl).await?;

        // Bygg midlertidig EDL uten normalize for pass 1
        let mut pass1_ops: Vec<EdlOperation> = edl.operations.clone();
        pass1_ops.retain(|op| !matches!(op, EdlOperation::Normalize { .. }));
        pass1_ops.extend(silence_cuts.iter().cloned());

        let pass1_filter = build_filter_chain(&pass1_ops, info.duration_ms, None);

        // Pass 1: mål loudness etter andre filtre er påført
        let measured = if pass1_filter.is_empty() {
            analyze_loudness(cas, &edl.source_hash).await?
        } else {
            analyze_with_filter(cas, &edl.source_hash, &pass1_filter).await?
        };
        Some(measured)
    } else {
        None
    };

    // Resolve trim_silence til faktiske cuts
    let silence_cuts = resolve_silence_cuts(cas, edl).await?;
    let mut all_ops = edl.operations.clone();
    // Fjern TrimSilence og legg til genererte cuts
    all_ops.retain(|op| !matches!(op, EdlOperation::TrimSilence { .. }));
    all_ops.extend(silence_cuts);

    let filter = build_filter_chain(&all_ops, info.duration_ms, loudness_measured.as_ref());

    if filter.is_empty() {
        return Err("Ingen operasjoner å utføre".to_string());
    }

    // Bestem output-codec basert på format
    let codec_args = match output_format {
        "mp3" => vec!["-codec:a", "libmp3lame", "-q:a", "2"],
        "wav" => vec!["-codec:a", "pcm_s16le"],
        "flac" => vec!["-codec:a", "flac"],
        "ogg" => vec!["-codec:a", "libvorbis", "-q:a", "6"],
        _ => vec!["-codec:a", "libmp3lame", "-q:a", "2"], // default: mp3
    };

    let ext = match output_format {
        "wav" => "wav",
        "flac" => "flac",
        "ogg" => "ogg",
        _ => "mp3",
    };

    // Output til temp-fil
    let tmp_dir = cas.root().join("tmp");
    tokio::fs::create_dir_all(&tmp_dir)
        .await
        .map_err(|e| format!("Kunne ikke opprette tmp-katalog: {e}"))?;
    let tmp_output = tmp_dir.join(format!("audio_process_{}.{ext}", Uuid::now_v7()));

    let mut cmd = tokio::process::Command::new("ffmpeg");
    cmd.args(["-i"])
        .arg(&source_path)
        .args(["-af", &filter])
        .args(&codec_args)
        .args(["-y"])
        .arg(&tmp_output);

    tracing::info!(
        source = %edl.source_hash,
        filter = %filter,
        output = %tmp_output.display(),
        "Kjører ffmpeg audio processing"
    );

    let output = cmd
        .output()
        .await
        .map_err(|e| format!("Kunne ikke kjøre ffmpeg: {e}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        // Rydd opp temp-fil
        let _ = tokio::fs::remove_file(&tmp_output).await;
        return Err(format!("ffmpeg feilet: {stderr}"));
    }

    // Les resultat og lagre i CAS
    let result_bytes = tokio::fs::read(&tmp_output)
        .await
        .map_err(|e| format!("Kunne ikke lese ffmpeg-output: {e}"))?;

    let _ = tokio::fs::remove_file(&tmp_output).await;

    let store_result = cas
        .store(&result_bytes)
        .await
        .map_err(|e| format!("Kunne ikke lagre i CAS: {e}"))?;

    tracing::info!(
        source = %edl.source_hash,
        result = %store_result.hash,
        size = store_result.size,
        "Audio processing fullført"
    );

    Ok((store_result.hash, store_result.size))
}

/// Kjør loudnorm-analyse med et forhåndsfilter (for to-pass normalisering).
async fn analyze_with_filter(
    cas: &CasStore,
    hash: &str,
    pre_filter: &str,
) -> Result<LoudnessInfo, String> {
    let path = cas.path_for(hash);
    let filter = format!("{pre_filter},loudnorm=print_format=json");

    let output = tokio::process::Command::new("ffmpeg")
        .args(["-i"])
        .arg(&path)
        .args(["-af", &filter, "-f", "null", "-"])
        .output()
        .await
        .map_err(|e| format!("Kunne ikke kjøre ffmpeg loudnorm pass 1: {e}"))?;

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

    let json_start = stderr
        .find("{\n")
        .ok_or("Fant ikke loudnorm JSON i pass 1")?;
    let json_end = stderr[json_start..]
        .find("\n}")
        .map(|i| json_start + i + 2)
        .ok_or("Ufullstendig loudnorm JSON i pass 1")?;

    let json: serde_json::Value = serde_json::from_str(&stderr[json_start..json_end])
        .map_err(|e| format!("Kunne ikke parse loudnorm pass 1: {e}"))?;

    Ok(LoudnessInfo {
        input_i: parse_loudnorm_field(&json, "input_i")?,
        input_tp: parse_loudnorm_field(&json, "input_tp")?,
        input_lra: parse_loudnorm_field(&json, "input_lra")?,
        input_thresh: parse_loudnorm_field(&json, "input_thresh")?,
    })
}

/// Konverter TrimSilence-operasjoner til faktiske Cut-operasjoner
/// ved å kjøre silence detection.
async fn resolve_silence_cuts(
    cas: &CasStore,
    edl: &EdlDocument,
) -> Result<Vec<EdlOperation>, String> {
    let mut cuts = Vec::new();
    for op in &edl.operations {
        if let EdlOperation::TrimSilence {
            threshold_db,
            min_duration_ms,
        } = op
        {
            let regions = detect_silence(cas, &edl.source_hash, *threshold_db, *min_duration_ms).await?;
            for region in regions {
                // Behold 200ms stillhet på hver side for naturlig lyd,
                // men aldri mer enn halve regionens varighet
                let region_duration = region.end_ms - region.start_ms;
                let margin_ms = 200i64.min(region_duration / 2);
                let start = region.start_ms + margin_ms;
                let end = region.end_ms - margin_ms;
                if end > start {
                    cuts.push(EdlOperation::Cut {
                        start_ms: start,
                        end_ms: end,
                    });
                }
            }
        }
    }
    Ok(cuts)
}

// ─── Jobbhåndterer — delegerer til synops-audio CLI ────────────────
//
// CLI gjør: FFmpeg-prosessering, CAS-lagring, PG-skriving, ressurslogging.
// PG NOTIFY-triggere sender sanntidsoppdateringer.

/// Synops-audio binary path.
fn audio_bin() -> String {
    std::env::var("SYNOPS_AUDIO_BIN")
        .unwrap_or_else(|_| "synops-audio".to_string())
}

/// Håndterer `audio_process`-jobber fra jobbkøen.
///
/// Spawner synops-audio med --write for å gjøre alt arbeidet:
/// FFmpeg-prosessering, CAS-lagring, PG-skriving, ressurslogging.
/// PG NOTIFY-triggere sender sanntidsoppdateringer til klienter.
///
/// Payload:
/// ```json
/// {
///   "media_node_id": "uuid",
///   "edl": { "source_hash": "...", "operations": [...] },
///   "output_format": "mp3",
///   "requested_by": "uuid"
/// }
/// ```
pub async fn handle_audio_process_job(
    job: &JobRow,
    _db: &PgPool,
    cas: &CasStore,
) -> Result<serde_json::Value, String> {
    let media_node_id: Uuid = job.payload["media_node_id"]
        .as_str()
        .and_then(|s| s.parse().ok())
        .ok_or("Mangler media_node_id i payload")?;

    let edl_value = &job.payload["edl"];
    let edl_json = serde_json::to_string(edl_value)
        .map_err(|e| format!("Kunne ikke serialisere EDL: {e}"))?;

    // Verifiser at source_hash finnes i EDL
    let cas_hash = edl_value["source_hash"]
        .as_str()
        .ok_or("Mangler source_hash i EDL")?;

    let output_format = job.payload["output_format"]
        .as_str()
        .unwrap_or("mp3");

    let requested_by: Uuid = job.payload["requested_by"]
        .as_str()
        .and_then(|s| s.parse().ok())
        .ok_or("Mangler requested_by i payload")?;

    // Bygg kommando
    let bin = audio_bin();
    let mut cmd = tokio::process::Command::new(&bin);

    cmd.arg("--cas-hash").arg(cas_hash)
        .arg("--edl").arg(&edl_json)
        .arg("--output-format").arg(output_format)
        .arg("--node-id").arg(media_node_id.to_string())
        .arg("--requested-by").arg(requested_by.to_string())
        .arg("--write");

    // Sett miljøvariabler
    crate::cli_dispatch::set_database_url(&mut cmd)?;
    cmd.env("CAS_ROOT", cas.root().to_string_lossy().to_string());

    tracing::info!(
        media_node_id = %media_node_id,
        cas_hash = %cas_hash,
        operations = edl_value["operations"].as_array().map(|a| a.len()).unwrap_or(0),
        bin = %bin,
        "Starter synops-audio"
    );

    let result = crate::cli_dispatch::run_cli_tool(&bin, &mut cmd).await?;

    // PG-skriving gjøres av synops-audio med --write.
    // PG NOTIFY-triggere sender sanntidsoppdateringer til WebSocket-klienter.

    tracing::info!(
        original = %media_node_id,
        processed = result["processed_node_id"].as_str().unwrap_or("n/a"),
        hash = result["cas_hash"].as_str().unwrap_or("n/a"),
        "synops-audio fullført"
    );

    Ok(result)
}

// ─── Tester ──────────────────────────────────────────────────────

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

    #[test]
    fn valid_operations_pass() {
        let ops = vec![
            EdlOperation::Cut { start_ms: 0, end_ms: 1000 },
            EdlOperation::Normalize { target_lufs: -16.0 },
            EdlOperation::TrimSilence { threshold_db: -30.0, min_duration_ms: 500 },
            EdlOperation::FadeIn { duration_ms: 1000 },
            EdlOperation::FadeOut { duration_ms: 2000 },
            EdlOperation::NoiseReduction { strength_db: -25.0 },
            EdlOperation::Equalizer { low_gain: 2.0, mid_gain: 0.0, high_gain: -1.0 },
            EdlOperation::Compressor { threshold_db: -20.0, ratio: 4.0 },
        ];
        assert!(validate_operations(&ops).is_ok());
    }

    #[test]
    fn cut_end_before_start_rejected() {
        let ops = vec![EdlOperation::Cut { start_ms: 5000, end_ms: 3000 }];
        let err = validate_operations(&ops).unwrap_err();
        assert!(err.contains("end_ms"));
    }

    #[test]
    fn cut_negative_start_rejected() {
        let ops = vec![EdlOperation::Cut { start_ms: -100, end_ms: 1000 }];
        let err = validate_operations(&ops).unwrap_err();
        assert!(err.contains("negativ"));
    }

    #[test]
    fn normalize_out_of_range_rejected() {
        let ops = vec![EdlOperation::Normalize { target_lufs: 5.0 }];
        assert!(validate_operations(&ops).is_err());

        let ops = vec![EdlOperation::Normalize { target_lufs: -80.0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn compressor_ratio_out_of_range_rejected() {
        let ops = vec![EdlOperation::Compressor { threshold_db: -20.0, ratio: 0.5 }];
        let err = validate_operations(&ops).unwrap_err();
        assert!(err.contains("ratio"));

        let ops = vec![EdlOperation::Compressor { threshold_db: -20.0, ratio: 25.0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn eq_gain_out_of_range_rejected() {
        let ops = vec![EdlOperation::Equalizer { low_gain: 50.0, mid_gain: 0.0, high_gain: 0.0 }];
        let err = validate_operations(&ops).unwrap_err();
        assert!(err.contains("low_gain"));
    }

    #[test]
    fn noise_reduction_out_of_range_rejected() {
        let ops = vec![EdlOperation::NoiseReduction { strength_db: 10.0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn fade_zero_duration_rejected() {
        let ops = vec![EdlOperation::FadeIn { duration_ms: 0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn trim_silence_zero_duration_rejected() {
        let ops = vec![EdlOperation::TrimSilence { threshold_db: -30.0, min_duration_ms: 0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn nan_values_rejected() {
        let ops = vec![EdlOperation::Normalize { target_lufs: f64::NAN }];
        assert!(validate_operations(&ops).is_err());

        let ops = vec![EdlOperation::Compressor { threshold_db: f32::NAN, ratio: 4.0 }];
        assert!(validate_operations(&ops).is_err());
    }

    #[test]
    fn multiple_errors_collected() {
        let ops = vec![
            EdlOperation::Normalize { target_lufs: 100.0 },
            EdlOperation::Compressor { threshold_db: 50.0, ratio: 0.0 },
        ];
        let err = validate_operations(&ops).unwrap_err();
        // Should contain errors for both operations
        assert!(err.contains("normalize"));
        assert!(err.contains("compressor"));
    }

    #[test]
    fn empty_operations_valid() {
        assert!(validate_operations(&[]).is_ok());
    }
}
