//! Tauri commands for download operations

#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::{Manager, State};

use super::{DatabaseWrapper, SmartCacheWrapper};
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
use crate::download::{DownloadInfo, DownloadManager};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};

// Cohesive command clusters in their own submodules, re-exported so the command
// names remain at `commands::download::*` (invoke_handler unchanged).
mod pinning;
mod smart_cache;
pub use pinning::*;
pub use smart_cache::*;

/// One row of the series episode listing used when queueing a whole series:
/// `(id, name, season_name, index_number, parent_index_number)`.
type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);

/// Wrapper for DownloadManager to be used as Tauri state
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);

/// Wrapper for the current network transport, used by the WiFi-only gate.
///
/// TRACES: UR-053 | DR-074
pub struct NetworkStateWrapper(pub NetworkStateHandle);

/// Report the device's current network transport (Android → Rust).
///
/// The frontend calls this on startup and whenever the native network callback
/// fires. Updating to an acceptable network re-pumps the download queue, so a
/// queue parked on "waiting for WiFi" drains itself without user action.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn set_network_state(
    app: tauri::AppHandle,
    network: NetworkStateWrapperArg,
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
) -> Result<(), String> {
    let new_state = NetworkState {
        network_type: network.network_type,
        unmetered: network.unmetered,
    };

    let handle = app.state::<NetworkStateWrapper>().0.clone();
    let previous = handle.get().await;
    handle.set(new_state).await;

    if previous != new_state {
        info!(
            "[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
            previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
        );
    }

    // If the new network unblocks the gate, drain whatever was waiting.
    if downloads_allowed_on_current_network(&app).await {
        let db_service = {
            let database = db.0.lock().map_err(|e| e.to_string())?;
            Arc::new(database.service())
        };
        let active = {
            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
            manager.get_active_downloads()
        };
        pump_download_queue(app.clone(), db_service, active).await;
    }

    Ok(())
}

/// Argument struct for [`set_network_state`].
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkStateWrapperArg {
    pub network_type: NetworkType,
    pub unmetered: bool,
}

/// Whether downloads are currently permitted by the WiFi-only gate.
///
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
/// rather than leaving them looking silently stuck.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
    Ok(downloads_allowed_on_current_network(&app).await)
}

/// Download statistics computed server-side
#[allow(dead_code)]
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadStats {
    pub total: usize,
    pub active_count: usize,
    pub queued_count: usize,
    pub completed_count: usize,
    pub failed_count: usize,
    pub paused_count: usize,
}

/// Enhanced response with pre-computed stats
#[allow(dead_code)]
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
    pub downloads: Vec<DownloadInfo>,
    pub stats: DownloadStats,
}

/// Sanitize filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
    name.chars()
        .map(|c| match c {
            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
            _ => c,
        })
        .collect()
}

/// Request payload for download_item_and_start (bundled to stay within specta's
/// 10-argument command limit).
#[derive(Debug, specta::Type, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadItemAndStartRequest {
    pub item_id: String,
    pub user_id: String,
    pub stream_url: String,
    pub target_dir: String,
    pub item_name: Option<String>,
    pub artist_name: Option<String>,
    pub album_name: Option<String>,
}

/// Request payload for download_item.
#[derive(Debug, specta::Type, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadItemRequest {
    pub item_id: String,
    pub user_id: String,
    pub file_path: String,
    pub mime_type: Option<String>,
    pub priority: Option<i32>,
    pub item_name: Option<String>,
    pub artist_name: Option<String>,
    pub album_name: Option<String>,
    pub expected_size: Option<i64>,
}

/// Request payload for download_video.
#[derive(Debug, specta::Type, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadVideoRequest {
    pub item_id: String,
    pub user_id: String,
    pub file_path: String,
    pub mime_type: Option<String>,
    pub priority: Option<i32>,
    pub item_name: Option<String>,
    pub quality_preset: Option<String>,
    pub series_name: Option<String>,
    pub season_name: Option<String>,
    pub episode_number: Option<i32>,
    pub season_number: Option<i32>,
}

/// Queue and start a download in a single atomic operation
/// This simplifies the frontend flow by combining multiple steps
#[tauri::command]
#[specta::specta]
pub async fn download_item_and_start(
    db: State<'_, DatabaseWrapper>,
    smart_cache: State<'_, SmartCacheWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    app: tauri::AppHandle,
    request: DownloadItemAndStartRequest,
) -> Result<i64, String> {
    let DownloadItemAndStartRequest {
        item_id,
        user_id,
        stream_url,
        target_dir,
        item_name,
        artist_name,
        album_name,
    } = request;
    // Sanitize filename
    let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
    let file_path = format!("downloads/{}.mp3", safe_name);

    // Queue the download
    let download_id = download_item(
        db.clone(),
        smart_cache.clone(),
        DownloadItemRequest {
            item_id,
            user_id,
            file_path,
            mime_type: None,
            priority: None,
            item_name,
            artist_name,
            album_name,
            expected_size: None,
        },
    )
    .await?;

    // Start the download immediately
    start_download(
        db,
        download_manager,
        app,
        download_id,
        stream_url,
        target_dir,
    )
    .await?;

    Ok(download_id)
}

/// Queue a media item for download
#[tauri::command]
#[specta::specta]
pub async fn download_item(
    db: State<'_, DatabaseWrapper>,
    smart_cache: State<'_, SmartCacheWrapper>,
    request: DownloadItemRequest,
) -> Result<i64, String> {
    let DownloadItemRequest {
        item_id,
        user_id,
        file_path,
        mime_type,
        priority,
        item_name,
        artist_name,
        album_name,
        expected_size,
    } = request;
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Check storage limit if size is known
    if let Some(size) = expected_size {
        // Clone Arc to avoid holding lock during async operations
        let cache_arc = {
            let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
            guard.clone()
        };

        // Check if we have space
        let can_download = cache_arc
            .can_download_async(&db_service, &user_id, size as u64)
            .await;

        if !can_download {
            warn!("Storage limit reached. Attempting to free space...");

            // Reclaim expired temporary entries first: they are dead weight, so
            // freeing them may avoid evicting cache that is still within its
            // life. Best-effort — a failure here just means eviction does more.
            // TRACES: UR-071 | DR-127
            match cache_arc
                .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
                .await
            {
                Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
                Ok(_) => {}
                Err(e) => warn!("Expired-entry reclaim failed: {}", e),
            }

            // Try to evict LRU items to make space
            match cache_arc
                .evict_lru_async(&db_service, &user_id, size as u64)
                .await
            {
                Ok(freed) if freed > 0 => {
                    info!("Freed {} bytes, proceeding with download", freed);
                }
                Ok(_) => {
                    let storage_limit =
                        cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
                    return Err(format!(
                        "Storage limit reached ({} bytes). Unable to free enough space.",
                        storage_limit
                    ));
                }
                Err(e) => {
                    error!("Failed to evict items: {}", e);
                    return Err(format!("Storage limit reached. Eviction failed: {}", e));
                }
            }
        }
    }

    // Insert or update download record with metadata
    let insert_query = Query::with_params(
        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name)
         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?)
         ON CONFLICT(item_id, user_id) DO UPDATE SET
           priority = excluded.priority,
           status = 'pending',
           queued_at = CURRENT_TIMESTAMP,
           item_name = COALESCE(excluded.item_name, downloads.item_name),
           artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
           album_name = COALESCE(excluded.album_name, downloads.album_name)",
        vec![
            QueryParam::String(item_id.clone()),
            QueryParam::String(user_id.clone()),
            QueryParam::String(file_path),
            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
            QueryParam::Int(priority.unwrap_or(0)),
            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
            artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
            album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
        ],
    );

    db_service
        .execute(insert_query)
        .await
        .map_err(|e| e.to_string())?;

    // Query for the download ID by unique constraint columns
    // NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
    let id_query = Query::with_params(
        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
    );

    let download_id: i64 = db_service
        .query_one(id_query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    Ok(download_id)
}

/// One track of an album, as the album-download path queues it.
///
/// `artist_name` carries whatever the catalog holds for the track's artists (a
/// JSON array, as stored on `items.artists`); it is display metadata for the
/// downloads list, not a lookup key.
///
/// TRACES: UR-018, UR-055 | DR-173
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AlbumTrack {
    pub id: String,
    pub name: String,
    pub artist_name: Option<String>,
    pub album_name: Option<String>,
    pub index_number: Option<i32>,
}

impl From<&crate::repository::types::MediaItem> for AlbumTrack {
    fn from(item: &crate::repository::types::MediaItem) -> Self {
        Self {
            id: item.id.clone(),
            name: item.name.clone(),
            artist_name: item
                .artists
                .as_ref()
                .and_then(|a| serde_json::to_string(a).ok()),
            album_name: item.album_name.clone(),
            index_number: item.index_number,
        }
    }
}

/// The album's tracks as the local catalog cache knows them.
///
/// Only a fallback for [`download_album`]: the cache links a track to its album
/// through `items.album_id`, which Jellyfin does not populate on every listing
/// endpoint, so this can legitimately return fewer tracks than the album has.
///
/// TRACES: UR-018, UR-055 | DR-173
pub(crate) async fn cached_album_tracks(
    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    album_id: &str,
) -> Result<Vec<AlbumTrack>, String> {
    let tracks_query = Query::with_params(
        "SELECT id, name, artists, album_name, index_number FROM items
         WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
         ORDER BY index_number",
        vec![
            QueryParam::String(album_id.to_string()),
            QueryParam::String(album_id.to_string()),
        ],
    );

    db_service
        .query_many(tracks_query, |row| {
            Ok(AlbumTrack {
                id: row.get(0)?,
                name: row.get(1)?,
                artist_name: row.get(2)?,
                album_name: row.get(3)?,
                index_number: row.get(4)?,
            })
        })
        .await
        .map_err(|e| e.to_string())
}

