//! Download state and management.
//!
//! `DownloadJob` holds per-download progress/status.
//! `DownloadManager` owns the shared job list and spawns background tasks.

//! ROM and save file download management.
//!
//! This module handles the logic for downloading files from the RomM server,
//! including resume support and progress tracking.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use crate::client::RommClient;
use crate::core::extras::build_base_rom_file_targets;
use crate::core::extras::{DownloadAssetKind, DownloadTarget};
use crate::core::interrupt::is_cancelled_error;
use crate::core::utils;
use crate::types::Rom;
use anyhow::{anyhow, Context, Result};
use std::fs::File;
use zip::ZipArchive;

/// Directory for ROM storage (`ROMM_ROMS_DIR`, `ROMM_DOWNLOAD_DIR`, or configured path).
pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
    let env_override = std::env::var("ROMM_ROMS_DIR")
        .ok()
        .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
    resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
}

/// Validate configured download path without env override fallback.
pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
    resolve_download_directory_from_inputs(Some(configured_download_dir), None)
}

/// Backward-compatible default used by legacy CLI download code.
pub fn download_directory() -> PathBuf {
    std::env::var("ROMM_ROMS_DIR")
        .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("./downloads"))
}

fn resolve_download_directory_from_inputs(
    configured_download_dir: Option<&str>,
    env_override: Option<&str>,
) -> Result<PathBuf> {
    let raw = env_override
        .or(configured_download_dir)
        .map(str::trim)
        .ok_or_else(|| {
            anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
        })?;

    if raw.is_empty() {
        return Err(anyhow!("ROMs directory cannot be empty"));
    }

    let input_path = PathBuf::from(raw);
    let normalized = if input_path.is_relative() {
        std::env::current_dir()
            .context("Could not resolve current working directory")?
            .join(input_path)
    } else {
        input_path
    };

    if normalized.exists() && !normalized.is_dir() {
        return Err(anyhow!(
            "Download path is not a directory: {}",
            normalized.display()
        ));
    }

    std::fs::create_dir_all(&normalized).with_context(|| {
        format!(
            "Could not create download directory {}",
            normalized.display()
        )
    })?;

    let probe_name = format!(
        ".romm-write-test-{}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos()
    );
    let probe_path = normalized.join(probe_name);
    let probe = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&probe_path)
        .with_context(|| format!("ROMs directory is not writable: {}", normalized.display()))?;
    drop(probe);
    let _ = std::fs::remove_file(&probe_path);

    Ok(normalized)
}

/// Pick `stem.zip`, then `stem__2.zip`, `stem__3.zip`, … until the path does not exist.
pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
    let mut n = 1u32;
    loop {
        let name = if n == 1 {
            format!("{}.zip", stem)
        } else {
            format!("{}__{}.zip", stem, n)
        };
        let p = dir.join(name);
        if !p.exists() {
            return p;
        }
        n = n.saturating_add(1);
    }
}

/// Extract a ZIP archive into `destination_dir`.
pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<()> {
    let zip_path = zip_path.to_path_buf();
    let destination_dir = destination_dir.to_path_buf();
    std::fs::create_dir_all(&destination_dir).with_context(|| {
        format!(
            "Could not create extraction directory {}",
            destination_dir.display()
        )
    })?;

    let file = File::open(&zip_path)
        .with_context(|| format!("Could not open zip archive {}", zip_path.display()))?;
    let mut archive = ZipArchive::new(file)
        .with_context(|| format!("Invalid ZIP archive {}", zip_path.display()))?;
    archive.extract(&destination_dir).with_context(|| {
        format!(
            "Could not extract archive into {}",
            destination_dir.display()
        )
    })?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Job status / data
// ---------------------------------------------------------------------------

/// High-level status of a single download.
#[derive(Debug, Clone)]
pub enum DownloadStatus {
    Downloading,
    Done,
    SkippedAlreadyExists,
    Cancelled,
    FinalizeFailed(String),
    Error(String),
}

/// A single background download job (for one ROM).
#[derive(Debug, Clone)]
pub struct DownloadJob {
    pub id: usize,
    pub rom_id: u64,
    pub name: String,
    pub platform: String,
    /// 0.0 ..= 1.0
    pub progress: f64,
    pub status: DownloadStatus,
}

static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);

