use std::{
    path::{Component, Path, PathBuf},
    time::Duration,
};

use tokio::{fs, process::Child, sync::Mutex, time};
use uuid::Uuid;

use crate::{error::AppError, media::playback::PlaybackPlan};

use super::process;

pub struct DashSession {
    id: String,
    directory: PathBuf,
    process: Mutex<Option<Child>>,
}

impl DashSession {
    pub async fn start(
        ffmpeg: &Path,
        root: &Path,
        media: &Path,
        plan: &PlaybackPlan,
        start_seconds: f64,
    ) -> Result<Self, AppError> {
        let id = Uuid::new_v4().simple().to_string();
        let directory = root.join(&id);
        fs::create_dir(&directory)
            .await
            .map_err(AppError::internal)?;
        let mut child = match process::spawn(ffmpeg, media, &directory, plan, start_seconds) {
            Ok(child) => child,
            Err(error) => {
                fs::remove_dir_all(&directory).await.ok();
                return Err(error);
            }
        };
        wait_until_ready(&mut child, &directory, plan.audio_count()).await?;
        Ok(Self {
            id,
            directory,
            process: Mutex::new(Some(child)),
        })
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn asset_path(&self, name: &str) -> PathBuf {
        self.directory.join(name)
    }

    pub async fn stop(&self) {
        if let Some(mut child) = self.process.lock().await.take() {
            if child.try_wait().ok().flatten().is_none() {
                child.start_kill().ok();
            }
            child.wait().await.ok();
        }
        fs::remove_dir_all(&self.directory).await.ok();
    }
}

async fn wait_until_ready(
    child: &mut Child,
    directory: &Path,
    audio_count: usize,
) -> Result<(), AppError> {
    for _ in 0..1800 {
        if ready(directory, audio_count).await {
            return Ok(());
        }
        if child.try_wait().map_err(AppError::internal)?.is_some() {
            return Err(startup_error(directory, "ffmpeg stopped before DASH became ready").await);
        }
        time::sleep(Duration::from_millis(50)).await;
    }
    child.start_kill().ok();
    child.wait().await.ok();
    Err(startup_error(directory, "timed out waiting for live DASH segments").await)
}

async fn ready(directory: &Path, audio_count: usize) -> bool {
    let manifest = match fs::read_to_string(directory.join("manifest.mpd")).await {
        Ok(value) => value,
        Err(_) => return false,
    };
    if !manifest.contains("type=\"dynamic\"") && !manifest.contains("type=\"static\"") {
        return false;
    }
    if !stream_ready(directory, 0).await {
        return false;
    }
    for stream in 1..=audio_count {
        if !stream_ready(directory, stream).await {
            return false;
        }
    }
    true
}

async fn stream_ready(directory: &Path, stream: usize) -> bool {
    directory.join(format!("init-stream{stream}.m4s")).is_file()
        && has_segment(directory, stream).await
}

async fn has_segment(directory: &Path, stream: usize) -> bool {
    let prefix = format!("chunk-stream{stream}-");
    let mut entries = match fs::read_dir(directory).await {
        Ok(value) => value,
        Err(_) => return false,
    };
    while let Ok(Some(entry)) = entries.next_entry().await {
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if name.starts_with(&prefix) && name.ends_with(".m4s") {
            return true;
        }
    }
    false
}

async fn startup_error(directory: &Path, fallback: &str) -> AppError {
    let log = fs::read_to_string(directory.join("ffmpeg.log"))
        .await
        .unwrap_or_default();
    fs::remove_dir_all(directory).await.ok();
    let details = log.trim();
    AppError::bad_request(if details.is_empty() {
        fallback.to_owned()
    } else {
        format!("{fallback}: {details}")
    })
}

pub fn valid_asset_name(name: &str) -> bool {
    let mut components = Path::new(name).components();
    !name.is_empty()
        && matches!(components.next(), Some(Component::Normal(_)))
        && components.next().is_none()
        && matches!(
            Path::new(name).extension().and_then(|value| value.to_str()),
            Some("mpd" | "m4s" | "mp4")
        )
}

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

    #[test]
    fn accepts_flat_dash_assets_only() {
        assert!(valid_asset_name("manifest.mpd"));
        assert!(valid_asset_name("chunk-stream0-00001.m4s"));
        assert!(!valid_asset_name("../secret.mp4"));
        assert!(!valid_asset_name("nested/chunk.m4s"));
        assert!(!valid_asset_name("notes.txt"));
    }
}