/// Queue one download row per track and link every track to its album.
///
/// The linkage is the half that is easy to miss: offline browsing joins a track
/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
/// track whose cached row lacks it stays invisible under the album even after
/// its file is on disk. Queuing a track *is* the statement that it belongs to
/// this album, so the link is written here rather than hoped for from whichever
/// listing endpoint happened to cache the row.
///
/// Idempotent: re-queuing an album fills in what is missing and returns the same
/// row ids, in the order the tracks were given.
///
/// A file name per track, unique within the album.
///
/// A title is not a unique name inside its own album: a deluxe edition carries
/// the album version and a demo of the same song, and a two-disc set repeats
/// titles across discs. Naming files after the title alone gave those tracks one
/// path, and each download overwrote the previous one — an album that quietly
/// ends up short by however many titles it repeats. The track number
/// disambiguates the ordinary case; anything still colliding falls back to the
/// item id, which is unique by construction.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    for track in tracks {
        *counts.entry(track.name.to_lowercase()).or_default() += 1;
    }

    tracks
        .iter()
        .map(|track| {
            let title = sanitize_filename(&track.name);
            if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
                return format!("{}.mp3", title);
            }
            match track.index_number {
                Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
                None => format!("{} [{}].mp3", title, track.id),
            }
        })
        .collect()
}

/// TRACES: UR-018, UR-055 | DR-173 | UT-170
pub(crate) async fn queue_album_tracks(
    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    album_id: &str,
    tracks: &[AlbumTrack],
    user_id: &str,
    base_path: &str,
) -> Result<Vec<i64>, String> {
    let mut download_ids = Vec::with_capacity(tracks.len());
    let file_names = album_file_names(tracks);

    for (track, file_name) in tracks.iter().zip(file_names) {
        // Cache a row for a track the catalog has never seen, borrowing the
        // album's server. Nothing is inserted when the album itself is unknown,
        // which also keeps the parent_id foreign key satisfiable.
        let cache_query = Query::with_params(
            "INSERT OR IGNORE INTO items
                (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
             SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
             FROM items a WHERE a.id = ?",
            vec![
                QueryParam::String(track.id.clone()),
                QueryParam::String(track.name.clone()),
                track
                    .album_name
                    .clone()
                    .map(QueryParam::String)
                    .unwrap_or(QueryParam::Null),
                track
                    .artist_name
                    .clone()
                    .map(QueryParam::String)
                    .unwrap_or(QueryParam::Null),
                track
                    .index_number
                    .map(QueryParam::Int)
                    .unwrap_or(QueryParam::Null),
                QueryParam::String(album_id.to_string()),
            ],
        );
        db_service
            .execute(cache_query)
            .await
            .map_err(|e| e.to_string())?;

        // Link an already-cached track to the album. The parent_id subquery
        // resolves to NULL when the album is not cached, so the foreign key
        // holds either way.
        let link_query = Query::with_params(
            "UPDATE items
                SET album_id = ?,
                    parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
              WHERE id = ?",
            vec![
                QueryParam::String(album_id.to_string()),
                QueryParam::String(album_id.to_string()),
                QueryParam::String(track.id.clone()),
            ],
        );
        db_service
            .execute(link_query)
            .await
            .map_err(|e| e.to_string())?;

        let file_path = format!("{}/{}", base_path, file_name);

        // Queue at album priority (100). A track already downloaded stays
        // completed — re-queuing an album must fill the gaps, not re-fetch it.
        let insert_query = Query::with_params(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
             VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
             ON CONFLICT(item_id, user_id) DO UPDATE SET
               priority = 100,
               status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
               media_type = 'audio',
               item_name = COALESCE(excluded.item_name, downloads.item_name),
               artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
               album_name = COALESCE(excluded.album_name, downloads.album_name)",
            vec![
                QueryParam::String(track.id.clone()),
                QueryParam::String(user_id.to_string()),
                QueryParam::String(file_path),
                QueryParam::String(track.name.clone()),
                track
                    .artist_name
                    .clone()
                    .map(QueryParam::String)
                    .unwrap_or(QueryParam::Null),
                track
                    .album_name
                    .clone()
                    .map(QueryParam::String)
                    .unwrap_or(QueryParam::Null),
            ],
        );

        db_service
            .execute(insert_query)
            .await
            .map_err(|e| e.to_string())?;

        // Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
        let id_query = Query::with_params(
            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
            vec![
                QueryParam::String(track.id.clone()),
                QueryParam::String(user_id.to_string()),
            ],
        );

        let download_id: i64 = db_service
            .query_one(id_query, |row| row.get(0))
            .await
            .map_err(|e| e.to_string())?;
        download_ids.push(download_id);
    }

    Ok(download_ids)
}

/// Queue an entire album for download.
///
/// Owns the whole operation: the album's track list comes from the server (the
/// only place that knows all of it), every track is queued and linked to its
/// album, each row's stream URL is resolved here, and the queue is pumped.
///
/// The frontend used to do the second half — resolve one URL per track and pair
/// it with the returned ids **by position**. That pairing had no basis: the ids
/// came back in the backend's own order over a different set of rows, so
/// whenever the two lists disagreed a row was handed another track's URL, and
/// any track past the end of the shorter list was never started at all. Nothing
/// crosses the boundary now except the album id.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tauri::command]
#[specta::specta]
// Three of the eight arguments are Tauri `State<'_, _>` injections plus the
// `AppHandle`, not caller input. Folding the rest into a struct would change the
// IPC contract and the generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn download_album(
    db: State<'_, DatabaseWrapper>,
    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    app: tauri::AppHandle,
    handle: String,
    album_id: String,
    user_id: String,
    base_path: String,
) -> Result<Vec<i64>, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let repo = repository.0.get(&handle);

    // Ask the server what the album contains; the cache is only a fallback for
    // when it cannot answer.
    let tracks: Vec<AlbumTrack> = match &repo {
        Some(repo) => match repo.get_album_tracks(&album_id).await {
            Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
            Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
            Err(e) => {
                warn!(
                    "[download_album] Could not list album {} from the repository ({:?}); \
                     falling back to the cached track list",
                    album_id, e
                );
                cached_album_tracks(&db_service, &album_id).await?
            }
        },
        None => cached_album_tracks(&db_service, &album_id).await?,
    };

    if tracks.is_empty() {
        warn!("[download_album] No tracks found for album {}", album_id);
        return Ok(Vec::new());
    }

    let download_ids =
        queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;

    info!(
        "[download_album] Queued {} track(s) for album {}",
        download_ids.len(),
        album_id
    );

    // Resolve each queued row's stream URL here, then pump. Without a
    // repository (or while offline) the rows stay pending with no URL and
    // `resume_queued_downloads` picks them up on reconnect.
    let Some(repo) = repo else {
        return Ok(download_ids);
    };

    let target_dir = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        database
            .path()
            .parent()
            .ok_or_else(|| "Database path has no parent directory".to_string())?
            .to_string_lossy()
            .to_string()
    };

    let repo_for_resolve = Arc::clone(&repo);
    let outcome = crate::commands::catalog::resolve_pending_download_urls(
        &db_service,
        &target_dir,
        Some(&download_ids),
        move |item_id: String, _media_type: String, _quality: String| {
            let repo = Arc::clone(&repo_for_resolve);
            async move {
                use crate::repository::MediaRepository;
                match repo.get_audio_stream_url(&item_id).await {
                    Ok(url) => Some(url),
                    Err(e) => {
                        warn!(
                            "[download_album] Failed to resolve stream URL for {}: {:?}",
                            item_id, e
                        );
                        None
                    }
                }
            }
        },
    )
    .await?;

    if outcome.failed > 0 {
        warn!(
            "[download_album] {} track(s) could not be resolved and stay queued for the next \
             reconnect",
            outcome.failed
        );
    }

    let active_downloads = {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.get_active_downloads()
    };
    pump_download_queue(app, db_service, active_downloads).await;

    Ok(download_ids)
}

/// Queue a video item (movie or episode) for download with quality preset
#[tauri::command]
#[specta::specta]
pub async fn download_video(
    db: State<'_, DatabaseWrapper>,
    request: DownloadVideoRequest,
) -> Result<i64, String> {
    let DownloadVideoRequest {
        item_id,
        user_id,
        file_path,
        mime_type,
        priority,
        item_name,
        quality_preset,
        series_name,
        season_name,
        episode_number,
        season_number,
    } = request;
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let quality = quality_preset.unwrap_or_else(|| "original".to_string());

    // Insert or update download record with video metadata
    let insert_query = Query::with_params(
        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at,
                                item_name, quality_preset, media_type, series_name, season_name,
                                episode_number, season_number)
         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
         ON CONFLICT(item_id, user_id) DO UPDATE SET
           priority = excluded.priority,
           status = 'pending',
           queued_at = CURRENT_TIMESTAMP,
           quality_preset = excluded.quality_preset,
           item_name = COALESCE(excluded.item_name, downloads.item_name),
           series_name = COALESCE(excluded.series_name, downloads.series_name),
           season_name = COALESCE(excluded.season_name, downloads.season_name),
           episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
           season_number = COALESCE(excluded.season_number, downloads.season_number)",
        vec![
            QueryParam::String(item_id.clone()),
            QueryParam::String(user_id.clone()),
            QueryParam::String(file_path),
            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
            QueryParam::Int(priority.unwrap_or(0)),
            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
            QueryParam::String(quality),
            series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
            season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
            episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
            season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
        ],
    );

    db_service
        .execute(insert_query)
        .await
        .map_err(|e| e.to_string())?;

    // Query for the download ID by unique constraint columns
    let id_query = Query::with_params(
        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
    );

    let download_id: i64 = db_service
        .query_one(id_query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    Ok(download_id)
}