impl DownloadJob {
    /// Construct a new job in the `Downloading` state.
    pub fn new(rom_id: u64, name: String, platform: String) -> Self {
        Self {
            id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
            rom_id,
            name,
            platform,
            progress: 0.0,
            status: DownloadStatus::Downloading,
        }
    }

    /// Progress as percentage 0..=100.
    pub fn percent(&self) -> u16 {
        (self.progress * 100.0).round().min(100.0) as u16
    }
}

// ---------------------------------------------------------------------------
// Extras (composite) jobs
// ---------------------------------------------------------------------------

/// Outcome of one item inside an [`ExtrasJob`].
#[derive(Debug, Clone)]
pub struct ExtrasItemResult {
    pub title: String,
    pub kind: DownloadAssetKind,
    pub ok: bool,
    pub error: Option<String>,
}

/// Terminal status for a composite extras download.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtrasJobStatus {
    Running,
    Done,
    /// Some items failed (`usize` = failure count).
    PartialFailure(usize),
    AllFailed,
}

/// One queued extras batch for a parent ROM (related files + cover + manual).
#[derive(Debug, Clone)]
pub struct ExtrasJob {
    pub id: usize,
    pub rom_id: u64,
    pub name: String,
    pub platform: String,
    pub completed_items: usize,
    pub total_items: usize,
    pub status: ExtrasJobStatus,
    pub item_results: Vec<ExtrasItemResult>,
}

static NEXT_EXTRAS_JOB_ID: AtomicUsize = AtomicUsize::new(0);

impl ExtrasJob {
    pub fn new(rom_id: u64, name: String, platform: String, total_items: usize) -> Self {
        Self {
            id: NEXT_EXTRAS_JOB_ID.fetch_add(1, Ordering::Relaxed),
            rom_id,
            name,
            platform,
            completed_items: 0,
            total_items,
            status: ExtrasJobStatus::Running,
            item_results: Vec::new(),
        }
    }

    /// Progress 0..=100 from completed item count only.
    pub fn percent(&self) -> u16 {
        if self.total_items == 0 {
            return 100;
        }
        ((self.completed_items.saturating_mul(100)) / self.total_items).min(100) as u16
    }
}

fn finalize_extras_job_status(results: &[ExtrasItemResult]) -> ExtrasJobStatus {
    let n = results.len();
    if n == 0 {
        return ExtrasJobStatus::Done;
    }
    let failures = results.iter().filter(|r| !r.ok).count();
    if failures == 0 {
        ExtrasJobStatus::Done
    } else if failures == n {
        ExtrasJobStatus::AllFailed
    } else {
        ExtrasJobStatus::PartialFailure(failures)
    }
}

// ---------------------------------------------------------------------------
// Manager
// ---------------------------------------------------------------------------

/// Owns the shared download list and spawns background download tasks.
///
/// Frontends only need an `Arc<Mutex<Vec<DownloadJob>>>` to inspect jobs.
#[derive(Clone)]
pub struct DownloadManager {
    jobs: Arc<Mutex<Vec<DownloadJob>>>,
    extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
}

impl Default for DownloadManager {
    fn default() -> Self {
        Self::new()
    }
}

