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

use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use tempfile::TempPath;
use tokio::io::AsyncWriteExt;

use crate::api::types::Clip;
use crate::core::CliError;
use crate::net::http;

const DOWNLOAD_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_DOWNLOAD_FILENAME_BYTES: usize = 240;

#[derive(Debug)]
pub struct StagedDownload {
    temp_path: Option<TempPath>,
    destination_path: PathBuf,
    force: bool,
}

impl StagedDownload {
    pub fn path(&self) -> &Path {
        self.temp_path
            .as_deref()
            .expect("staged download path is available before commit")
    }

    pub fn commit_after<F>(self, postprocess: F) -> Result<String, CliError>
    where
        F: FnOnce(&Path) -> Result<(), CliError>,
    {
        postprocess(self.path())?;
        self.commit()
    }

    fn commit(mut self) -> Result<String, CliError> {
        let temp_path = self
            .temp_path
            .take()
            .expect("staged download path is available before commit");
        commit_download(temp_path, &self.destination_path, self.force)?;
        Ok(self.destination_path.display().to_string())
    }
}

fn download_progress_bar(total: u64, quiet: bool) -> ProgressBar {
    if quiet {
        ProgressBar::hidden()
    } else {
        ProgressBar::new(total)
    }
}

pub async fn download_clip(
    clip: &Clip,
    output_dir: &str,
    video: bool,
    force: bool,
    quiet: bool,
) -> Result<String, CliError> {
    let url = if video {
        clip.video_url
            .as_deref()
            .ok_or_else(|| CliError::Download("no video URL available".into()))?
    } else {
        clip.audio_url
            .as_deref()
            .ok_or_else(|| CliError::Download("no audio URL available".into()))?
    };

    let ext = if video { "mp4" } else { "mp3" };
    download_clip_url(clip, output_dir, url, ext, force, quiet).await
}

pub async fn download_clip_url(
    clip: &Clip,
    output_dir: &str,
    url: &str,
    ext: &str,
    force: bool,
    quiet: bool,
) -> Result<String, CliError> {
    stage_clip_url(clip, output_dir, url, ext, force, quiet)
        .await?
        .commit()
}

pub async fn stage_clip_url(
    clip: &Clip,
    output_dir: &str,
    url: &str,
    ext: &str,
    force: bool,
    quiet: bool,
) -> Result<StagedDownload, CliError> {
    stage_clip_url_with_idle_timeout(
        clip,
        output_dir,
        url,
        ext,
        force,
        quiet,
        DOWNLOAD_IDLE_TIMEOUT,
    )
    .await
}

async fn stage_clip_url_with_idle_timeout(
    clip: &Clip,
    output_dir: &str,
    url: &str,
    ext: &str,
    force: bool,
    quiet: bool,
    idle_timeout: Duration,
) -> Result<StagedDownload, CliError> {
    let filename = download_filename(clip, ext);
    let output_dir = Path::new(output_dir);
    tokio::fs::create_dir_all(output_dir).await?;
    let path = output_dir.join(&filename);
    reject_existing_output(&path, force).await?;

    let resp = tokio::time::timeout(idle_timeout, http::download_client()?.get(url).send())
        .await
        .map_err(|_| CliError::Download(format!("download stalled before response: {filename}")))?
        .map_err(CliError::Http)?
        .error_for_status()
        .map_err(CliError::Http)?;

    let total = resp.content_length().unwrap_or(0);
    let pb = download_progress_bar(total, quiet);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{msg} [{bar:40}] {bytes}/{total_bytes} ({eta})")
            .unwrap_or_else(|_| ProgressStyle::default_bar())
            .progress_chars("=> "),
    );
    pb.set_message(filename.clone());

    let temp_path = TempPath::try_from_path(temporary_path(output_dir))?;
    let temp_file_path: &Path = temp_path.as_ref();
    let mut file = tokio::fs::File::create(temp_file_path).await?;
    let result = async {
        let mut stream = resp.bytes_stream();
        while let Some(chunk) = tokio::time::timeout(idle_timeout, stream.next())
            .await
            .map_err(|_| CliError::Download(format!("download stalled: {filename}")))?
        {
            let chunk = chunk.map_err(CliError::Http)?;
            pb.inc(chunk.len() as u64);
            file.write_all(&chunk).await?;
        }
        file.flush().await?;
        Ok::<(), CliError>(())
    }
    .await;
    drop(file);

    if let Err(error) = result {
        pb.abandon_with_message("failed");
        return Err(error);
    }

    pb.finish_with_message("downloaded");
    Ok(StagedDownload {
        temp_path: Some(temp_path),
        destination_path: path,
        force,
    })
}

