use std::{
    collections::hash_map::DefaultHasher,
    hash::{Hash, Hasher},
    process::Stdio,
    sync::Arc,
    time::Duration,
};

use axum::{
    body::Body,
    extract::{Query, State},
    http::header,
    response::{IntoResponse, Response},
};
use tokio::process::Command;

use crate::{
    app::AppState,
    error::AppError,
    media::{PathQuery, path, probe},
};

const FRAME_COUNT: usize = 10;
const FRAME_WIDTH: usize = 320;
const FRAME_HEIGHT: usize = 180;

pub async fn sprite(
    State(state): State<Arc<AppState>>,
    Query(query): Query<PathQuery>,
) -> Result<Response, AppError> {
    let media = path::resolve(&state.config, &query.path)?;
    if !media.is_file() {
        return Err(AppError::bad_request("path is not a media file"));
    }
    let _permit = state
        .preview_jobs
        .acquire()
        .await
        .map_err(AppError::internal)?;
    let metadata = probe::run(&state.config, &media).await?;
    let duration = metadata
        .format
        .and_then(|format| format.duration)
        .and_then(|value| value.parse::<f64>().ok())
        .filter(|value| value.is_finite() && *value > 0.0)
        .ok_or_else(|| AppError::bad_request("media duration is unavailable"))?;

    let timestamps = preview_timestamps(&query.path, duration);
    let mut command = Command::new(&state.config.ffmpeg);
    command.args(["-v", "error"]);
    for timestamp in timestamps {
        command
            .args(["-ss", &format!("{timestamp:.3}"), "-i"])
            .arg(&media);
    }
    let filter = sprite_filter();
    command
        .args([
            "-filter_complex",
            &filter,
            "-map",
            "[sprite]",
            "-frames:v",
            "1",
            "-c:v",
            "mjpeg",
            "-q:v",
            "5",
            "-f",
            "image2pipe",
            "pipe:1",
        ])
        .stdin(Stdio::null())
        .kill_on_drop(true);
    let output = tokio::time::timeout(Duration::from_secs(30), command.output())
        .await
        .map_err(|_| AppError::bad_request("preview generation timed out"))?
        .map_err(AppError::internal)?;
    if !output.status.success() || output.stdout.is_empty() {
        let details = String::from_utf8_lossy(&output.stderr);
        return Err(AppError::bad_request(format!(
            "failed to create preview: {}",
            details.trim()
        )));
    }
    Ok((
        [
            (header::CONTENT_TYPE, "image/jpeg"),
            (header::CACHE_CONTROL, "private, max-age=3600"),
        ],
        Body::from(output.stdout),
    )
        .into_response())
}

fn preview_timestamps(path: &str, duration: f64) -> [f64; FRAME_COUNT] {
    let mut hasher = DefaultHasher::new();
    path.hash(&mut hasher);
    let mut seed = hasher.finish();
    std::array::from_fn(|index| {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        let jitter = (seed as f64 / u64::MAX as f64 - 0.5) * 0.06;
        let position = ((index as f64 + 0.5) / FRAME_COUNT as f64 + jitter).clamp(0.02, 0.98);
        position * duration
    })
}

fn sprite_filter() -> String {
    let mut filters = (0..FRAME_COUNT)
        .map(|index| {
            format!(
                "[{index}:v:0]setpts=PTS-STARTPTS,scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{index}]"
            )
        })
        .collect::<Vec<_>>();
    let inputs = (0..FRAME_COUNT)
        .map(|index| format!("[v{index}]"))
        .collect::<String>();
    let layout = (0..FRAME_COUNT)
        .map(|index| {
            format!(
                "{}_{}",
                (index % 5) * FRAME_WIDTH,
                (index / 5) * FRAME_HEIGHT
            )
        })
        .collect::<Vec<_>>()
        .join("|");
    filters.push(format!(
        "{inputs}xstack=inputs={FRAME_COUNT}:layout={layout}[sprite]"
    ));
    filters.join(";")
}

#[cfg(test)]
mod tests {
    use super::{FRAME_COUNT, preview_timestamps, sprite_filter};

    #[test]
    fn timestamps_are_stable_and_inside_the_video() {
        let first = preview_timestamps("series/episode.mkv", 100.0);
        let second = preview_timestamps("series/episode.mkv", 100.0);
        assert_eq!(first, second);
        assert!(first.iter().all(|value| *value >= 2.0 && *value <= 98.0));
    }

    #[test]
    fn sprite_contains_every_input() {
        let filter = sprite_filter();
        assert!(filter.contains(&format!("xstack=inputs={FRAME_COUNT}")));
        for index in 0..FRAME_COUNT {
            assert!(filter.contains(&format!("[{index}:v:0]")));
        }
    }
}