/// Queue all episodes of a series for download
#[tauri::command]
#[specta::specta]
pub async fn download_series(
    db: State<'_, DatabaseWrapper>,
    series_id: String,
    series_name: String,
    user_id: String,
    base_path: String,
    quality_preset: Option<String>,
) -> Result<Vec<i64>, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let quality = quality_preset.unwrap_or_else(|| "original".to_string());

    // Get all episodes for this series, ordered by season and episode number
    let episodes_query = Query::with_params(
        "SELECT id, name, season_name, index_number, parent_index_number
         FROM items
         WHERE series_id = ? AND item_type = 'Episode'
         ORDER BY parent_index_number, index_number",
        vec![QueryParam::String(series_id)],
    );

    let episodes: Vec<EpisodeRow> = db_service
        .query_many(episodes_query, |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
            ))
        })
        .await
        .map_err(|e| e.to_string())?;

    let mut download_ids = Vec::new();

    // Queue each episode with descending priority (first episodes download first)
    // Priority starts high and decreases so earlier episodes finish first
    let total_episodes = episodes.len() as i32;
    for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
        episodes.into_iter().enumerate()
    {
        let priority = 1000 - idx as i32; // High priority for first episodes

        // Create path like: videos/SeriesName/S01E01_Title.mp4
        let season_num = season_number.unwrap_or(1);
        let episode_num = episode_number.unwrap_or(1);
        let file_name = format!(
            "S{:02}E{:02}_{}.mp4",
            season_num,
            episode_num,
            sanitize_filename(&episode_name)
        );
        let file_path = format!(
            "{}/{}/{}",
            base_path,
            sanitize_filename(&series_name),
            file_name
        );

        let insert_query = Query::with_params(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
                                    item_name, quality_preset, media_type, series_name, season_name,
                                    episode_number, season_number)
             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
             ON CONFLICT(item_id, user_id) DO UPDATE SET
               priority = excluded.priority,
               status = 'pending',
               queued_at = CURRENT_TIMESTAMP,
               quality_preset = excluded.quality_preset,
               item_name = COALESCE(excluded.item_name, downloads.item_name),
               series_name = COALESCE(excluded.series_name, downloads.series_name),
               season_name = COALESCE(excluded.season_name, downloads.season_name),
               episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
               season_number = COALESCE(excluded.season_number, downloads.season_number)",
            vec![
                QueryParam::String(episode_id.clone()),
                QueryParam::String(user_id.clone()),
                QueryParam::String(file_path),
                QueryParam::Int(priority),
                QueryParam::String(episode_name),
                QueryParam::String(quality.clone()),
                QueryParam::String(series_name.clone()),
                season_name
                    .map(QueryParam::String)
                    .unwrap_or(QueryParam::Null),
                episode_number
                    .map(QueryParam::Int)
                    .unwrap_or(QueryParam::Null),
                season_number
                    .map(QueryParam::Int)
                    .unwrap_or(QueryParam::Null),
            ],
        );

        db_service
            .execute(insert_query)
            .await
            .map_err(|e| e.to_string())?;

        let id_query = Query::with_params(
            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
            vec![
                QueryParam::String(episode_id),
                QueryParam::String(user_id.clone()),
            ],
        );

        let download_id: i64 = db_service
            .query_one(id_query, |row| row.get(0))
            .await
            .map_err(|e| e.to_string())?;

        download_ids.push(download_id);
    }

    info!(
        "[download_series] Queued {} episodes for series '{}'",
        total_episodes, series_name
    );
    Ok(download_ids)
}

/// Queue all episodes of a specific season for download
#[tauri::command]
#[specta::specta]
// One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are
// the season's identifying fields. Folding them into a struct would change the
// IPC contract and the generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn download_season(
    db: State<'_, DatabaseWrapper>,
    season_id: String,
    series_name: String,
    season_name: String,
    season_number: i32,
    user_id: String,
    base_path: String,
    quality_preset: Option<String>,
) -> Result<Vec<i64>, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let quality = quality_preset.unwrap_or_else(|| "original".to_string());

    // Get all episodes for this season, ordered by episode number
    let episodes_query = Query::with_params(
        "SELECT id, name, index_number
         FROM items
         WHERE parent_id = ? AND item_type = 'Episode'
         ORDER BY index_number",
        vec![QueryParam::String(season_id)],
    );

    let episodes: Vec<(String, String, Option<i32>)> = db_service
        .query_many(episodes_query, |row| {
            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
        })
        .await
        .map_err(|e| e.to_string())?;

    let mut download_ids = Vec::new();

    // Queue each episode with priority based on episode number
    for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() {
        let priority = 1000 - idx as i32;
        let episode_num = episode_number.unwrap_or(1);

        let file_name = format!(
            "S{:02}E{:02}_{}.mp4",
            season_number,
            episode_num,
            sanitize_filename(&episode_name)
        );
        let file_path = format!(
            "{}/{}/{}",
            base_path,
            sanitize_filename(&series_name),
            file_name
        );

        let insert_query = Query::with_params(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
                                    item_name, quality_preset, media_type, series_name, season_name,
                                    episode_number, season_number)
             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
             ON CONFLICT(item_id, user_id) DO UPDATE SET
               priority = excluded.priority,
               status = 'pending',
               queued_at = CURRENT_TIMESTAMP,
               quality_preset = excluded.quality_preset",
            vec![
                QueryParam::String(episode_id.clone()),
                QueryParam::String(user_id.clone()),
                QueryParam::String(file_path),
                QueryParam::Int(priority),
                QueryParam::String(episode_name),
                QueryParam::String(quality.clone()),
                QueryParam::String(series_name.clone()),
                QueryParam::String(season_name.clone()),
                QueryParam::Int(episode_num),
                QueryParam::Int(season_number),
            ],
        );

        db_service
            .execute(insert_query)
            .await
            .map_err(|e| e.to_string())?;

        let id_query = Query::with_params(
            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
            vec![
                QueryParam::String(episode_id),
                QueryParam::String(user_id.clone()),
            ],
        );

        let download_id: i64 = db_service
            .query_one(id_query, |row| row.get(0))
            .await
            .map_err(|e| e.to_string())?;

        download_ids.push(download_id);
    }

    info!(
        "[download_season] Queued {} episodes for {} - {}",
        download_ids.len(),
        series_name,
        season_name
    );
    Ok(download_ids)
}

/// Helper to compute download statistics from a list of downloads
#[allow(dead_code)]
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
    let mut stats = DownloadStats {
        total: downloads.len(),
        active_count: 0,
        queued_count: 0,
        completed_count: 0,
        failed_count: 0,
        paused_count: 0,
    };

    for download in downloads {
        match download.status.as_str() {
            "downloading" => stats.active_count += 1,
            "pending" => stats.queued_count += 1,
            "completed" => stats.completed_count += 1,
            "failed" => stats.failed_count += 1,
            "paused" => stats.paused_count += 1,
            _ => {}
        }
    }

    stats
}

/// Get all downloads for a user, optionally filtered by status
#[tauri::command]
#[specta::specta]
pub async fn get_downloads(
    db: State<'_, DatabaseWrapper>,
    user_id: String,
    status_filter: Option<Vec<String>>,
) -> Result<DownloadsResponse, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Use COALESCE to prefer stored metadata over LEFT JOIN results
    // This ensures correct display even when items aren't synced locally
    let (query_str, params) = if let Some(ref statuses) = status_filter {
        let placeholders = statuses.iter().map(|_| "?").collect::<Vec<_>>().join(",");
        let sql = format!(
            "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
                    d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
                    d.retry_count, d.priority,
                    COALESCE(d.item_name, i.name) as item_name,
                    COALESCE(d.artist_name, i.artists) as artist_name,
                    COALESCE(d.album_name, i.album_name) as album_name,
                    COALESCE(d.series_name, i.series_name) as series_name,
                    COALESCE(d.season_name, i.season_name) as season_name,
                    COALESCE(d.episode_number, i.index_number) as episode_number,
                    COALESCE(d.season_number, i.parent_index_number) as season_number,
                    d.quality_preset,
                    COALESCE(d.media_type, 'audio') as media_type,
                    COALESCE(d.download_source, 'user') as download_source
             FROM downloads d
             LEFT JOIN items i ON d.item_id = i.id
             WHERE d.user_id = ? AND d.status IN ({})
             ORDER BY d.priority DESC, d.queued_at ASC",
            placeholders
        );
        let mut p = vec![QueryParam::String(user_id.clone())];
        p.extend(statuses.iter().map(|s| QueryParam::String(s.clone())));
        (sql, p)
    } else {
        let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
                d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
                d.retry_count, d.priority,
                COALESCE(d.item_name, i.name) as item_name,
                COALESCE(d.artist_name, i.artists) as artist_name,
                COALESCE(d.album_name, i.album_name) as album_name,
                COALESCE(d.series_name, i.series_name) as series_name,
                COALESCE(d.season_name, i.season_name) as season_name,
                COALESCE(d.episode_number, i.index_number) as episode_number,
                COALESCE(d.season_number, i.parent_index_number) as season_number,
                d.quality_preset,
                COALESCE(d.media_type, 'audio') as media_type,
                COALESCE(d.download_source, 'user') as download_source
         FROM downloads d
         LEFT JOIN items i ON d.item_id = i.id
         WHERE d.user_id = ?
         ORDER BY d.priority DESC, d.queued_at ASC"
            .to_string();
        (sql, vec![QueryParam::String(user_id)])
    };

    let query = Query::with_params(query_str, params);

    let downloads = db_service
        .query_many(query, map_download_row)
        .await
        .map_err(|e| e.to_string())?;

    // Compute stats in single pass
    let stats = compute_download_stats(&downloads);

    Ok(DownloadsResponse { downloads, stats })
}

/// Pause a download.
///
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
/// streaming task knew nothing about the row and kept running, then overwrote it
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
/// undid itself — the reported "pause does not work". Signalling the worker is
/// what actually stops the bytes; it leaves the `.part` file in place so
/// [`resume_download`] can continue from it.
///
/// A queued (not yet started) download has no worker to signal, and the status
/// write alone is enough — the pump skips anything that is not `pending`.
///
/// TRACES: UR-055 | DR-168
#[tauri::command]
#[specta::specta]
pub async fn pause_download(
    db: State<'_, DatabaseWrapper>,
    download_id: i64,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let query = Query::with_params(
        "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
        vec![QueryParam::Int64(download_id)],
    );

    db_service.execute(query).await.map_err(|e| e.to_string())?;

    let was_running = crate::download::stop::signal(download_id);
    info!(
        "[pause] Download {} paused (in flight: {})",
        download_id, was_running
    );
    Ok(())
}