fn download_filename(clip: &Clip, ext: &str) -> String {
    let slug = clip
        .title
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        .replace("--", "-")
        .trim_matches('-')
        .to_string();
    let short_id = clip.id.chars().take(8).collect::<String>();
    let suffix = format!("-{short_id}.{ext}");
    let max_slug_bytes = MAX_DOWNLOAD_FILENAME_BYTES.saturating_sub(suffix.len());
    let mut slug_end = slug.len().min(max_slug_bytes);
    while !slug.is_char_boundary(slug_end) {
        slug_end -= 1;
    }
    let slug = slug[..slug_end].trim_matches('-');
    let slug = if slug.is_empty() { "untitled" } else { slug };
    format!("{slug}{suffix}")
}

async fn reject_existing_output(path: &Path, force: bool) -> Result<(), CliError> {
    if output_exists_as_regular_file(path).await? && !force {
        return Err(existing_output_error(path));
    }
    Ok(())
}

fn commit_download(temp_path: TempPath, path: &Path, force: bool) -> Result<(), CliError> {
    let result = if force {
        temp_path.persist(path)
    } else {
        temp_path.persist_noclobber(path)
    };
    result.map_err(|error| {
        if error.error.kind() == std::io::ErrorKind::AlreadyExists {
            existing_output_error(path)
        } else {
            error.error.into()
        }
    })
}

async fn output_exists_as_regular_file(path: &Path) -> Result<bool, CliError> {
    match tokio::fs::metadata(path).await {
        Ok(metadata) if metadata.is_file() => Ok(true),
        Ok(_) => Err(CliError::Download(format!(
            "output path exists but is not a file: {}",
            path.display()
        ))),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(error) => Err(error.into()),
    }
}

fn existing_output_error(path: &Path) -> CliError {
    CliError::Download(format!(
        "output file already exists: {} (pass --force to replace it)",
        path.display()
    ))
}