impl DownloadManager {
    pub fn new() -> Self {
        Self {
            jobs: Arc::new(Mutex::new(Vec::new())),
            extras_jobs: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Shared handle for observers (TUI, GUI, tests) to inspect jobs.
    pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
        self.jobs.clone()
    }

    /// Shared extras jobs (composite batches).
    pub fn shared_extras(&self) -> Arc<Mutex<Vec<ExtrasJob>>> {
        self.extras_jobs.clone()
    }

    /// Start downloading `rom` in the background; returns immediately.
    ///
    /// Progress updates are pushed into the shared `jobs` list so that
    /// any frontend can render them.
    pub fn start_download(
        &self,
        rom: &Rom,
        client: RommClient,
        configured_download_dir: Option<&str>,
    ) -> Result<()> {
        let platform = rom
            .platform_display_name
            .as_deref()
            .or(rom.platform_custom_name.as_deref())
            .unwrap_or("—")
            .to_string();

        let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
        let job_id = job.id;
        let rom_id = rom.id;
        let fs_name = rom.fs_name.clone();
        let final_console_slug = rom
            .platform_fs_slug
            .clone()
            .or_else(|| rom.platform_slug.clone())
            .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
        let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
        let rom_for_targets = rom.clone();
        match self.jobs.lock() {
            Ok(mut jobs) => jobs.push(job),
            Err(err) => {
                eprintln!("warning: download job list lock poisoned: {}", err);
                return Err(anyhow!("download job list lock poisoned: {err}"));
            }
        }

        let save_dir = resolve_download_directory(configured_download_dir)?;
        let jobs = self.jobs.clone();
        tokio::spawn(async move {
            let temp_root = save_dir.join(".tmp");
            if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
                if let Ok(mut list) = jobs.lock() {
                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                        j.status = DownloadStatus::Error(format!(
                            "Could not create temp directory {}: {err}",
                            temp_root.display()
                        ));
                    }
                }
                return;
            }

            let console_dir = save_dir.join(utils::sanitize_filename(&final_console_slug));
            let final_path = console_dir.join(final_name.clone());
            if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
                if let Ok(mut list) = jobs.lock() {
                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                        j.status = DownloadStatus::Error(format!(
                            "Could not create console directory {}: {err}",
                            console_dir.display()
                        ));
                    }
                }
                return;
            }