/// Resume a paused download.
///
/// Flipping the row back to `pending` is likewise not enough on its own: the
/// pump is not a poller, it runs when something calls it, so a resumed download
/// sat untouched until some unrelated event happened to pump the queue. That is
/// the other half of "resume does not work".
///
/// TRACES: UR-055 | DR-168
#[tauri::command]
#[specta::specta]
pub async fn resume_download(
    app: tauri::AppHandle,
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    download_id: i64,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let query = Query::with_params(
        "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
        vec![QueryParam::Int64(download_id)],
    );

    db_service.execute(query).await.map_err(|e| e.to_string())?;

    // Drop any stale stop flag before the pump can start this id again, or the
    // resumed run would read the pause that stopped it and halt immediately.
    crate::download::stop::clear(download_id);

    let active_downloads = {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.get_active_downloads()
    };
    pump_download_queue(app, db_service, active_downloads).await;

    Ok(())
}

/// Cancel a download
#[tauri::command]
#[specta::specta]
pub async fn cancel_download(
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    download_id: i64,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Get file path before deleting
    let file_query = Query::with_params(
        "SELECT file_path FROM downloads WHERE id = ?",
        vec![QueryParam::Int64(download_id)],
    );

    let file_path: Option<String> = db_service
        .query_optional(file_query, |row| row.get(0))
        .await
        .ok()
        .flatten();

    // Delete from database
    let delete_query = Query::with_params(
        "DELETE FROM downloads WHERE id = ?",
        vec![QueryParam::Int64(download_id)],
    );

    db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    // Stop the worker if this download is actually running. Without this the
    // task keeps streaming into a `.part` file whose `downloads` row has just
    // been deleted — bytes with nothing pointing at them, and the file below is
    // removed while still being written to. (DR-168)
    crate::download::stop::signal(download_id);
    crate::download::stop::clear(download_id);

    // Unregister from download manager (in case it was active)
    {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.unregister_download(download_id);
        info!(
            "Cancelled download {}. Active downloads: {}",
            download_id,
            manager.active_count()
        );
    }

    // Delete the partial file, and any completed file, if present. Both go
    // through `partial_path` so this cannot drift from what the worker writes —
    // it did, and every cancelled download leaked its partial. (DR-169)
    if let Some(path) = file_path {
        let target = std::path::PathBuf::from(&path);
        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
    }

    Ok(())
}

/// Mark a download as completed
#[tauri::command]
#[specta::specta]
pub async fn mark_download_completed(
    db: State<'_, DatabaseWrapper>,
    download_id: i64,
    bytes_downloaded: i64,
    file_path: String,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let query = Query::with_params(
        "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?,
         file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
        vec![
            QueryParam::Int64(bytes_downloaded),
            QueryParam::Int64(bytes_downloaded),
            QueryParam::String(file_path),
            QueryParam::Int64(download_id),
        ],
    );

    db_service.execute(query).await.map_err(|e| e.to_string())?;
    Ok(())
}

/// Mark a download as failed
#[tauri::command]
#[specta::specta]
pub async fn mark_download_failed(
    db: State<'_, DatabaseWrapper>,
    download_id: i64,
    error_message: String,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    let query = Query::with_params(
        "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
        vec![
            QueryParam::String(error_message),
            QueryParam::Int64(download_id),
        ],
    );

    db_service.execute(query).await.map_err(|e| e.to_string())?;
    Ok(())
}

/// Start downloading a file immediately
/// This command actually downloads the file using the worker
#[tauri::command]
#[specta::specta]
pub async fn start_download(
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    app: tauri::AppHandle,
    download_id: i64,
    stream_url: String,
    target_dir: String,
) -> Result<(), String> {
    use crate::download::events::DownloadEvent;
    use tauri::Emitter;

    debug!("start_download called for download_id: {}", download_id);
    debug!("   stream_url: {}", stream_url);
    debug!("   target_dir: {}", target_dir);

    // Check concurrent download limit and register this download
    {
        let manager = download_manager.0.lock().map_err(|e| {
            error!("Failed to lock download manager: {}", e);
            format!("Failed to lock download manager: {}", e)
        })?;

        if !manager.can_start_download() {
            warn!(
                "Cannot start download: maximum concurrent downloads ({}) reached",
                manager.max_concurrent()
            );
            debug!("   Active downloads: {}", manager.active_count());
            return Err(format!(
                "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
                manager.max_concurrent()
            ));
        }

        // Register this download as active
        let registered = manager.register_download(download_id);
        if !registered {
            warn!(
                "Failed to register download {}: already registered or limit reached",
                download_id
            );
            return Err("Download already in progress or limit reached".to_string());
        }

        info!(
            "Download {} registered. Active downloads: {}/{}",
            download_id,
            manager.active_count(),
            manager.max_concurrent()
        );
    }

    // Get download info from DB
    let db_service = {
        let database = db.0.lock().map_err(|e| {
            error!("Failed to lock database: {}", e);
            e.to_string()
        })?;
        Arc::new(database.service())
    };

    let info_query = Query::with_params(
        "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?",
        vec![QueryParam::Int64(download_id)],
    );

    let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
        .query_one(info_query, |row| {
            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
        })
        .await
        .map_err(|e| {
            error!("Failed to query download info: {}", e);
            e.to_string()
        })?;

    debug!(
        "   Retrieved: item_id={}, file_path={}, file_size={:?}",
        item_id, file_path, file_size
    );

    // Make a HEAD request to get the file size from Content-Length header
    debug!("Making HEAD request to get file size...");
    let head_response = reqwest::Client::new().head(&stream_url).send().await;

    let file_size_from_server = match head_response {
        Ok(response) => {
            let size = response
                .headers()
                .get(reqwest::header::CONTENT_LENGTH)
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<i64>().ok());

            if let Some(size) = size {
                debug!(
                    "   Got file size from server: {} bytes ({} MB)",
                    size,
                    size / 1024 / 1024
                );
            } else {
                warn!("   Server didn't provide Content-Length header");
            }
            size
        }
        Err(e) => {
            warn!("   HEAD request failed: {}, continuing anyway...", e);
            None
        }
    };

    // Update status to downloading and save file_size if we got it.
    // Also persist the resolved stream URL + target dir so the queue pump can
    // restart/resume this download by itself if needed.
    let update_query = if let Some(size) = file_size_from_server {
        Query::with_params(
            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
            vec![
                QueryParam::Int64(size),
                QueryParam::String(stream_url.clone()),
                QueryParam::String(target_dir.clone()),
                QueryParam::Int64(download_id),
            ],
        )
    } else {
        Query::with_params(
            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
            vec![
                QueryParam::String(stream_url.clone()),
                QueryParam::String(target_dir.clone()),
                QueryParam::Int64(download_id),
            ],
        )
    };

    db_service
        .execute(update_query)
        .await
        .map_err(|e| e.to_string())?;

    // Emit started event
    let started_event = DownloadEvent::Started {
        download_id,
        item_id: item_id.clone(),
    };
    debug!("Emitting download-event: {:?}", started_event);
    debug!(
        "   Serialized: {}",
        serde_json::to_string(&started_event).unwrap_or_default()
    );
    match app.emit("download-event", started_event) {
        Ok(_) => debug!("   Event emitted successfully"),
        Err(e) => error!("   Event emit failed: {:?}", e),
    }

    // Build target path
    let target_path = PathBuf::from(&target_dir).join(&file_path);

    // Get a clone of the active downloads Arc for unregistering later
    let active_downloads = {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.get_active_downloads()
    };

    // Run the worker in the background; on completion/failure it frees the slot
    // and pumps the next pending download.
    spawn_download_worker(
        app.clone(),
        download_id,
        item_id,
        stream_url,
        target_path,
        active_downloads,
    );

    Ok(())
}

/// Enqueue a download with its resolved stream URL, then let the queue pump
/// start it (or a higher-priority pending item) when a slot is free.
///
/// Unlike [`start_download`], this never errors when the concurrency limit is
/// reached: the URL is persisted on the row and the pump will pick it up once a
/// slot frees. This is the path bulk operations (album/series/season) use so
/// every queued item eventually downloads without the frontend re-issuing it.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_download(
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    app: tauri::AppHandle,
    download_id: i64,
    stream_url: String,
    target_dir: String,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Persist the resolved URL/dir and mark the row pending so the pump can
    // start it. We don't flip to 'downloading' here — the pump owns that.
    let update_query = Query::with_params(
        "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
        vec![
            QueryParam::String(stream_url),
            QueryParam::String(target_dir),
            QueryParam::Int64(download_id),
        ],
    );
    db_service
        .execute(update_query)
        .await
        .map_err(|e| e.to_string())?;

    // Kick the pump: it will start as many pending downloads as there are slots.
    let active_downloads = {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.get_active_downloads()
    };
    pump_download_queue(app, db_service, active_downloads).await;

    Ok(())
}

/// Enqueue a batch of already-queued video downloads, resolving each one's
/// transcode URL from the repository using the `quality_preset` stored on the
/// row. Then let the pump start them subject to the concurrency limit.
///
/// This is the bulk video path (series/season): `download_series`/
/// `download_season` insert the rows, then this resolves URLs and enqueues them
/// so they actually start. Resolving server-side avoids round-tripping every
/// episode URL through the frontend.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_video_downloads(
    db: State<'_, DatabaseWrapper>,
    download_manager: State<'_, DownloadManagerWrapper>,
    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
    app: tauri::AppHandle,
    handle: String,
    download_ids: Vec<i64>,
    target_dir: String,
) -> Result<(), String> {
    let repo = repository.0.get(&handle).ok_or("Repository not found")?;

    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    for download_id in download_ids {
        // Read the item + quality preset for this queued download.
        let info_query = Query::with_params(
            "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
            vec![QueryParam::Int64(download_id)],
        );
        let (item_id, quality): (String, String) = match db_service
            .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
            .await
        {
            Ok(row) => row,
            Err(e) => {
                warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
                continue;
            }
        };

        // Build the download URL, resolving the source's audio codec first so a
        // track this device cannot decode is re-encoded on the way down rather
        // than saved as a silent file (DR-167).
        let stream_url =
            crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
                .await;

        let update_query = Query::with_params(
            "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
            vec![
                QueryParam::String(stream_url),
                QueryParam::String(target_dir.clone()),
                QueryParam::Int64(download_id),
            ],
        );
        if let Err(e) = db_service.execute(update_query).await {
            warn!(
                "[enqueue_video] Failed to persist URL for download {}: {}",
                download_id, e
            );
        }
    }

    // Pump once: starts up to max_concurrent, the rest drain as slots free.
    let active_downloads = {
        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
        manager.get_active_downloads()
    };
    pump_download_queue(app, db_service, active_downloads).await;

    Ok(())
}