fn temporary_path(output_dir: &Path) -> PathBuf {
    output_dir.join(format!(".sunox-{}.part", uuid::Uuid::new_v4()))
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    use crate::api::types::Clip;
    use crate::core::CliError;

    use super::{
        DOWNLOAD_IDLE_TIMEOUT, download_clip_url, download_filename, download_progress_bar,
        stage_clip_url, stage_clip_url_with_idle_timeout,
    };

    fn clip() -> Clip {
        Clip {
            id: "clip-a".into(),
            title: "Track".into(),
            status: "complete".into(),
            model_name: "chirp-fenix".into(),
            audio_url: None,
            video_url: None,
            image_url: None,
            created_at: "2026-07-10T00:00:00Z".into(),
            play_count: 0,
            upvote_count: 0,
            metadata: Default::default(),
        }
    }

    fn test_dir(name: &str) -> PathBuf {
        std::env::temp_dir().join(format!("sunox-{name}-{}", uuid::Uuid::new_v4()))
    }

    #[test]
    fn production_idle_timeout_is_not_reduced_for_the_test_build() {
        assert_eq!(DOWNLOAD_IDLE_TIMEOUT, std::time::Duration::from_secs(60));
    }

    #[test]
    fn quiet_download_uses_a_hidden_progress_bar() {
        assert!(download_progress_bar(1024, true).is_hidden());
    }

    #[test]
    fn download_filename_bounds_long_unicode_titles() {
        let mut long_title_clip = clip();
        long_title_clip.title = "界".repeat(100);

        let filename = download_filename(&long_title_clip, "mp3");

        assert!(
            filename.len() <= 240,
            "filename was {} bytes",
            filename.len()
        );
        assert!(filename.ends_with("-clip-a.mp3"));
    }

    async fn audio_server(body: &'static [u8]) -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind audio server");
        let address = listener.local_addr().expect("audio server address");
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("accept request");
            let mut request = [0_u8; 1024];
            let _ = stream.read(&mut request).await;
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
                body.len()
            );
            stream
                .write_all(response.as_bytes())
                .await
                .expect("write headers");
            stream.write_all(body).await.expect("write body");
        });
        format!("http://{address}/track.mp3")
    }

    async fn truncated_audio_server(body: &'static [u8]) -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind truncated audio server");
        let address = listener
            .local_addr()
            .expect("truncated audio server address");
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("accept request");
            let mut request = [0_u8; 1024];
            let _ = stream.read(&mut request).await;
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
                body.len() + 1
            );
            stream
                .write_all(response.as_bytes())
                .await
                .expect("write headers");
            stream.write_all(body).await.expect("write truncated body");
        });
        format!("http://{address}/track.mp3")
    }

    async fn stalled_audio_server() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind stalled audio server");
        let address = listener.local_addr().expect("stalled audio server address");
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("accept request");
            let mut request = [0_u8; 1024];
            let _ = stream.read(&mut request).await;
            stream
                .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\nconnection: close\r\n\r\n")
                .await
                .expect("write headers");
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            let _ = stream.write_all(b"audio").await;
        });
        format!("http://{address}/track.mp3")
    }

    async fn header_stalled_audio_server() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind header-stalled audio server");
        let address = listener
            .local_addr()
            .expect("header-stalled audio server address");
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("accept request");
            let mut request = [0_u8; 1024];
            let _ = stream.read(&mut request).await;
            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        });
        format!("http://{address}/track.mp3")
    }

    #[tokio::test]
    async fn download_creates_missing_output_directory() {
        let dir = test_dir("download-creates-dir");
        let output_dir = dir.join("nested").join("songs");
        let url = audio_server(b"audio").await;

        let path = download_clip_url(
            &clip(),
            &output_dir.to_string_lossy(),
            &url,
            "mp3",
            false,
            true,
        )
        .await
        .expect("download into a new output directory");

        assert_eq!(std::fs::read(path).expect("downloaded audio"), b"audio");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn download_preserves_existing_file_without_force() {
        let dir = test_dir("download-preserves-existing");
        std::fs::create_dir_all(&dir).expect("create output directory");
        let destination = dir.join("track-clip-a.mp3");
        std::fs::write(&destination, b"original").expect("write existing file");
        let url = audio_server(b"replacement").await;

        let error = download_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", false, true)
            .await
            .expect_err("existing output must not be overwritten by default");

        assert!(matches!(error, CliError::Download(message) if message.contains("already exists")));
        assert_eq!(
            std::fs::read(&destination).expect("existing file"),
            b"original"
        );
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn download_replaces_existing_file_only_when_forced() {
        let dir = test_dir("download-force-replaces");
        std::fs::create_dir_all(&dir).expect("create output directory");
        let destination = dir.join("track-clip-a.mp3");
        std::fs::write(&destination, b"original").expect("write existing file");
        let url = audio_server(b"replacement").await;

        let path = download_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", true, true)
            .await
            .expect("force should replace existing output");

        assert_eq!(
            std::fs::read(path).expect("replacement file"),
            b"replacement"
        );
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn failed_postprocessing_preserves_a_forced_destination() {
        let dir = test_dir("download-force-postprocess-failure");
        std::fs::create_dir_all(&dir).expect("create output directory");
        let destination = dir.join("track-clip-a.mp3");
        std::fs::write(&destination, b"original").expect("write existing file");
        let url = audio_server(b"replacement").await;
        let staged = stage_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", true, true)
            .await
            .expect("stage forced replacement");
        let temporary = staged.path().to_path_buf();

        let error = staged
            .commit_after(|_| Err(CliError::Download("post-processing failed".into())))
            .expect_err("post-processing must fail before commit");

        assert!(
            matches!(error, CliError::Download(message) if message.contains("post-processing"))
        );
        assert_eq!(
            std::fs::read(&destination).expect("original destination"),
            b"original"
        );
        assert!(!temporary.exists(), "failed staging file must be removed");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn successful_postprocessing_commits_a_forced_destination() {
        let dir = test_dir("download-force-postprocess-success");
        std::fs::create_dir_all(&dir).expect("create output directory");
        let destination = dir.join("track-clip-a.mp3");
        std::fs::write(&destination, b"original").expect("write existing file");
        let url = audio_server(b"replacement").await;
        let staged = stage_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", true, true)
            .await
            .expect("stage forced replacement");

        let path = staged
            .commit_after(|temporary_path| {
                std::fs::write(temporary_path, b"processed")?;
                Ok(())
            })
            .expect("commit processed replacement");

        assert_eq!(std::fs::read(path).expect("processed file"), b"processed");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn forced_download_refuses_a_directory_at_the_output_path() {
        let dir = test_dir("download-force-refuses-directory");
        std::fs::create_dir_all(dir.join("track-clip-a.mp3")).expect("create output directory");
        let url = audio_server(b"replacement").await;

        let error = download_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", true, true)
            .await
            .expect_err("a directory must not be moved aside as a forced download target");

        assert!(matches!(error, CliError::Download(message) if message.contains("not a file")));
        assert!(dir.join("track-clip-a.mp3").is_dir());
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn interrupted_download_removes_partial_file() {
        let dir = test_dir("download-cleans-partial");
        let url = truncated_audio_server(b"audio").await;

        let error = download_clip_url(&clip(), &dir.to_string_lossy(), &url, "mp3", false, true)
            .await
            .expect_err("truncated response must fail");

        assert!(matches!(error, CliError::Http(_)));
        let files = std::fs::read_dir(&dir)
            .expect("output directory")
            .collect::<Result<Vec<_>, _>>()
            .expect("read output directory");
        assert!(files.is_empty(), "partial file must be cleaned up");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn stalled_download_times_out_and_removes_partial_file() {
        let dir = test_dir("download-times-out-when-stalled");
        let url = stalled_audio_server().await;

        let error = stage_clip_url_with_idle_timeout(
            &clip(),
            &dir.to_string_lossy(),
            &url,
            "mp3",
            false,
            true,
            std::time::Duration::from_millis(10),
        )
        .await
        .expect_err("a stalled body must not wait forever");

        assert!(matches!(error, CliError::Download(message) if message.contains("stalled")));
        let files = std::fs::read_dir(&dir)
            .expect("output directory")
            .collect::<Result<Vec<_>, _>>()
            .expect("read output directory");
        assert!(
            files.is_empty(),
            "stalled download must clean temporary files"
        );
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn stalled_response_headers_respect_the_idle_timeout() {
        let dir = test_dir("download-header-timeout");
        let url = header_stalled_audio_server().await;

        let error = tokio::time::timeout(
            std::time::Duration::from_millis(50),
            stage_clip_url_with_idle_timeout(
                &clip(),
                &dir.to_string_lossy(),
                &url,
                "mp3",
                false,
                true,
                std::time::Duration::from_millis(10),
            ),
        )
        .await
        .expect("response headers must use the configured idle timeout")
        .expect_err("stalled response headers must fail");

        assert!(matches!(error, CliError::Download(message) if message.contains("stalled")));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn cancelling_a_body_download_removes_the_staging_file() {
        let dir = test_dir("download-cancel-cleans-staging");
        let url = stalled_audio_server().await;

        let result = tokio::time::timeout(
            std::time::Duration::from_millis(10),
            stage_clip_url_with_idle_timeout(
                &clip(),
                &dir.to_string_lossy(),
                &url,
                "mp3",
                false,
                true,
                std::time::Duration::from_secs(1),
            ),
        )
        .await;

        assert!(result.is_err(), "outer cancellation must win");
        let files = std::fs::read_dir(&dir)
            .expect("output directory")
            .collect::<Result<Vec<_>, _>>()
            .expect("read output directory");
        assert!(files.is_empty(), "cancellation must remove staging files");
        let _ = std::fs::remove_dir_all(dir);
    }

    #[tokio::test]
    async fn staging_does_not_expand_a_legal_destination_filename_past_fs_limits() {
        let dir = test_dir("download-long-title");
        let url = audio_server(b"audio").await;
        let mut long_title_clip = clip();
        long_title_clip.title = "a".repeat(202);

        let staged = stage_clip_url(
            &long_title_clip,
            &dir.to_string_lossy(),
            &url,
            "mp3",
            false,
            true,
        )
        .await
        .expect("a legal destination filename must have a legal staging filename");

        assert!(
            staged
                .path()
                .file_name()
                .expect("temporary filename")
                .to_string_lossy()
                .len()
                < 255
        );
        drop(staged);
        let _ = std::fs::remove_dir_all(dir);
    }
}