            let base_targets = build_base_rom_file_targets(&rom_for_targets, &save_dir);
            if !base_targets.is_empty() {
                let total_targets = base_targets.len() as f64;
                for (idx, target) in base_targets.iter().enumerate() {
                    let client = client.clone();
                    let mut progress = {
                        let jobs = jobs.clone();
                        move |received: u64, total: u64| {
                            let file_ratio = if total > 0 {
                                received as f64 / total as f64
                            } else {
                                0.0
                            };
                            let total_ratio = ((idx as f64) + file_ratio) / total_targets;
                            if let Ok(mut list) = jobs.lock() {
                                if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                    j.progress = total_ratio.min(1.0);
                                }
                            }
                        }
                    };
                    match prepare_download_target_destination(target).await {
                        Ok(true) => {
                            progress(
                                target.expected_size_bytes.unwrap_or(0),
                                target.expected_size_bytes.unwrap_or(0),
                            );
                            continue;
                        }
                        Ok(false) => {}
                        Err(err) => {
                            if let Ok(mut list) = jobs.lock() {
                                if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                    j.status = DownloadStatus::Error(err.to_string());
                                }
                            }
                            return;
                        }
                    }
                    if let Err(final_err) =
                        download_target_with_fallback(&client, target, |_, _| false, &mut progress)
                            .await
                    {
                        if let Ok(mut list) = jobs.lock() {
                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                j.status = DownloadStatus::Error(final_err.to_string());
                            }
                        }
                        return;
                    }
                }
                if let Ok(mut list) = jobs.lock() {
                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                        j.status = DownloadStatus::Done;
                        j.progress = 1.0;
                    }
                }
                return;
            }

            if final_path.exists() {
                if let Ok(mut list) = jobs.lock() {
                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                        j.status = DownloadStatus::SkippedAlreadyExists;
                        j.progress = 1.0;
                    }
                }
                return;
            }

            let temp_name = format!(
                "rom-{}-{}-{}.part",
                rom_id,
                utils::sanitize_filename(&fs_name),
                job_id
            );
            let temp_path = temp_root.join(temp_name);

            let on_progress = |received: u64, total: u64| {
                let p = if total > 0 {
                    received as f64 / total as f64
                } else {
                    0.0
                };

                if let Ok(mut list) = jobs.lock() {
                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                        j.progress = p;
                    }
                }
            };

            let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
            if download_result.is_err() {
                let _ = tokio::fs::remove_file(&temp_path).await;
            }
            match download_result {
                Ok(()) => match finalize_download(&temp_path, &final_path).await {
                    Ok(FinalizeResult::Done) => {
                        if let Ok(mut list) = jobs.lock() {
                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                j.status = DownloadStatus::Done;
                                j.progress = 1.0;
                            }
                        }
                    }
                    Ok(FinalizeResult::SkippedAlreadyExists) => {
                        if let Ok(mut list) = jobs.lock() {
                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                j.status = DownloadStatus::SkippedAlreadyExists;
                                j.progress = 1.0;
                            }
                        }
                    }
                    Err(err) => {
                        let _ = tokio::fs::remove_file(&temp_path).await;
                        if let Ok(mut list) = jobs.lock() {
                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                                j.status = DownloadStatus::FinalizeFailed(err.to_string());
                            }
                        }
                    }
                },
                Err(e) => {
                    if let Ok(mut list) = jobs.lock() {
                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                            if is_cancelled_error(&e) {
                                j.status = DownloadStatus::Cancelled;
                            } else {
                                j.status = DownloadStatus::Error(e.to_string());
                            }
                        }
                    }
                }
            }
        });
        Ok(())
    }

    /// Download selected extras targets in the background as one composite job.
    ///
    /// Uses up to 4 concurrent URL downloads. Progress is item-count only (`ExtrasJob::percent`).
    pub fn start_extras_download(
        &self,
        rom: &Rom,
        selected: Vec<DownloadTarget>,
        client: RommClient,
        configured_download_dir: Option<&str>,
    ) -> Result<()> {
        if selected.is_empty() {
            return Err(anyhow!("no extras targets selected"));
        }

        let _ = resolve_download_directory(configured_download_dir)?;

        let platform = rom
            .platform_display_name
            .as_deref()
            .or(rom.platform_custom_name.as_deref())
            .unwrap_or("—")
            .to_string();

        let total_items = selected.len();
        let job = ExtrasJob::new(rom.id, rom.name.clone(), platform, total_items);
        let job_id = job.id;

        match self.extras_jobs.lock() {
            Ok(mut jobs) => jobs.push(job),
            Err(err) => {
                eprintln!("warning: extras job list lock poisoned: {}", err);
                return Err(anyhow!("extras job list lock poisoned: {err}"));
            }
        }

        let extras_jobs = self.extras_jobs.clone();
        tokio::spawn(async move {
            let semaphore = Arc::new(tokio::sync::Semaphore::new(4));
            let mut handles = Vec::new();

            for target in selected {
                let permit = match semaphore.clone().acquire_owned().await {
                    Ok(p) => p,
                    Err(_) => break,
                };
                let client = client.clone();
                let extras_jobs = extras_jobs.clone();
                handles.push(tokio::spawn(async move {
                    let mut on_progress = |_r: u64, _t: u64| {};
                    let download_result = match prepare_download_target_destination(&target).await {
                        Ok(true) => Ok(()),
                        Ok(false) => {
                            download_target_with_fallback(
                                &client,
                                &target,
                                |_, _| false,
                                &mut on_progress,
                            )
                            .await
                        }
                        Err(err) => Err(err),
                    };

                    drop(permit);

                    let (ok, err) = match download_result {
                        Ok(()) => (true, None),
                        Err(e) => (false, Some(e.to_string())),
                    };

                    let item = ExtrasItemResult {
                        title: target.title.clone(),
                        kind: target.kind,
                        ok,
                        error: err,
                    };

                    if let Ok(mut list) = extras_jobs.lock() {
                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
                            j.completed_items = j.completed_items.saturating_add(1);
                            j.item_results.push(item);
                            if j.completed_items >= j.total_items {
                                j.status = finalize_extras_job_status(&j.item_results);
                            }
                        }
                    }
                }));
            }

            for h in handles {
                let _ = h.await;
            }
        });

        Ok(())
    }
}