/// Whether the current network permits downloads, given the user's WiFi-only
/// preference.
///
/// Reads `wifi_only` from the SmartCache config (the single home of the
/// setting) and checks it against the transport reported by the platform. On
/// desktop the transport defaults to unmetered ethernet, so this is always
/// true there.
///
/// TRACES: UR-053 | DR-074
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
    let wifi_only = {
        let smart_cache = app.state::<SmartCacheWrapper>();
        let cache = match smart_cache.0.lock() {
            Ok(c) => c,
            Err(e) => {
                error!("[pump] Failed to lock smart cache: {}", e);
                // Fail open: a lock problem must not silently wedge downloads.
                return true;
            }
        };
        cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
    };

    if !wifi_only {
        return true;
    }

    let network = app.state::<NetworkStateWrapper>();
    network.0.allows_download(true).await
}

/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
/// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement.
pub(crate) async fn pump_download_queue(
    app: tauri::AppHandle,
    db_service: Arc<crate::storage::db_service::RusqliteService>,
    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
    use crate::download::events::DownloadEvent;
    use tauri::Emitter;

    // WiFi-only gate (UR-053): when the user has restricted downloads to
    // unmetered networks and we're on cellular (or can't tell), leave every
    // pending row exactly as it is. They stay 'pending' and the Android
    // network callback re-pumps us as soon as an acceptable network appears.
    if !downloads_allowed_on_current_network(&app).await {
        info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
        let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
        return;
    }

    let max_concurrent = {
        let manager = app.state::<DownloadManagerWrapper>();
        let manager = match manager.0.lock() {
            Ok(m) => m,
            Err(e) => {
                error!("[pump] Failed to lock download manager: {}", e);
                return;
            }
        };
        manager.max_concurrent()
    };

    loop {
        // How many slots are free right now?
        let free_slots = {
            let active = match active_downloads.lock() {
                Ok(a) => a,
                Err(e) => {
                    error!("[pump] Failed to lock active downloads: {}", e);
                    return;
                }
            };
            max_concurrent.saturating_sub(active.len())
        };
        if free_slots == 0 {
            return;
        }

        // Find the next pending, startable download (has a stream URL). Exclude
        // anything already registered as active to avoid double-starting.
        let next_query = Query::with_params(
            "SELECT id, item_id, file_path, stream_url, target_dir
             FROM downloads
             WHERE status = 'pending'
               AND stream_url IS NOT NULL
               AND target_dir IS NOT NULL
             ORDER BY priority DESC, queued_at ASC",
            vec![],
        );

        let candidates: Vec<(i64, String, String, String, String)> = match db_service
            .query_many(next_query, |row| {
                Ok((
                    row.get(0)?,
                    row.get(1)?,
                    row.get(2)?,
                    row.get(3)?,
                    row.get(4)?,
                ))
            })
            .await
        {
            Ok(rows) => rows,
            Err(e) => {
                error!("[pump] Failed to query pending downloads: {}", e);
                return;
            }
        };

        // Pick the first candidate not already active.
        let next = candidates.into_iter().find(|(id, _, _, _, _)| {
            active_downloads
                .lock()
                .map(|active| !active.contains(id))
                .unwrap_or(false)
        });

        let (download_id, item_id, file_path, stream_url, target_dir) = match next {
            Some(n) => n,
            None => return, // Nothing pending to start
        };

        // Register the slot. If registration fails (race: another pump filled
        // the last slot), stop — we'll be re-pumped when a slot frees.
        {
            let manager = app.state::<DownloadManagerWrapper>();
            let manager = match manager.0.lock() {
                Ok(m) => m,
                Err(e) => {
                    error!("[pump] Failed to lock download manager: {}", e);
                    return;
                }
            };
            if !manager.register_download(download_id) {
                return;
            }
            info!(
                "[pump] Download {} started. Active downloads: {}/{}",
                download_id,
                manager.active_count(),
                manager.max_concurrent()
            );
        }

        // Mark as downloading and stamp started_at.
        let update_query = Query::with_params(
            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
            vec![QueryParam::Int64(download_id)],
        );
        if let Err(e) = db_service.execute(update_query).await {
            error!(
                "[pump] Failed to mark download {} downloading: {}",
                download_id, e
            );
            if let Ok(mut a) = active_downloads.lock() {
                a.remove(&download_id);
            }
            continue;
        }

        // Emit started event so the UI flips the row.
        let _ = app.emit(
            "download-event",
            DownloadEvent::Started {
                download_id,
                item_id: item_id.clone(),
            },
        );

        let target_path = PathBuf::from(&target_dir).join(&file_path);
        spawn_download_worker(
            app.clone(),
            download_id,
            item_id,
            stream_url,
            target_path,
            active_downloads.clone(),
        );
    }
}

/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
fn spawn_download_worker(
    app: tauri::AppHandle,
    download_id: i64,
    item_id: String,
    stream_url: String,
    target_path: std::path::PathBuf,
    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
    use crate::download::events::DownloadEvent;
    use crate::download::{DownloadTask, DownloadWorker};
    use tauri::Emitter;

    let task = DownloadTask {
        url: stream_url,
        target_path: target_path.clone(),
    };

    tauri::async_runtime::spawn(async move {
        debug!("Download task started for download_id: {}", download_id);
        let worker = DownloadWorker::new();

        // Progress callback that emits events to the frontend
        let progress_app = app.clone();
        let progress_item_id = item_id.clone();
        let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
            let progress = total_bytes
                .filter(|&t| t > 0)
                .map(|t| bytes_downloaded as f64 / t as f64)
                .unwrap_or(0.0);

            let event = DownloadEvent::Progress {
                download_id,
                item_id: progress_item_id.clone(),
                bytes_downloaded: bytes_downloaded as i64,
                total_bytes: total_bytes.map(|t| t as i64),
                progress,
            };
            let _ = progress_app.emit("download-event", event);
        };

        // Registering returns a fresh flag, so a download resumed after a pause
        // does not inherit the stop that ended its previous run. (DR-168)
        let stop_flag = crate::download::stop::register(download_id);
        let result = worker.download(&task, &stop_flag, on_progress).await;
        crate::download::stop::clear(download_id);

        // Free the slot before pumping so the next download can take it.
        if let Ok(mut active) = active_downloads.lock() {
            active.remove(&download_id);
            debug!(
                "   Unregistered download {}. Active downloads: {}",
                download_id,
                active.len()
            );
        }

        // The pump runs downloads in the background, so the terminal status MUST
        // be persisted to the DB here — the frontend event handler only writes it
        // when that download happens to be loaded in its store, which is not the
        // case for auto-pumped rows (or any completion while the downloads page is
        // closed). `check_for_local_download` filters on status = 'completed', so a
        // missed write leaves finished files unrecognized: albums never show as
        // downloaded and playback never switches from the (expiring) stream to the
        // local file, cutting tracks off mid-play.
        let db_service = {
            let db = app.state::<DatabaseWrapper>();
            let database = match db.0.lock() {
                Ok(d) => d,
                Err(e) => {
                    error!(
                        "[pump] Failed to lock database after download {}: {}",
                        download_id, e
                    );
                    return;
                }
            };
            Arc::new(database.service())
        };

        match result {
            Ok(res) => {
                info!(
                    "Download completed successfully: {} bytes",
                    res.bytes_downloaded
                );
                let file_path = target_path.to_string_lossy().to_string();

                let update = Query::with_params(
                    "UPDATE downloads SET status = 'completed', progress = 1.0, \
                     bytes_downloaded = ?, file_size = ?, file_path = ?, \
                     completed_at = CURRENT_TIMESTAMP WHERE id = ?",
                    vec![
                        QueryParam::Int64(res.bytes_downloaded as i64),
                        QueryParam::Int64(res.bytes_downloaded as i64),
                        QueryParam::String(file_path.clone()),
                        QueryParam::Int64(download_id),
                    ],
                );
                if let Err(e) = db_service.execute(update).await {
                    error!(
                        "[pump] Failed to persist completed status for download {}: {}",
                        download_id, e
                    );
                }

                let completed_event = DownloadEvent::Completed {
                    download_id,
                    item_id,
                    file_path,
                };
                match app.emit("download-event", completed_event) {
                    Ok(_) => debug!("   Completed event emitted successfully"),
                    Err(e) => error!("   Completed event emit failed: {:?}", e),
                }
            }
            // A pause or cancel is not a failure. The row already says `paused`
            // (or the row is gone, for a cancel), and overwriting that with
            // `failed` is what made a pause look like an error and stranded the
            // download outside the resumable set. The `.part` file is deliberately
            // left alone — it is what the resume continues from. (DR-168)
            Err(e) if e.is_stopped() => {
                info!(
                    "[pump] Download {} stopped by request; partial file kept for resume",
                    download_id
                );
            }
            Err(e) => {
                error!("Download failed: {:?}", e);

                let update = Query::with_params(
                    "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
                    vec![
                        QueryParam::String(e.to_string()),
                        QueryParam::Int64(download_id),
                    ],
                );
                if let Err(db_err) = db_service.execute(update).await {
                    error!(
                        "[pump] Failed to persist failed status for download {}: {}",
                        download_id, db_err
                    );
                }

                let failed_event = DownloadEvent::Failed {
                    download_id,
                    item_id,
                    error: e.to_string(),
                };
                match app.emit("download-event", failed_event) {
                    Ok(_) => debug!("   Failed event emitted successfully"),
                    Err(e) => error!("   Failed event emit failed: {:?}", e),
                }
            }
        }

        // A slot just freed — start the next pending download (if any).
        pump_download_queue(app.clone(), db_service, active_downloads).await;
    });
}

