mod process;
mod session;

use std::{collections::HashMap, path::PathBuf, sync::Arc};

use axum::{
    Json,
    body::Body,
    extract::{Path, Query, State},
    http::{StatusCode, header},
    response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
use tokio::{fs, sync::Mutex};

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

use self::session::DashSession;

pub struct DashManager {
    _root: TempDir,
    root_path: PathBuf,
    sessions: Mutex<HashMap<String, Arc<DashSession>>>,
}

#[derive(Serialize)]
pub struct StartResponse {
    session_id: String,
    manifest_url: String,
    duration_seconds: Option<f64>,
    start_seconds: f64,
}

#[derive(Deserialize)]
pub struct StartQuery {
    path: String,
    #[serde(default)]
    start: f64,
}

impl DashManager {
    pub fn new() -> anyhow::Result<Self> {
        let root = tempfile::Builder::new()
            .prefix("lan-play-dash-")
            .tempdir()?;
        Ok(Self {
            root_path: root.path().to_owned(),
            _root: root,
            sessions: Mutex::new(HashMap::new()),
        })
    }
}

pub async fn start(
    State(state): State<Arc<AppState>>,
    Query(query): Query<StartQuery>,
) -> Result<Json<StartResponse>, AppError> {
    if !query.start.is_finite() || query.start < 0.0 {
        return Err(AppError::bad_request("invalid playback start position"));
    }
    let media = path::resolve(&state.config, &query.path)?;
    if !media.is_file() {
        return Err(AppError::bad_request("path is not a media file"));
    }

    let plan = playback::plan(&state.config, &media).await?;
    if plan
        .duration_seconds
        .is_some_and(|duration| query.start >= duration)
    {
        return Err(AppError::bad_request(
            "playback start is beyond the file duration",
        ));
    }
    let duration_seconds = plan.duration_seconds;
    let session = DashSession::start(
        &state.config.ffmpeg,
        &state.dash.root_path,
        &media,
        &plan,
        query.start,
    )
    .await?;
    let session_id = session.id().to_owned();
    state
        .dash
        .sessions
        .lock()
        .await
        .insert(session_id.clone(), Arc::new(session));

    Ok(Json(StartResponse {
        manifest_url: format!("/api/dash/{session_id}/manifest.mpd"),
        session_id,
        duration_seconds,
        start_seconds: query.start,
    }))
}

pub async fn asset(
    State(state): State<Arc<AppState>>,
    Path((session_id, asset)): Path<(String, String)>,
) -> Result<Response, AppError> {
    if !session::valid_asset_name(&asset) {
        return Err(AppError::bad_request("invalid DASH asset name"));
    }
    let session = state
        .dash
        .sessions
        .lock()
        .await
        .get(&session_id)
        .cloned()
        .ok_or_else(|| AppError::not_found("DASH session not found"))?;
    let file = session.asset_path(&asset);
    let bytes = fs::read(file)
        .await
        .map_err(|_| AppError::not_found("DASH asset not ready"))?;
    let content_type = asset_content_type(&asset);
    Ok((
        StatusCode::OK,
        [
            (header::CONTENT_TYPE, content_type),
            (header::CACHE_CONTROL, "no-store"),
        ],
        Body::from(bytes),
    )
        .into_response())
}

fn asset_content_type(asset: &str) -> &'static str {
    if asset.ends_with(".mpd") {
        "application/dash+xml"
    } else if dash_stream_index(asset).is_some_and(|stream| stream > 0) {
        "audio/iso.segment"
    } else if asset.ends_with(".m4s") || asset.ends_with(".mp4") {
        "video/iso.segment"
    } else {
        "application/octet-stream"
    }
}

fn dash_stream_index(asset: &str) -> Option<usize> {
    let start = asset.find("stream")? + "stream".len();
    let digits: String = asset[start..]
        .chars()
        .take_while(|character| character.is_ascii_digit())
        .collect();
    digits.parse().ok()
}

pub async fn stop(
    State(state): State<Arc<AppState>>,
    Path(session_id): Path<String>,
) -> Result<StatusCode, AppError> {
    let session = state
        .dash
        .sessions
        .lock()
        .await
        .remove(&session_id)
        .ok_or_else(|| AppError::not_found("DASH session not found"))?;
    session.stop().await;
    Ok(StatusCode::NO_CONTENT)
}

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

    #[test]
    fn uses_media_specific_content_types() {
        assert_eq!(asset_content_type("manifest.mpd"), "application/dash+xml");
        assert_eq!(asset_content_type("init-stream0.m4s"), "video/iso.segment");
        assert_eq!(
            asset_content_type("chunk-stream0-00001.m4s"),
            "video/iso.segment"
        );
        assert_eq!(asset_content_type("init-stream1.m4s"), "audio/iso.segment");
        assert_eq!(
            asset_content_type("chunk-stream1-00001.m4s"),
            "audio/iso.segment"
        );
        assert_eq!(asset_content_type("init-stream2.m4s"), "audio/iso.segment");
        assert_eq!(
            asset_content_type("chunk-stream7-00001.m4s"),
            "audio/iso.segment"
        );
    }
}