pub async fn prepare_download_target_destination(target: &DownloadTarget) -> Result<bool> {
    let Some(expected_size) = target.expected_size_bytes else {
        return Ok(false);
    };
    if expected_size == 0 {
        return Ok(false);
    }

    let Ok(metadata) = tokio::fs::metadata(&target.destination).await else {
        return Ok(false);
    };
    let current_size = metadata.len();
    if current_size == expected_size {
        return Ok(true);
    }
    if current_size > expected_size {
        tokio::fs::remove_file(&target.destination)
            .await
            .with_context(|| {
                format!(
                    "remove oversized stale download {} ({} > {} bytes)",
                    target.destination.display(),
                    current_size,
                    expected_size
                )
            })?;
    }
    Ok(false)
}

async fn download_target_with_fallback<F, C>(
    client: &RommClient,
    target: &DownloadTarget,
    mut is_cancelled: C,
    on_progress: &mut F,
) -> Result<()>
where
    F: FnMut(u64, u64) + Send,
    C: FnMut(u64, u64) -> bool + Send,
{
    let urls = candidate_download_urls(target);
    let mut last_err: Option<anyhow::Error> = None;
    for url in urls {
        match client
            .download_url_with_query_with_cancel(
                &url,
                &target.source_query,
                &target.destination,
                &mut is_cancelled,
                on_progress,
            )
            .await
        {
            Ok(()) => return Ok(()),
            Err(err) => {
                if !err.to_string().contains("404 Not Found") {
                    return Err(err);
                }
                last_err = Some(err);
            }
        }
    }
    Err(last_err.unwrap_or_else(|| anyhow!("download failed without error details")))
}

fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
    let mut out = vec![target.source_url.clone()];
    if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
    } else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
    } else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
    }
    dedupe_preserve_order(out)
}

fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/roms/";
    let marker = "/files/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/romsfiles/";
    let marker = "/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/roms/files/";
    let marker = "/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for u in urls {
        if seen.insert(u.clone()) {
            out.push(u);
        }
    }
    out
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FinalizeResult {
    Done,
    SkippedAlreadyExists,
}

async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
    if final_path.exists() {
        let _ = tokio::fs::remove_file(temp_path).await;
        return Ok(FinalizeResult::SkippedAlreadyExists);
    }

    match tokio::fs::rename(temp_path, final_path).await {
        Ok(()) => Ok(FinalizeResult::Done),
        Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
            tokio::fs::copy(temp_path, final_path)
                .await
                .with_context(|| {
                    format!(
                        "Could not copy temp ROM {} to final destination {}",
                        temp_path.display(),
                        final_path.display()
                    )
                })?;
            let file = tokio::fs::File::open(final_path).await.with_context(|| {
                format!(
                    "Could not open finalized ROM for sync: {}",
                    final_path.display()
                )
            })?;
            file.sync_all().await.with_context(|| {
                format!(
                    "Could not sync finalized ROM to disk: {}",
                    final_path.display()
                )
            })?;
            tokio::fs::remove_file(temp_path).await.with_context(|| {
                format!(
                    "Could not remove temp ROM after copy: {}",
                    temp_path.display()
                )
            })?;
            Ok(FinalizeResult::Done)
        }
        Err(rename_err) => Err(anyhow!(
            "Could not move temp ROM {} to final destination {}: {}",
            temp_path.display(),
            final_path.display(),
            rename_err
        )),
    }
}

fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
    matches!(err.raw_os_error(), Some(18) | Some(17))
}

fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
    let sanitized = utils::sanitize_filename(fs_name);
    if sanitized.trim().is_empty() {
        format!("rom-{rom_id}.zip")
    } else {
        sanitized
    }
}

#[cfg(test)]
fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
    let platform_slug = rom
        .platform_fs_slug
        .clone()
        .or_else(|| rom.platform_slug.clone())
        .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
    let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
    console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::Rom;
    use std::io::Write;
    use std::time::{SystemTime, UNIX_EPOCH};
    use zip::write::SimpleFileOptions;
    use zip::ZipWriter;

    fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
        Rom {
            id: 42,
            platform_id: 7,
            platform_slug: Some("nintendo-switch".to_string()),
            platform_fs_slug: platform_fs_slug.map(ToString::to_string),
            platform_custom_name: None,
            platform_display_name: None,
            fs_name: fs_name.to_string(),
            fs_name_no_tags: "game".to_string(),
            fs_name_no_ext: "game".to_string(),
            fs_extension: "zip".to_string(),
            fs_path: "/game.zip".to_string(),
            fs_size_bytes: 1,
            name: "Game".to_string(),
            slug: None,
            summary: None,
            path_cover_small: None,
            path_cover_large: None,
            url_cover: None,
            has_manual: false,
            path_manual: None,
            url_manual: None,
            is_unidentified: false,
            is_identified: true,
            files: Vec::new(),
        }
    }

    #[test]
    fn extras_job_percent_tracks_completed_items() {
        let mut j = ExtrasJob::new(1, "Zelda".into(), "NES".into(), 4);
        assert_eq!(j.percent(), 0);
        j.completed_items = 2;
        assert_eq!(j.percent(), 50);
        j.completed_items = 4;
        assert_eq!(j.percent(), 100);
    }

    #[test]
    fn finalize_extras_job_status_reflects_failures() {
        use crate::core::extras::DownloadAssetKind;

        let ok = ExtrasItemResult {
            title: "a".into(),
            kind: DownloadAssetKind::Cover,
            ok: true,
            error: None,
        };
        let bad = ExtrasItemResult {
            title: "b".into(),
            kind: DownloadAssetKind::Manual,
            ok: false,
            error: Some("e".into()),
        };
        assert_eq!(
            super::finalize_extras_job_status(&[ok.clone(), ok.clone()]),
            ExtrasJobStatus::Done
        );
        assert_eq!(
            super::finalize_extras_job_status(&[bad.clone(), bad.clone()]),
            ExtrasJobStatus::AllFailed
        );
        assert_eq!(
            super::finalize_extras_job_status(&[ok, bad]),
            ExtrasJobStatus::PartialFailure(1)
        );
    }

    #[test]
    fn unique_zip_path_skips_existing_files() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
        std::fs::create_dir_all(&dir).unwrap();
        let p1 = dir.join("game.zip");
        std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
        let p2 = unique_zip_path(&dir, "game");
        assert_eq!(p2.file_name().unwrap(), "game__2.zip");
        let _ = std::fs::remove_file(&p1);
        let _ = std::fs::remove_dir(&dir);
    }

    #[test]
    fn resolve_download_directory_rejects_empty_configured_path() {
        let err = resolve_download_directory_from_inputs(Some("   "), None)
            .expect_err("empty configured path should be rejected");
        assert!(
            err.to_string().contains("cannot be empty"),
            "unexpected error: {err:#}"
        );
    }

    #[test]
    fn resolve_download_directory_creates_missing_nested_directory() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
        let nested = base.join("a").join("b").join("c");
        let nested_str = nested.to_string_lossy().to_string();

        let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
            .expect("expected missing directory to be created");

        assert!(resolved.is_dir(), "resolved path must be a directory");
        assert!(nested.is_dir(), "nested path should be created");
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn resolve_download_directory_fails_when_target_is_a_file() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
        std::fs::create_dir_all(&base).expect("create base dir");
        let file_path = base.join("not-a-dir.txt");
        std::fs::write(&file_path, b"x").expect("create file");
        let input = file_path.to_string_lossy().to_string();

        let err = resolve_download_directory_from_inputs(Some(&input), None)
            .expect_err("file target must fail");
        assert!(
            err.to_string().contains("not a directory"),
            "unexpected error: {err:#}"
        );

        let _ = std::fs::remove_file(&file_path);
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn resolve_download_directory_env_override_takes_precedence() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
        let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
        let configured_str = configured.to_string_lossy().to_string();
        let env_str = env_dir.to_string_lossy().to_string();

        let resolved =
            resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
                .expect("env override should be used");

        assert_eq!(resolved, env_dir);
        assert!(env_dir.is_dir(), "env directory should be created");
        assert!(
            !configured.is_dir(),
            "configured path should be ignored when env override is set"
        );
        let _ = std::fs::remove_dir_all(&env_dir);
    }

    #[test]
    fn final_download_path_uses_console_folder_and_original_file_name() {
        let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
        let base = PathBuf::from("/roms");
        let out = final_download_path_for_rom(&base, &rom);
        assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
    }

    #[test]
    fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
        let target = DownloadTarget {
            kind: DownloadAssetKind::RomFile,
            title: "Update".into(),
            source_url: "/api/roms/11/files/content/update%2Ensp".into(),
            source_query: Vec::new(),
            destination: PathBuf::from("/tmp/update.nsp"),
            expected_size_bytes: Some(11),
        };

        assert_eq!(
            candidate_download_urls(&target),
            vec![
                "/api/roms/11/files/content/update%2Ensp".to_string(),
                "/api/romsfiles/11/content/update%2Ensp".to_string(),
                "/api/roms/files/11/content/update%2Ensp".to_string()
            ]
        );
    }

    #[test]
    fn romsfiles_candidate_falls_forward_to_current_official_path() {
        let target = DownloadTarget {
            kind: DownloadAssetKind::RomFile,
            title: "Update".into(),
            source_url: "/api/romsfiles/11/content/update%2Ensp".into(),
            source_query: Vec::new(),
            destination: PathBuf::from("/tmp/update.nsp"),
            expected_size_bytes: Some(11),
        };

        assert_eq!(
            candidate_download_urls(&target),
            vec![
                "/api/romsfiles/11/content/update%2Ensp".to_string(),
                "/api/roms/11/files/content/update%2Ensp".to_string(),
                "/api/roms/files/11/content/update%2Ensp".to_string()
            ]
        );
    }

    #[test]
    fn legacy_roms_files_candidate_falls_forward_to_romsfiles() {
        let target = DownloadTarget {
            kind: DownloadAssetKind::RomFile,
            title: "Update".into(),
            source_url: "/api/roms/files/11/content/update%2Ensp".into(),
            source_query: Vec::new(),
            destination: PathBuf::from("/tmp/update.nsp"),
            expected_size_bytes: Some(11),
        };

        assert_eq!(
            candidate_download_urls(&target),
            vec![
                "/api/roms/files/11/content/update%2Ensp".to_string(),
                "/api/roms/11/files/content/update%2Ensp".to_string(),
                "/api/romsfiles/11/content/update%2Ensp".to_string()
            ]
        );
    }

    #[tokio::test]
    async fn prepare_target_removes_oversized_stale_rom_file() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("romm-oversized-target-{ts}.nsp"));
        tokio::fs::write(&path, b"too-large").await.unwrap();

        let target = DownloadTarget {
            kind: DownloadAssetKind::RomFile,
            title: "Base".into(),
            source_url: "/api/roms/1/files/content/base.nsp".into(),
            source_query: Vec::new(),
            destination: path.clone(),
            expected_size_bytes: Some(4),
        };

        let skip = prepare_download_target_destination(&target).await.unwrap();
        assert!(!skip);
        assert!(!path.exists());
    }

    #[tokio::test]
    async fn prepare_target_skips_exact_size_rom_file() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("romm-exact-target-{ts}.nsp"));
        tokio::fs::write(&path, b"done").await.unwrap();

        let target = DownloadTarget {
            kind: DownloadAssetKind::RomFile,
            title: "Base".into(),
            source_url: "/api/roms/1/files/content/base.nsp".into(),
            source_query: Vec::new(),
            destination: path.clone(),
            expected_size_bytes: Some(4),
        };

        let skip = prepare_download_target_destination(&target).await.unwrap();
        assert!(skip);
        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"done");
        let _ = tokio::fs::remove_file(path).await;
    }

    #[tokio::test]
    async fn base_target_prepare_skips_exact_size_file() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-base-exact-{ts}"));
        let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
        rom.files = vec![crate::types::RomFile {
            id: 1,
            rom_id: rom.id,
            file_name: "base.nsp".into(),
            file_path: "/base.nsp".into(),
            file_size_bytes: 4,
            category: Some(crate::types::RomFileCategory::Game),
        }];
        let target = build_base_rom_file_targets(&rom, &base).remove(0);
        tokio::fs::create_dir_all(target.destination.parent().unwrap())
            .await
            .unwrap();
        tokio::fs::write(&target.destination, b"done")
            .await
            .unwrap();

        let skip = prepare_download_target_destination(&target).await.unwrap();
        assert!(skip);
        assert_eq!(tokio::fs::read(&target.destination).await.unwrap(), b"done");
        let _ = tokio::fs::remove_dir_all(base).await;
    }

    #[tokio::test]
    async fn base_target_prepare_removes_oversized_file() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-base-oversized-{ts}"));
        let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
        rom.files = vec![crate::types::RomFile {
            id: 1,
            rom_id: rom.id,
            file_name: "base.nsp".into(),
            file_path: "/base.nsp".into(),
            file_size_bytes: 4,
            category: Some(crate::types::RomFileCategory::Game),
        }];
        let target = build_base_rom_file_targets(&rom, &base).remove(0);
        tokio::fs::create_dir_all(target.destination.parent().unwrap())
            .await
            .unwrap();
        tokio::fs::write(&target.destination, b"too-large")
            .await
            .unwrap();

        let skip = prepare_download_target_destination(&target).await.unwrap();
        assert!(!skip);
        assert!(!target.destination.exists());
        let _ = tokio::fs::remove_dir_all(base).await;
    }

    #[tokio::test]
    async fn finalize_download_skips_when_final_exists() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
        std::fs::create_dir_all(&base).unwrap();
        let temp = base.join("temp.part");
        let final_path = base.join("final.zip");
        std::fs::write(&temp, b"temp").unwrap();
        std::fs::write(&final_path, b"existing").unwrap();

        let result = finalize_download(&temp, &final_path).await.unwrap();
        assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
        assert!(
            !temp.exists(),
            "temp file should be removed when final destination exists"
        );

        let _ = std::fs::remove_file(&final_path);
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn extract_zip_archive_writes_files_to_destination() {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("romm-extract-{ts}"));
        let zip_path = base.join("sample.zip");
        let out_dir = base.join("out");
        std::fs::create_dir_all(&base).unwrap();

        let zip_file = std::fs::File::create(&zip_path).unwrap();
        let mut writer = ZipWriter::new(zip_file);
        writer
            .start_file("nested/game.rom", SimpleFileOptions::default())
            .unwrap();
        writer.write_all(b"rom-bytes").unwrap();
        writer.finish().unwrap();

        extract_zip_archive(&zip_path, &out_dir).unwrap();

        let extracted = out_dir.join("nested").join("game.rom");
        assert!(
            extracted.exists(),
            "expected extracted file at {:?}",
            extracted
        );
        let data = std::fs::read(&extracted).unwrap();
        assert_eq!(data, b"rom-bytes");

        let _ = std::fs::remove_dir_all(&base);
    }
}