/// Delete a completed download
#[tauri::command]
#[specta::specta]
pub async fn delete_download(
    db: State<'_, DatabaseWrapper>,
    download_id: i64,
) -> Result<(), String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Get file path
    let file_query = Query::with_params(
        "SELECT file_path FROM downloads WHERE id = ?",
        vec![QueryParam::Int64(download_id)],
    );

    let file_path: Option<String> = db_service
        .query_optional(file_query, |row| row.get(0))
        .await
        .ok()
        .flatten();

    // Delete from database
    let delete_query = Query::with_params(
        "DELETE FROM downloads WHERE id = ?",
        vec![QueryParam::Int64(download_id)],
    );

    db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    // Delete actual file if exists
    if let Some(path) = file_path {
        let _ = std::fs::remove_file(&path); // Ignore errors
    }

    Ok(())
}

/// Helper function to map database row to DownloadInfo
fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
    Ok(DownloadInfo {
        id: row.get(0)?,
        item_id: row.get(1)?,
        user_id: row.get(2)?,
        file_path: row.get(3)?,
        file_size: row.get(4)?,
        mime_type: row.get(5)?,
        status: row.get(6)?,
        progress: row.get(7)?,
        bytes_downloaded: row.get(8)?,
        queued_at: row.get(9)?,
        started_at: row.get(10)?,
        completed_at: row.get(11)?,
        error_message: row.get(12)?,
        retry_count: row.get(13)?,
        priority: row.get(14)?,
        item_name: row.get(15)?,
        artist_name: row.get(16)?,
        album_name: row.get(17)?,
        // Video-specific metadata
        series_name: row.get(18)?,
        season_name: row.get(19)?,
        episode_number: row.get(20)?,
        season_number: row.get(21)?,
        quality_preset: row.get(22)?,
        media_type: row
            .get::<_, Option<String>>(23)?
            .unwrap_or_else(|| "audio".to_string()),
        download_source: row
            .get::<_, Option<String>>(24)?
            .unwrap_or_else(|| "user".to_string()),
    })
}

/// Storage statistics for downloads
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct StorageStats {
    pub total_bytes: i64,
    pub total_items: i64,
    pub albums: Vec<AlbumStorageInfo>,
}

/// Storage info for a single album
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct AlbumStorageInfo {
    pub album_id: String,
    pub album_name: String,
    pub artist_name: Option<String>,
    pub bytes_used: i64,
    pub track_count: i64,
}

/// Get storage statistics for downloads
#[tauri::command]
#[specta::specta]
pub async fn get_download_storage_stats(
    db: State<'_, DatabaseWrapper>,
    user_id: String,
) -> Result<StorageStats, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Get total storage and item count
    let total_query = Query::with_params(
        "SELECT COALESCE(SUM(file_size), 0), COUNT(*)
         FROM downloads
         WHERE user_id = ? AND status = 'completed'",
        vec![QueryParam::String(user_id.clone())],
    );

    let (total_bytes, total_items): (i64, i64) = db_service
        .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?)))
        .await
        .map_err(|e| e.to_string())?;

    // Get storage breakdown by album
    let albums_query = Query::with_params(
        "SELECT
            COALESCE(i.album_id, 'unknown') as album_id,
            COALESCE(i.album_name, 'Unknown Album') as album_name,
            i.artists as artist_name,
            COALESCE(SUM(d.file_size), 0) as bytes_used,
            COUNT(*) as track_count
         FROM downloads d
         LEFT JOIN items i ON d.item_id = i.id
         WHERE d.user_id = ? AND d.status = 'completed'
         GROUP BY COALESCE(i.album_id, 'unknown')
         ORDER BY bytes_used DESC",
        vec![QueryParam::String(user_id)],
    );

    let albums: Vec<AlbumStorageInfo> = db_service
        .query_many(albums_query, |row| {
            Ok(AlbumStorageInfo {
                album_id: row.get(0)?,
                album_name: row.get(1)?,
                artist_name: row.get(2)?,
                bytes_used: row.get(3)?,
                track_count: row.get(4)?,
            })
        })
        .await
        .map_err(|e| e.to_string())?;

    Ok(StorageStats {
        total_bytes,
        total_items,
        albums,
    })
}

/// Delete all downloads for a user
#[tauri::command]
#[specta::specta]
pub async fn delete_all_downloads(
    db: State<'_, DatabaseWrapper>,
    user_id: String,
) -> Result<i64, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Get all file paths for completed downloads
    let file_query = Query::with_params(
        "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'",
        vec![QueryParam::String(user_id.clone())],
    );

    let file_paths: Vec<String> = db_service
        .query_many(file_query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    // Delete all downloads for user
    let delete_query = Query::with_params(
        "DELETE FROM downloads WHERE user_id = ?",
        vec![QueryParam::String(user_id)],
    );

    let deleted_count = db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    // Delete actual files
    for path in file_paths {
        let _ = std::fs::remove_file(&path); // Ignore errors
        let _ = std::fs::remove_file(format!("{}.part", path)); // Also clean up partials
    }

    Ok(deleted_count as i64)
}

/// Clear all stale pending/failed/paused downloads
#[tauri::command]
#[specta::specta]
pub async fn clear_stale_downloads(
    db: State<'_, DatabaseWrapper>,
    user_id: String,
) -> Result<i64, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Ids as well as paths: a stale row may still have a worker attached (a
    // 'downloading' row that was paused mid-flight is 'paused' here), and
    // deleting the row without stopping the task leaves it writing to a file we
    // are about to remove. (DR-168)
    let file_query = Query::with_params(
        "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
        vec![QueryParam::String(user_id.clone())],
    );

    let stale: Vec<(i64, String)> = db_service
        .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
        .await
        .map_err(|e| e.to_string())?;

    for (id, _) in &stale {
        crate::download::stop::signal(*id);
        crate::download::stop::clear(*id);
    }
    let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();

    // Delete all pending, paused, and failed downloads (but keep completed ones)
    let delete_query = Query::with_params(
        "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
        vec![QueryParam::String(user_id)],
    );

    let deleted_count = db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    // Delete any partial files, via the shared helper so this cannot drift from
    // what the worker actually writes. (DR-169)
    for path in file_paths {
        let target = std::path::PathBuf::from(&path);
        let _ = std::fs::remove_file(&target);
        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
    }

    Ok(deleted_count as i64)
}

/// Delete all downloads for a specific album
#[tauri::command]
#[specta::specta]
pub async fn delete_album_downloads(
    db: State<'_, DatabaseWrapper>,
    album_id: String,
    user_id: String,
) -> Result<i64, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // Get file paths for this album's downloads
    let file_query = Query::with_params(
        "SELECT d.file_path FROM downloads d
         JOIN items i ON d.item_id = i.id
         WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
        vec![
            QueryParam::String(user_id.clone()),
            QueryParam::String(album_id.clone()),
        ],
    );

    let file_paths: Vec<String> = db_service
        .query_many(file_query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    // Delete downloads for this album
    let delete_query = Query::with_params(
        "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)",
        vec![QueryParam::String(user_id), QueryParam::String(album_id)],
    );

    let deleted_count = db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    // Delete actual files
    for path in file_paths {
        let _ = std::fs::remove_file(&path);
        let _ = std::fs::remove_file(format!("{}.part", path));
    }

    Ok(deleted_count as i64)
}

/// Remove every completed download at or under a container item.
///
/// Works at any level of the Downloaded browse: a leaf (removes just that
/// download), an album/season/series (removes all downloaded descendants linked
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
/// on-disk files. Returns the number of downloads removed. Idempotent.
///
/// TRACES: UR-055 | DR-083
#[tauri::command]
#[specta::specta]
pub async fn delete_downloads_under(
    db: State<'_, DatabaseWrapper>,
    item_id: String,
    user_id: String,
) -> Result<i64, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    // The item itself, or any child linked to it by container id.
    const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
         AND (
             d.item_id = ?
             OR d.item_id IN (
                 SELECT c.id FROM items c
                 WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
             )
         )";

    let file_query = Query::with_params(
        format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
        vec![
            QueryParam::String(user_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
        ],
    );
    let file_paths: Vec<String> = db_service
        .query_many(file_query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    let delete_query = Query::with_params(
        format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
        vec![
            QueryParam::String(user_id),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id.clone()),
            QueryParam::String(item_id),
        ],
    );
    let deleted_count = db_service
        .execute(delete_query)
        .await
        .map_err(|e| e.to_string())?;

    for path in file_paths {
        let _ = std::fs::remove_file(&path);
        let _ = std::fs::remove_file(format!("{}.part", path));
    }

    Ok(deleted_count as i64)
}

/// Download manager statistics
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct DownloadManagerStats {
    pub max_concurrent: usize,
    pub active_count: usize,
    pub available_slots: usize,
}

/// Get download manager statistics
#[tauri::command]
#[specta::specta]
pub async fn get_download_manager_stats(
    download_manager: State<'_, DownloadManagerWrapper>,
) -> Result<DownloadManagerStats, String> {
    let manager = download_manager.0.lock().map_err(|e| e.to_string())?;

    let active_count = manager.active_count();
    let max_concurrent = manager.max_concurrent();

    Ok(DownloadManagerStats {
        max_concurrent,
        active_count,
        available_slots: max_concurrent.saturating_sub(active_count),
    })
}

/// Set the maximum concurrent downloads
#[tauri::command]
#[specta::specta]
pub async fn set_max_concurrent_downloads(
    download_manager: State<'_, DownloadManagerWrapper>,
    max: usize,
) -> Result<(), String> {
    let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    manager.set_max_concurrent(max);
    info!("Set max concurrent downloads to: {}", max);
    Ok(())
}

// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::Database;
    use rusqlite::params;

    #[test]
    fn test_sanitize_filename() {
        assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3");
        assert_eq!(
            sanitize_filename("track/with\\invalid:chars"),
            "track_with_invalid_chars"
        );
        assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3");
        assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt");
    }

    #[test]
    fn test_sanitize_filename_preserves_extension() {
        assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3");
        assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
    }

    /// Helper to set up test database with required foreign key data
    fn setup_test_db() -> Database {
        let db = Database::open_in_memory().unwrap();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // Create server
        conn.execute(
            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
            params!["server1", "Test Server", "http://localhost:8096"],
        )
        .unwrap();

        // Create user
        conn.execute(
            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
            params!["user1", "server1", "testuser"],
        )
        .unwrap();

        // Create some items for download testing
        conn.execute(
            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params!["item1", "server1", "Test Song 1", "Audio", "album1", 1],
        )
        .unwrap();

        conn.execute(
            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params!["item2", "server1", "Test Song 2", "Audio", "album1", 2],
        )
        .unwrap();

        conn.execute(
            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params!["item3", "server1", "Test Song 3", "Audio", "album1", 3],
        )
        .unwrap();

        drop(conn);
        db
    }

    #[test]
    fn test_download_item_returns_correct_id_on_insert() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // Insert a new download
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at)
             VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)",
            params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0],
        )
        .unwrap();

        // Query for the download ID (like our fixed code does)
        let download_id: i64 = conn
            .query_row(
                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                params!["item1", "user1"],
                |row| row.get(0),
            )
            .unwrap();

        assert!(download_id > 0, "Download ID should be positive");

        // Verify the download record exists with correct data
        let (status, file_path): (String, String) = conn
            .query_row(
                "SELECT status, file_path FROM downloads WHERE id = ?1",
                params![download_id],
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .unwrap();

        assert_eq!(status, "pending");
        assert_eq!(file_path, "/path/to/file.mp3");
    }

    #[test]
    fn test_download_item_upsert_returns_correct_id() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // First insert
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
            params!["item1", "user1", "/path/to/file.mp3"],
        )
        .unwrap();

        let first_id: i64 = conn
            .query_row(
                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                params!["item1", "user1"],
                |row| row.get(0),
            )
            .unwrap();

        // Simulate failure - mark as failed
        conn.execute(
            "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1",
            params![first_id],
        )
        .unwrap();

        // Now re-download (UPSERT) - this is the scenario that was broken
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
             ON CONFLICT(item_id, user_id) DO UPDATE SET
               priority = excluded.priority,
               status = 'pending',
               queued_at = CURRENT_TIMESTAMP",
            params!["item1", "user1", "/path/to/file.mp3"],
        )
        .unwrap();

        // The old buggy code used last_insert_rowid() which would return 0 or wrong value
        // Our fixed code queries by unique constraint
        let second_id: i64 = conn
            .query_row(
                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                params!["item1", "user1"],
                |row| row.get(0),
            )
            .unwrap();

        // The ID should be the same as the first insert (UPSERT updated the existing row)
        assert_eq!(first_id, second_id, "UPSERT should return the same row ID");

        // Verify the status was reset to pending
        let status: String = conn
            .query_row(
                "SELECT status FROM downloads WHERE id = ?1",
                params![second_id],
                |row| row.get(0),
            )
            .unwrap();

        assert_eq!(
            status, "pending",
            "Status should be reset to pending after UPSERT"
        );
    }

    #[test]
    fn test_query_by_unique_constraint_is_reliable() {
        // This test demonstrates that querying by unique constraint is always reliable,
        // unlike last_insert_rowid() which has undefined behavior with UPSERT.
        //
        // SQLite documentation states that last_insert_rowid() behavior is undefined
        // when ON CONFLICT triggers an UPDATE instead of INSERT. Some versions return 0,
        // others return the existing row ID - it's not consistent.
        //
        // Our fix: always query by the unique constraint columns (item_id, user_id)
        // to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // First insert
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
            params!["item1", "user1", "/path/to/file.mp3"],
        )
        .unwrap();

        let first_id: i64 = conn
            .query_row(
                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                params!["item1", "user1"],
                |row| row.get(0),
            )
            .unwrap();

        // UPSERT (triggers UPDATE)
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
             ON CONFLICT(item_id, user_id) DO UPDATE SET
               status = 'pending'",
            params!["item1", "user1", "/path/to/file.mp3"],
        )
        .unwrap();

        // Our approach: query by unique constraint - always works!
        let id_after_upsert: i64 = conn
            .query_row(
                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                params!["item1", "user1"],
                |row| row.get(0),
            )
            .unwrap();

        // This should ALWAYS be the same ID - our approach is reliable
        assert_eq!(
            first_id, id_after_upsert,
            "Query by unique constraint should always return correct ID"
        );
    }

    #[test]
    fn test_download_album_returns_correct_ids() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // Insert downloads for multiple items (simulating album download)
        let track_ids = vec!["item1", "item2", "item3"];
        let mut download_ids = Vec::new();

        for track_id in &track_ids {
            conn.execute(
                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
                 ON CONFLICT(item_id, user_id) DO UPDATE SET
                   priority = 100,
                   status = 'pending'",
                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
            )
            .unwrap();

            // Use our fixed approach - query by unique constraint
            let download_id: i64 = conn
                .query_row(
                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                    params![track_id, "user1"],
                    |row| row.get(0),
                )
                .unwrap();

            download_ids.push(download_id);
        }

        // All IDs should be unique and positive
        assert_eq!(download_ids.len(), 3);
        for id in &download_ids {
            assert!(*id > 0, "Download ID should be positive");
        }

        // IDs should be unique
        let mut sorted_ids = download_ids.clone();
        sorted_ids.sort();
        sorted_ids.dedup();
        assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique");
    }

    #[test]
    fn test_download_album_upsert_returns_correct_ids() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // First download attempt - insert all
        let track_ids = vec!["item1", "item2", "item3"];
        let mut first_ids = Vec::new();

        for track_id in &track_ids {
            conn.execute(
                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)",
                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
            )
            .unwrap();

            let id: i64 = conn
                .query_row(
                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                    params![track_id, "user1"],
                    |row| row.get(0),
                )
                .unwrap();
            first_ids.push(id);
        }

        // Mark all as failed
        conn.execute("UPDATE downloads SET status = 'failed'", [])
            .unwrap();

        // Re-download (UPSERT all)
        let mut second_ids = Vec::new();

        for track_id in &track_ids {
            conn.execute(
                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
                 ON CONFLICT(item_id, user_id) DO UPDATE SET
                   priority = 100,
                   status = 'pending'",
                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
            )
            .unwrap();

            let id: i64 = conn
                .query_row(
                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
                    params![track_id, "user1"],
                    |row| row.get(0),
                )
                .unwrap();
            second_ids.push(id);
        }

        // IDs should be the same (UPSERT updates existing rows)
        assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs");
    }

    #[test]
    fn test_get_downloads_returns_correct_metadata() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // Insert a download
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority)
             VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)",
            params!["item1", "user1", "/path/to/song.mp3"],
        )
        .unwrap();

        // Query downloads with item metadata
        let download: DownloadInfo = conn
            .query_row(
                "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
                        d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
                        d.retry_count, d.priority,
                        COALESCE(d.item_name, i.name) as item_name,
                        COALESCE(d.artist_name, i.artists) as artist_name,
                        COALESCE(d.album_name, i.album_name) as album_name,
                        COALESCE(d.series_name, i.series_name) as series_name,
                        COALESCE(d.season_name, i.season_name) as season_name,
                        COALESCE(d.episode_number, i.index_number) as episode_number,
                        COALESCE(d.season_number, i.parent_index_number) as season_number,
                        d.quality_preset,
                        COALESCE(d.media_type, 'audio') as media_type,
                        COALESCE(d.download_source, 'user') as download_source
                 FROM downloads d
                 LEFT JOIN items i ON d.item_id = i.id
                 WHERE d.user_id = ?1",
                params!["user1"],
                map_download_row,
            )
            .unwrap();

        assert_eq!(download.item_id, "item1");
        assert_eq!(download.status, "downloading");
        assert!((download.progress - 0.5).abs() < 0.001);
        assert_eq!(download.priority, 10);
        assert_eq!(download.item_name, Some("Test Song 1".to_string()));
    }

    #[test]
    fn test_download_status_transitions() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        // Insert pending download
        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status)
             VALUES (?1, ?2, ?3, 'pending')",
            params!["item1", "user1", "/path/to/song.mp3"],
        )
        .unwrap();

        let id: i64 = conn.last_insert_rowid();

        // Transition: pending -> downloading
        conn.execute(
            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1",
            params![id],
        )
        .unwrap();

        let status: String = conn
            .query_row(
                "SELECT status FROM downloads WHERE id = ?1",
                params![id],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(status, "downloading");

        // Transition: downloading -> completed
        conn.execute(
            "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1",
            params![id],
        )
        .unwrap();

        let (status, progress): (String, f64) = conn
            .query_row(
                "SELECT status, progress FROM downloads WHERE id = ?1",
                params![id],
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .unwrap();
        assert_eq!(status, "completed");
        assert!((progress - 1.0).abs() < 0.001);
    }

    #[test]
    fn test_download_progress_updates() {
        let db = setup_test_db();
        let conn = db.connection();
        let conn = conn.lock_safe();

        conn.execute(
            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
             VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)",
            params!["item1", "user1", "/path/to/song.mp3"],
        )
        .unwrap();

        let id: i64 = conn.last_insert_rowid();

        // Simulate progress updates
        for i in 1..=10 {
            let progress = i as f64 / 10.0;
            let bytes = i * 1000000;

            conn.execute(
                "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3",
                params![progress, bytes, id],
            )
            .unwrap();

            let (actual_progress, actual_bytes): (f64, i64) = conn
                .query_row(
                    "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1",
                    params![id],
                    |row| Ok((row.get(0)?, row.get(1)?)),
                )
                .unwrap();

            assert!((actual_progress - progress).abs() < 0.001);
            assert_eq!(actual_bytes, bytes);
        }
    }

    #[test]
    fn test_compute_download_stats_empty() {
        let downloads = vec![];
        let stats = compute_download_stats(&downloads);
        assert_eq!(stats.total, 0);
        assert_eq!(stats.active_count, 0);
        assert_eq!(stats.queued_count, 0);
        assert_eq!(stats.completed_count, 0);
        assert_eq!(stats.failed_count, 0);
        assert_eq!(stats.paused_count, 0);
    }

    #[test]
    fn test_compute_download_stats_mixed() {
        let downloads = vec![
            create_test_download(1, "downloading"),
            create_test_download(2, "pending"),
            create_test_download(3, "downloading"),
            create_test_download(4, "completed"),
            create_test_download(5, "failed"),
            create_test_download(6, "paused"),
        ];
        let stats = compute_download_stats(&downloads);
        assert_eq!(stats.total, 6);
        assert_eq!(stats.active_count, 2);
        assert_eq!(stats.queued_count, 1);
        assert_eq!(stats.completed_count, 1);
        assert_eq!(stats.failed_count, 1);
        assert_eq!(stats.paused_count, 1);
    }

    #[test]
    fn test_compute_download_stats_all_same_status() {
        let downloads = vec![
            create_test_download(1, "completed"),
            create_test_download(2, "completed"),
            create_test_download(3, "completed"),
        ];
        let stats = compute_download_stats(&downloads);
        assert_eq!(stats.total, 3);
        assert_eq!(stats.active_count, 0);
        assert_eq!(stats.queued_count, 0);
        assert_eq!(stats.completed_count, 3);
        assert_eq!(stats.failed_count, 0);
        assert_eq!(stats.paused_count, 0);
    }

    /// Helper to create a test DownloadInfo for stats testing
    fn create_test_download(id: i64, status: &str) -> DownloadInfo {
        DownloadInfo {
            id,
            item_id: format!("item{}", id),
            user_id: "test_user".to_string(),
            file_path: format!("/tmp/download{}", id),
            file_size: Some(1000),
            mime_type: Some("audio/flac".to_string()),
            status: status.to_string(),
            progress: 0.0,
            bytes_downloaded: 0,
            queued_at: "2024-01-01T00:00:00Z".to_string(),
            started_at: None,
            completed_at: None,
            error_message: None,
            retry_count: 0,
            priority: 0,
            item_name: Some(format!("Track {}", id)),
            artist_name: None,
            album_name: None,
            series_name: None,
            season_name: None,
            episode_number: None,
            season_number: None,
            quality_preset: None,
            media_type: "audio".to_string(),
            download_source: "user".to_string(),
        }
    }

    // ===== Album download: track sourcing and album linkage =====

    /// A database with just the tables the album-download path touches.
    fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
        let conn = rusqlite::Connection::open_in_memory().unwrap();
        conn.execute_batch(
            r#"
            CREATE TABLE items (
                id TEXT PRIMARY KEY,
                server_id TEXT NOT NULL,
                parent_id TEXT,
                name TEXT NOT NULL,
                item_type TEXT NOT NULL,
                album_id TEXT,
                album_name TEXT,
                album_artist TEXT,
                artists TEXT,
                index_number INTEGER
            );
            CREATE TABLE downloads (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                item_id TEXT NOT NULL,
                user_id TEXT NOT NULL,
                file_path TEXT NOT NULL,
                status TEXT DEFAULT 'pending',
                priority INTEGER DEFAULT 0,
                progress REAL DEFAULT 0,
                queued_at TEXT,
                item_name TEXT,
                artist_name TEXT,
                album_name TEXT,
                media_type TEXT,
                stream_url TEXT,
                target_dir TEXT,
                UNIQUE(item_id, user_id)
            );
            INSERT INTO items (id, server_id, name, item_type)
                VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
            "#,
        )
        .unwrap();
        Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
            Mutex::new(conn),
        )))
    }

    fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
        AlbumTrack {
            id: id.to_string(),
            name: name.to_string(),
            artist_name: Some("Woodkid".to_string()),
            album_name: Some("The Golden Age".to_string()),
            index_number: Some(index),
        }
    }

    /// The album-download regression: every track the album actually has must be
    /// queued, and each queued track must be linked to its album.
    ///
    /// `download_album` used to take its track list from
    /// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
    /// listing endpoint, so tracks cached from those endpoints sit in `items`
    /// with a NULL `album_id` — invisible to that query. "Download album" then
    /// silently queued only the subset that happened to carry the link, which is
    /// the reported "only 4-5 songs downloaded". The same column is what offline
    /// browsing joins tracks to their album on (`i.album_id = ?` in
    /// `OfflineRepository::get_items`), so even a track that did download stayed
    /// invisible under its album offline.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    #[tokio::test]
    async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
        let db = album_test_db();

        // The cache holds all three tracks, but only one carries `album_id` —
        // exactly the state the bug report's database is in.
        for sql in [
            "INSERT INTO items (id, server_id, name, item_type, album_id) \
             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
            "INSERT INTO items (id, server_id, name, item_type, album_id) \
             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
            "INSERT INTO items (id, server_id, name, item_type, album_id) \
             VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
        ] {
            db.execute(Query::new(sql)).await.unwrap();
        }

        let tracks = vec![
            album_track("t1", "Run Boy Run", 1),
            album_track("t2", "The Great Escape", 2),
            album_track("t3", "Boat Song", 3),
        ];

        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
            .await
            .unwrap();

        assert_eq!(
            ids.len(),
            3,
            "every track of the album must get a download row"
        );

        let queued: i64 = db
            .query_one(
                Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(queued, 3);

        // Each track is now linked to its album, so the offline album page can
        // find it once the download completes.
        let linked: i64 = db
            .query_one(
                Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(
            linked, 3,
            "queued tracks must be linked to their album; offline browsing joins on album_id"
        );
    }

    /// The returned ids must line up with the tracks that were passed in. The
    /// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
    /// only sound if both lists agree — they did not, because the backend
    /// ordered by `index_number` over a different set of rows. Resolving URLs in
    /// Rust removes the pairing entirely, but the order is still the contract
    /// for anything that reads the ids back.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    #[tokio::test]
    async fn test_queue_album_tracks_returns_ids_in_track_order() {
        let db = album_test_db();
        let tracks = vec![
            album_track("t1", "Run Boy Run", 1),
            album_track("t2", "The Great Escape", 2),
        ];

        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
            .await
            .unwrap();

        for (id, track) in ids.iter().zip(tracks.iter()) {
            let item_id: String = db
                .query_one(
                    Query::with_params(
                        "SELECT item_id FROM downloads WHERE id = ?",
                        vec![QueryParam::Int64(*id)],
                    ),
                    |row| row.get(0),
                )
                .await
                .unwrap();
            assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
        }
    }

    /// Re-queueing an album already partly downloaded must not duplicate rows or
    /// reset a completed track — it fills in what is missing.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    #[tokio::test]
    async fn test_queue_album_tracks_is_idempotent() {
        let db = album_test_db();
        let tracks = vec![
            album_track("t1", "Run Boy Run", 1),
            album_track("t2", "The Great Escape", 2),
        ];

        let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
            .await
            .unwrap();
        let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
            .await
            .unwrap();

        assert_eq!(first, second, "the same tracks must map to the same rows");

        let rows: i64 = db
            .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
                row.get(0)
            })
            .await
            .unwrap();
        assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
    }

    /// Two tracks of one album can share a title — a deluxe edition carrying the
    /// album version and a demo of the same song, or the same song on two discs.
    /// Naming the file after the title alone gave them one path, so the second
    /// download overwrote the first and the album ended up short however many
    /// duplicates it had.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
    #[test]
    fn test_album_file_names_are_unique_within_the_album() {
        let tracks = vec![
            album_track("t1", "Crucified Again", 5),
            album_track("t2", "Crucified Again", 5),
            album_track("t3", "Get Right", 7),
        ];

        let names = album_file_names(&tracks);

        assert_eq!(names.len(), 3);
        let unique: std::collections::HashSet<_> = names.iter().collect();
        assert_eq!(
            unique.len(),
            3,
            "every track of an album needs its own file: {:?}",
            names
        );
        assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
        assert!(
            names[2].contains("Get Right"),
            "an unambiguous title keeps its name: {}",
            names[2]
        );
    }

    /// Path separators in a track title must not escape the album directory.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
    #[test]
    fn test_album_file_names_sanitize_the_title() {
        let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
        assert!(!names[0].contains('/'), "{}", names[0]);
        assert!(!names[0].contains(':'), "{}", names[0]);
    }

    /// The offline fallback reads the catalog directly, not through the
    /// availability-gated offline listing: queueing an album while the server is
    /// unreachable is a supported flow (the rows resolve on reconnect), and
    /// gating it on what is already downloaded would queue only the tracks the
    /// device already has.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    #[tokio::test]
    async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
        let db = album_test_db();
        for sql in [
            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
            // Linked by parent_id only — how a track cached from a folder
            // listing lands in the catalog.
            "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
            // A different album's track must not be swept in.
            "INSERT INTO items (id, server_id, name, item_type, album_id) \
             VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
        ] {
            db.execute(Query::new(sql)).await.unwrap();
        }

        let tracks = cached_album_tracks(&db, "album1").await.unwrap();
        let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
        assert_eq!(ids, vec!["t1", "t2"]);
    }

    /// Tracks the cache has never seen still get queued: the row is created and
    /// an `items` row is written for it, so the download is both startable and
    /// visible offline afterwards.
    ///
    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    #[tokio::test]
    async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
        let db = album_test_db();
        let tracks = vec![album_track("never-cached", "Iron", 1)];

        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
            .await
            .unwrap();
        assert_eq!(ids.len(), 1);

        let (item_type, album_id): (String, Option<String>) = db
            .query_one(
                Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .await
            .unwrap();
        assert_eq!(item_type, "Audio");
        assert_eq!(album_id.as_deref(), Some("album1"));
    }
}
