// Offline repository - queries SQLite database for cached data
//
// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use async_trait::async_trait;
use log::debug;

use super::{types::*, MediaRepository};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};

/// Whether offline library queries may include catalog items that are merely
/// *browsed/synced* but not downloaded (the greyed-out "browse the whole server"
/// view). Defaults to `true` so online browsing (which reads this same cache as
/// a fast path) still sees the full catalog.
///
/// While offline, the frontend drives this from the "Show all server media"
/// toggle: OFF means library pages show only downloaded/local media, ON reveals
/// the full greyed-out catalog. See `set_include_catalog_browse` and the
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
/// every server item regardless of the toggle.
///
/// TRACES: UR-052 | DR-078
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);

/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
/// items. Called from the frontend: `true` when online or when the offline
/// "Show all server media" toggle is on; `false` when offline with the toggle
/// off (show downloaded/local media only).
pub fn set_include_catalog_browse(include: bool) {
    INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
}

/// Whether offline `get_items` currently includes the synced-but-not-downloaded
/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`.
///
/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from
/// "user asked for downloads only and there are none here": when this is false,
/// an empty offline `get_items` is authoritative and must not fall through to
/// the server. See hybrid.rs `get_items`.
///
/// TRACES: UR-052 | DR-078, DR-080
pub fn include_catalog_browse() -> bool {
    INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
}

pub struct OfflineRepository {
    db_service: Arc<RusqliteService>,
    server_id: String,
    user_id: String,
}

impl OfflineRepository {
    pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
        Self {
            db_service,
            server_id,
            user_id,
        }
    }

    /// Helper to convert CachedItem from storage to MediaItem
    fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
        let artists_vec = item
            .artists
            .as_ref()
            .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
            .unwrap_or_default();

        MediaItem {
            id: item.id.clone(),
            name: item.name,
            item_type: item.item_type,
            is_folder: item.is_folder,
            server_id: item.server_id,
            parent_id: item.parent_id,
            library_id: item.library_id,
            overview: item.overview,
            genres: item
                .genres
                .as_ref()
                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
            runtime_ticks: item.runtime_ticks,
            production_year: item.production_year,
            premiere_date: item.premiere_date,
            community_rating: item.community_rating,
            official_rating: item.official_rating,
            primary_image_tag: item.primary_image_tag,
            backdrop_image_tags: item.backdrop_image_tags,
            parent_backdrop_image_tags: item.parent_backdrop_image_tags,
            album_id: item.album_id,
            album_name: item.album_name,
            album_artist: item.album_artist,
            artists: Some(artists_vec),
            artist_items: None, // Not stored in cache yet - TODO: add to database schema
            index_number: item.index_number,
            series_id: item.series_id,
            series_name: item.series_name,
            season_id: item.season_id,
            season_name: item.season_name,
            parent_index_number: item.parent_index_number,
            user_data,
            media_streams: None, // Not cached offline
            media_sources: None, // Not cached offline
            people: None,        // Not cached offline - TODO: add to database schema
        }
    }

    /// Get user data for an item (playback position, favorite, etc.)
    async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
        let query = Query::with_params(
            "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
             FROM user_data WHERE user_id = ? AND item_id = ?",
            vec![
                QueryParam::String(self.user_id.clone()),
                QueryParam::String(item_id.to_string()),
            ],
        );

        self.db_service
            .query_optional(query, |row| {
                Ok(UserData {
                    playback_position_ticks: row.get(0).ok(),
                    is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
                    is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
                    play_count: row.get(3).ok(),
                    last_played_date: row.get(4).ok(),
                    playback_context_type: row.get(5).ok(),
                    playback_context_id: row.get(6).ok(),
                })
            })
            .await
            .ok()
            .flatten()
    }
}

// Helper struct matching storage.rs CachedItem structure
#[derive(Debug)]
struct CachedItem {
    id: String,
    name: String,
    item_type: String,
    is_folder: bool,
    server_id: String,
    parent_id: Option<String>,
    library_id: Option<String>,
    overview: Option<String>,
    genres: Option<String>,
    runtime_ticks: Option<i64>,
    production_year: Option<i32>,
    premiere_date: Option<String>,
    community_rating: Option<f64>,
    official_rating: Option<String>,
    primary_image_tag: Option<String>,
    backdrop_image_tags: Option<Vec<String>>,
    parent_backdrop_image_tags: Option<Vec<String>>,
    album_id: Option<String>,
    album_name: Option<String>,
    album_artist: Option<String>,
    artists: Option<String>,
    index_number: Option<i32>,
    series_id: Option<String>,
    series_name: Option<String>,
    season_id: Option<String>,
    season_name: Option<String>,
    parent_index_number: Option<i32>,
}

fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
    Ok(CachedItem {
        id: row.get(0)?,
        name: row.get(1)?,
        item_type: row.get(2)?,
        server_id: row.get(3)?,
        parent_id: row.get(4)?,
        library_id: row.get(5)?,
        overview: row.get(6)?,
        genres: row.get(7)?,
        runtime_ticks: row.get(8)?,
        production_year: row.get(9)?,
        community_rating: row.get(10)?,
        official_rating: row.get(11)?,
        primary_image_tag: row.get(12)?,
        backdrop_image_tags: None,        // TODO: Add to DB schema
        parent_backdrop_image_tags: None, // TODO: Add to DB schema
        album_id: row.get(13)?,
        album_name: row.get(14)?,
        album_artist: row.get(15)?,
        artists: row.get(16)?,
        index_number: row.get(17)?,
        series_id: row.get(18)?,
        series_name: row.get(19)?,
        season_id: row.get(20)?,
        season_name: row.get(21)?,
        parent_index_number: row.get(22)?,
        // Appended as the final columns in every SELECT that maps through this fn.
        is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
        premiere_date: row.get(24)?,
    })
}

impl OfflineRepository {
    /// Save browsed items to cache for faster subsequent loading
    ///
    /// This persists metadata for items that were browsed (not necessarily downloaded).
    /// Items are marked with current timestamp for freshness tracking.
    pub async fn save_to_cache(
        &self,
        parent_id: &str,
        items: &[MediaItem],
    ) -> Result<usize, RepoError> {
        if items.is_empty() {
            return Ok(0);
        }

        let now = chrono::Utc::now().to_rfc3339();

        // Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
        // when replacing stub parent items with their actual data
        self.db_service
            .execute(Query::new("PRAGMA foreign_keys = OFF"))
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        // Ensure we re-enable foreign keys even if an error occurs
        let result = self.save_to_cache_impl(parent_id, items, &now).await;

        // Re-enable foreign key constraints
        let _ = self
            .db_service
            .execute(Query::new("PRAGMA foreign_keys = ON"))
            .await;

        result
    }

    async fn save_to_cache_impl(
        &self,
        parent_id: &str,
        items: &[MediaItem],
        now: &str,
    ) -> Result<usize, RepoError> {
        // Collect all unique parent IDs referenced by items being saved
        let mut parent_ids = std::collections::HashSet::new();
        parent_ids.insert(parent_id.to_string());

        for item in items {
            if let Some(pid) = &item.parent_id {
                parent_ids.insert(pid.clone());
            }
        }

        // Insert stub entries for all parent IDs to satisfy FK constraints
        #[cfg(test)]
        println!("Creating stub parents for: {:?}", parent_ids);

        for pid in parent_ids {
            let parent_query = Query::with_params(
                "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
                 VALUES (?1, ?2, ?3, ?4, ?5)",
                vec![
                    QueryParam::String(pid.clone()),
                    QueryParam::String(self.server_id.clone()),
                    QueryParam::String("Parent".to_string()),
                    QueryParam::String("Folder".to_string()),
                    QueryParam::String(now.to_string()),
                ],
            );

            let _stub_rows = self
                .db_service
                .execute(parent_query)
                .await
                .map_err(|e| RepoError::Database { message: e })?;
            #[cfg(test)]
            println!(
                "  Created stub parent {} (rows affected: {})",
                pid, _stub_rows
            );
        }

        let mut count = 0;

        for item in items {
            // Convert Option<Vec<String>> to JSON strings for storage
            let genres_json = item
                .genres
                .as_ref()
                .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
            let artists_json = item
                .artists
                .as_ref()
                .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
            let backdrop_tags_json = item
                .backdrop_image_tags
                .as_ref()
                .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));

            // Use INSERT OR REPLACE to upsert items
            let query = Query::with_params(
                "INSERT OR REPLACE INTO items (
                    id, server_id, library_id, parent_id,
                    name, item_type, is_folder, overview,
                    genres, series_id, series_name,
                    season_id, season_name, index_number, parent_index_number,
                    album_id, album_name, album_artist, artists,
                    production_year, premiere_date, runtime_ticks,
                    primary_image_tag, backdrop_image_tags,
                    community_rating, official_rating,
                    synced_at
                ) VALUES (
                    ?1, ?2, ?3, ?4,
                    ?5, ?6, ?7, ?8,
                    ?9, ?10, ?11,
                    ?12, ?13, ?14, ?15,
                    ?16, ?17, ?18, ?19,
                    ?20, ?21, ?22,
                    ?23, ?24,
                    ?25, ?26,
                    ?27
                )",
                vec![
                    QueryParam::String(item.id.clone()),
                    QueryParam::String(self.server_id.clone()),
                    // Library is NULL for cached items (may not be synced yet)
                    QueryParam::Null, // library_id
                    // Use the item's actual parent_id, not the function parameter
                    match &item.parent_id {
                        Some(pid) => QueryParam::String(pid.clone()),
                        None => QueryParam::Null,
                    },
                    QueryParam::String(item.name.clone()),
                    QueryParam::String(item.item_type.clone()),
                    QueryParam::Int(if item.is_folder { 1 } else { 0 }),
                    match &item.overview {
                        Some(o) => QueryParam::String(o.clone()),
                        None => QueryParam::Null,
                    },
                    match genres_json {
                        Some(g) => QueryParam::String(g),
                        None => QueryParam::Null,
                    },
                    match &item.series_id {
                        Some(s) => QueryParam::String(s.clone()),
                        None => QueryParam::Null,
                    },
                    match &item.series_name {
                        Some(s) => QueryParam::String(s.clone()),
                        None => QueryParam::Null,
                    },
                    match &item.season_id {
                        Some(s) => QueryParam::String(s.clone()),
                        None => QueryParam::Null,
                    },
                    match &item.season_name {
                        Some(s) => QueryParam::String(s.clone()),
                        None => QueryParam::Null,
                    },
                    match item.index_number {
                        Some(i) => QueryParam::Int(i),
                        None => QueryParam::Null,
                    },
                    match item.parent_index_number {
                        Some(i) => QueryParam::Int(i),
                        None => QueryParam::Null,
                    },
                    match &item.album_id {
                        Some(a) => QueryParam::String(a.clone()),
                        None => QueryParam::Null,
                    },
                    match &item.album_name {
                        Some(a) => QueryParam::String(a.clone()),
                        None => QueryParam::Null,
                    },
                    match &item.album_artist {
                        Some(a) => QueryParam::String(a.clone()),
                        None => QueryParam::Null,
                    },
                    match artists_json {
                        Some(a) => QueryParam::String(a),
                        None => QueryParam::Null,
                    },
                    match item.production_year {
                        Some(y) => QueryParam::Int(y),
                        None => QueryParam::Null,
                    },
                    match &item.premiere_date {
                        Some(d) => QueryParam::String(d.clone()),
                        None => QueryParam::Null,
                    },
                    match item.runtime_ticks {
                        Some(r) => QueryParam::Int64(r),
                        None => QueryParam::Null,
                    },
                    match &item.primary_image_tag {
                        Some(t) => QueryParam::String(t.clone()),
                        None => QueryParam::Null,
                    },
                    match backdrop_tags_json {
                        Some(b) => QueryParam::String(b),
                        None => QueryParam::Null,
                    },
                    match item.community_rating {
                        Some(r) => QueryParam::Float(r),
                        None => QueryParam::Null,
                    },
                    match &item.official_rating {
                        Some(r) => QueryParam::String(r.clone()),
                        None => QueryParam::Null,
                    },
                    QueryParam::String(now.to_string()),
                ],
            );

            let _rows_affected =
                self.db_service
                    .execute(query)
                    .await
                    .map_err(|e| RepoError::Database {
                        message: format!("Failed to insert item {}: {}", item.id, e),
                    })?;
            #[cfg(test)]
            println!(
                "  [save_to_cache] Saved item {} (rows affected: {})",
                item.id, _rows_affected
            );
            count += 1;
        }

        Ok(count)
    }

    /// Cache the library (view) list from the server into the local database.
    /// Called by HybridRepository after a successful online fetch so the list is
    /// available offline. Without this, the `libraries` table stays empty and
    /// offline startup shows no libraries at all.
    pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
        if libraries.is_empty() {
            return Ok(0);
        }

        let mut count = 0;
        for (idx, lib) in libraries.iter().enumerate() {
            let query = Query::with_params(
                "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
                 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
                vec![
                    QueryParam::String(lib.id.clone()),
                    QueryParam::String(self.server_id.clone()),
                    QueryParam::String(lib.name.clone()),
                    QueryParam::String(lib.collection_type.clone()),
                    lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
                    QueryParam::Int(idx as i32),
                ],
            );
            self.db_service
                .execute(query)
                .await
                .map_err(|e| RepoError::Database { message: e })?;
            count += 1;
        }
        Ok(count)
    }

    /// Cache the full server genre catalog for a library, so offline (and the
    /// hybrid cache-first race) can return the complete list instead of only the
    /// genres derivable from locally-cached albums. Replaces the scope's rows
    /// wholesale so genres removed on the server don't linger.
    pub async fn save_genres_to_cache(
        &self,
        parent_id: Option<&str>,
        genres: &[Genre],
    ) -> Result<usize, RepoError> {
        if genres.is_empty() {
            return Ok(0);
        }

        // library_id is part of the primary key; NULL keys don't de-dupe in
        // SQLite, so store the "no library" scope as an empty string.
        let library_id = parent_id.unwrap_or("").to_string();
        let server_id = self.server_id.clone();
        let genres: Vec<(String, String, Option<u32>)> = genres
            .iter()
            .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
            .collect();
        let saved = genres.len();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                // Clear the scope's existing genres, then re-insert the fresh set.
                tx.execute(Query::with_params(
                    "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
                    vec![
                        QueryParam::String(server_id.clone()),
                        QueryParam::String(library_id.clone()),
                    ],
                ))?;

                for (id, name, album_count) in &genres {
                    tx.execute(Query::with_params(
                        "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
                         VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
                        vec![
                            QueryParam::String(id.clone()),
                            QueryParam::String(server_id.clone()),
                            QueryParam::String(library_id.clone()),
                            QueryParam::String(name.clone()),
                            album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
                        ],
                    ))?;
                }

                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        Ok(saved)
    }

    /// SQL fragment: the set of item ids that are "on the device" — playable
    /// items with a completed download, plus containers (album/series/season)
    /// that have at least one downloaded child. This is the `get_items` CTE with
    /// the synced-but-not-downloaded catalog branch deliberately excluded, so it
    /// is authoritative regardless of the process-wide catalog-browse flag.
    ///
    /// TRACES: UR-055 | DR-082, DR-083
    const DOWNLOADED_ITEMS_CTE: &'static str = "
        WITH downloaded_items AS (
            SELECT DISTINCT i.id
            FROM items i
            INNER JOIN downloads d ON i.id = d.item_id
            WHERE d.status = 'completed'
              AND i.item_type IN ('Audio', 'Movie', 'Episode')

            UNION

            SELECT DISTINCT i.id
            FROM items i
            INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
            INNER JOIN downloads d ON children.id = d.item_id
            WHERE d.status = 'completed'
              AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
        )";

    /// Downloaded-only browse: items under `parent_id` that are on the device.
    ///
    /// Unlike [`MediaRepository::get_items`], this never includes the
    /// synced-but-not-downloaded catalog and never consults the process-wide
    /// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface.
    /// An empty result is authoritative ("nothing downloaded here"), so the
    /// hybrid repo must call this directly rather than racing the server.
    ///
    /// TRACES: UR-055 | DR-082, DR-083
    pub async fn get_downloaded_items(
        &self,
        parent_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let opts = options.unwrap_or_default();
        let limit = opts.limit.unwrap_or(10000);
        let start_index = opts.start_index.unwrap_or(0);

        let type_filter = if let Some(include_item_types) = &opts.include_item_types {
            if !include_item_types.is_empty() {
                let types = include_item_types
                    .iter()
                    .map(|t| format!("'{}'", t.replace('\'', "''")))
                    .collect::<Vec<_>>()
                    .join(",");
                format!(" AND i.item_type IN ({})", types)
            } else {
                String::new()
            }
        } else {
            String::new()
        };

        let sql = format!(
            "{cte}
            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
                    i.parent_index_number, i.is_folder, i.premiere_date
            FROM items i
             INNER JOIN downloaded_items di ON i.id = di.id
             WHERE i.server_id = ?
               AND (
                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
                   OR EXISTS (
                       SELECT 1 FROM libraries l
                       WHERE l.id = ? AND l.server_id = i.server_id
                   )
               ){type_filter}
             ORDER BY i.sort_name ASC, i.name ASC
             LIMIT {limit} OFFSET {start_index}",
            cte = Self::DOWNLOADED_ITEMS_CTE,
        );

        let query = Query::with_params(
            sql,
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(parent_id.to_string()),
                QueryParam::String(parent_id.to_string()),
                QueryParam::String(parent_id.to_string()),
                QueryParam::String(parent_id.to_string()),
                QueryParam::String(parent_id.to_string()),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        let total_record_count = items.len();
        Ok(SearchResult {
            items,
            total_record_count,
        })
    }

    /// Libraries that contain at least one downloaded item. Libraries with
    /// nothing on the device are omitted, so the Downloaded surface only lists
    /// libraries the user actually has offline content in.
    ///
    /// TRACES: UR-055 | DR-082
    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
        // A downloaded item links back to its library only indirectly (the
        // cache leaves library_id NULL — see [[offline-libraries-never-cached]]).
        // We match a library by collection_type ↔ item_type instead: any
        // completed download of a given media kind qualifies that library.
        let query = Query::with_params(
            &format!(
                "{cte}
                SELECT l.id, l.name, l.collection_type, l.image_tag
                FROM libraries l
                WHERE l.server_id = ?
                  AND EXISTS (
                      SELECT 1 FROM items i
                      INNER JOIN downloaded_items di ON i.id = di.id
                      WHERE i.server_id = l.server_id
                        AND (
                            (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
                            OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
                            OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
                            OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
                        )
                  )
                ORDER BY l.sort_order ASC, l.name ASC",
                cte = Self::DOWNLOADED_ITEMS_CTE,
            ),
            vec![QueryParam::String(self.server_id.clone())],
        );

        self.db_service
            .query_many(query, |row| {
                Ok(Library {
                    id: row.get(0)?,
                    name: row.get(1)?,
                    collection_type: row
                        .get::<_, Option<String>>(2)?
                        .unwrap_or_else(|| "unknown".to_string()),
                    image_tag: row.get(3)?,
                })
            })
            .await
            .map_err(|e| RepoError::Database { message: e })
    }

    /// On-disk bytes for downloaded content, for the disk-usage display.
    ///
    /// Returns one entry per *container or leaf* that appears in the Downloaded
    /// browse: a leaf's own `file_size`, a container's summed downloaded
    /// descendants — plus the device total and item (leaf) count. This is pure
    /// aggregation over `downloads.file_size`, not new tracking.
    ///
    /// TRACES: UR-056 | DR-085
    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
        // Per-leaf sizes (completed playable downloads only).
        let leaf_query = Query::with_params(
            "SELECT d.item_id, COALESCE(d.file_size, 0)
             FROM downloads d
             INNER JOIN items i ON i.id = d.item_id
             WHERE d.status = 'completed'
               AND i.server_id = ?
               AND i.item_type IN ('Audio', 'Movie', 'Episode')",
            vec![QueryParam::String(self.server_id.clone())],
        );
        let leaves: Vec<(String, i64)> = self
            .db_service
            .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        // Container subtotals: sum each container's downloaded descendants.
        let container_query = Query::with_params(
            "SELECT c.id, COALESCE(SUM(d.file_size), 0)
             FROM items c
             INNER JOIN items children
                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
             INNER JOIN downloads d ON children.id = d.item_id
             WHERE d.status = 'completed'
               AND c.server_id = ?
               AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
             GROUP BY c.id",
            vec![QueryParam::String(self.server_id.clone())],
        );
        let containers: Vec<(String, i64)> = self
            .db_service
            .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        // Partiality per container: a container is "partial" when it has cached
        // descendants that are NOT downloaded. We compare downloaded-descendant
        // count against total-cached-descendant count (the offline cache holds
        // the synced full catalog, so this is meaningful).
        let partial_query = Query::with_params(
            "SELECT c.id,
                    COUNT(children.id) AS total_children,
                    SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
             FROM items c
             INNER JOIN items children
                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
             LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
             WHERE c.server_id = ?
               AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
               AND children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
             GROUP BY c.id",
            vec![QueryParam::String(self.server_id.clone())],
        );
        let partial_rows: Vec<(String, i64, i64)> = self
            .db_service
            .query_many(partial_query, |row| {
                Ok((
                    row.get(0)?,
                    row.get(1)?,
                    row.get::<_, Option<i64>>(2)?.unwrap_or(0),
                ))
            })
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut partial_containers = std::collections::HashMap::new();
        for (id, total, downloaded) in partial_rows {
            // Only record containers that actually have a download (they appear
            // in the browse); mark partial when some cached child is missing.
            if downloaded > 0 && downloaded < total {
                partial_containers.insert(id, true);
            }
        }

        let item_count = leaves.len() as u32;
        let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();

        let mut sizes = std::collections::HashMap::new();
        for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) {
            // A container id can never collide with a leaf id, so a plain insert
            // is fine; use entry to be defensive against duplicate rows.
            *sizes.entry(id).or_insert(0) += bytes;
        }

        Ok(DownloadDiskUsage {
            sizes,
            partial_containers,
            device_total_bytes,
            item_count,
        })
    }

    /// Cache playlist items from server into local database
    /// Called by HybridRepository after fetching from online
    pub async fn save_playlist_items_to_cache(
        &self,
        playlist_id: &str,
        entries: &[PlaylistEntry],
    ) -> Result<(), RepoError> {
        let playlist_id = playlist_id.to_string();
        let user_id = self.user_id.clone();
        let entries: Vec<(String, String, usize)> = entries
            .iter()
            .enumerate()
            .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
            .collect();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                // Ensure playlist record exists
                tx.execute(Query::with_params(
                    "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
                    vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
                ))?;

                // Clear existing entries and re-insert
                tx.execute(Query::with_params(
                    "DELETE FROM playlist_items WHERE playlist_id = ?",
                    vec![QueryParam::String(playlist_id.clone())],
                ))?;

                for (_, item_id, sort_order) in &entries {
                    tx.execute(Query::with_params(
                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
                        vec![
                            QueryParam::String(playlist_id.clone()),
                            QueryParam::String(item_id.clone()),
                            QueryParam::Int(*sort_order as i32),
                        ],
                    ))?;
                }

                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to cache playlist items: {}", e),
            })
    }
}

#[async_trait]
impl MediaRepository for OfflineRepository {
    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
        // Return every cached library for this server. We deliberately do NOT
        // gate on `items.library_id` here: that column is not populated in the
        // cache (the Jellyfin client doesn't parse it), so the old
        // `INNER JOIN items i ON i.library_id = l.id` matched nothing and left
        // offline startup with zero libraries. Navigating into a library still
        // filters to downloaded content via get_items, so listing all cached
        // libraries is correct — it's the "local first" list the UI browses.
        let query = Query::with_params(
            "SELECT l.id, l.name, l.collection_type, l.image_tag
             FROM libraries l
             WHERE l.server_id = ?
             ORDER BY l.sort_order ASC, l.name ASC",
            vec![QueryParam::String(self.server_id.clone())],
        );

        self.db_service
            .query_many(query, |row| {
                Ok(Library {
                    id: row.get(0)?,
                    name: row.get(1)?,
                    collection_type: row
                        .get::<_, Option<String>>(2)?
                        .unwrap_or_else(|| "unknown".to_string()),
                    image_tag: row.get(3)?,
                })
            })
            .await
            .map_err(|e| RepoError::Database { message: e })
    }

    async fn get_items(
        &self,
        parent_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        debug!(
            "[OfflineRepo] get_items called for parent_id: {}",
            &parent_id[..8.min(parent_id.len())]
        );
        let opts = options.unwrap_or_default();
        let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
        let start_index = opts.start_index.unwrap_or(0);

        // SortBy=Random is the only sort the landing pages rely on offline (the
        // hero "surprise" pool); everything else keeps the stable name order.
        let order_by = match opts.sort_by.as_deref() {
            Some("Random") => "RANDOM()",
            _ => "i.sort_name ASC, i.name ASC",
        };

        // Build type filter for optional filtering
        let type_filter = if let Some(include_item_types) = &opts.include_item_types {
            if !include_item_types.is_empty() {
                let types = include_item_types
                    .iter()
                    .map(|t| format!("'{}'", t))
                    .collect::<Vec<_>>()
                    .join(",");
                format!(" AND i.item_type IN ({})", types)
            } else {
                String::new()
            }
        } else {
            String::new()
        };

        // Use CTE to find items that are either:
        // 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode)
        // 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode)
        // 3. Cached items with recent synced_at timestamp (fast online browsing, or the
        //    offline "Show all server media" catalog view) — only when the catalog-browse
        //    flag is set. When offline with the toggle off, this branch is omitted so the
        //    page shows downloaded/local media only. See `set_include_catalog_browse`.
        let catalog_branch = if include_catalog_browse() {
            "UNION

                -- Cached items for fast browsing (online) or the offline catalog view
                SELECT DISTINCT i.id
                FROM items i
                WHERE i.synced_at IS NOT NULL"
        } else {
            ""
        };
        let sql = format!(
            "WITH available_items AS (
                -- Playable items with completed downloads
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN downloads d ON i.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('Audio', 'Movie', 'Episode')

                UNION

                -- Containers with downloaded children
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                INNER JOIN downloads d ON children.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')

                {catalog_branch}
            )
            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
                    i.parent_index_number, i.is_folder, i.premiere_date
            FROM items i
             INNER JOIN available_items ai ON i.id = ai.id
             WHERE i.server_id = ?
               AND (
                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
                   -- When the requested parent is a LIBRARY, there is no per-item
                   -- link back to it (library_id/parent_id are NULL in the cache),
                   -- so match every item on the server and let the type filter
                   -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
                   -- makes library landing pages show albums/movies/shows offline.
                   OR EXISTS (
                       SELECT 1 FROM libraries l
                       WHERE l.id = ? AND l.server_id = i.server_id
                   )
               ){}
             ORDER BY {}
             LIMIT {} OFFSET {}",
            type_filter, order_by, limit, start_index
        );

        // The requested id is compared against every hierarchy-linkage column
        // because `parent_id` is not populated for cached items — music tracks
        // link to their album via `album_id`, episodes to their season/series
        // via `season_id`/`series_id`, and a library parent matches via the
        // `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
        let query = Query::with_params(
            sql,
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(parent_id.to_string()), // i.parent_id = ?
                QueryParam::String(parent_id.to_string()), // i.album_id = ?
                QueryParam::String(parent_id.to_string()), // i.season_id = ?
                QueryParam::String(parent_id.to_string()), // i.series_id = ?
                QueryParam::String(parent_id.to_string()), // libraries.id = ?
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        debug!(
            "[OfflineRepo] Found {} cached items for parent {}",
            cached_items.len(),
            &parent_id[..8.min(parent_id.len())]
        );

        // Fetch user data for each item
        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        let total_record_count = items.len();

        debug!(
            "[OfflineRepo] Returning {} items for parent {}",
            total_record_count,
            &parent_id[..8.min(parent_id.len())]
        );

        Ok(SearchResult {
            items,
            total_record_count,
        })
    }

    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
        // Check if item is available offline (either downloaded itself or has downloaded children)
        let query = Query::with_params(
            "WITH downloaded_items AS (
                -- Playable items with completed downloads
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN downloads d ON i.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('Audio', 'Movie', 'Episode')

                UNION

                -- Containers with downloaded children
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                INNER JOIN downloads d ON children.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
            )
            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
                    i.parent_index_number, i.is_folder, i.premiere_date
            FROM items i
             INNER JOIN downloaded_items di ON i.id = di.id
             WHERE i.id = ?",
            vec![QueryParam::String(item_id.to_string())],
        );

        let cached = self
            .db_service
            .query_optional(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?
            .ok_or_else(|| RepoError::NotFound {
                message: format!(
                    "Item {} not found in offline cache or not downloaded",
                    item_id
                ),
            })?;

        let user_data = self.get_user_data(item_id).await;
        Ok(Self::cached_item_to_media_item(cached, user_data))
    }

    async fn get_latest_items(
        &self,
        parent_id: &str,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(16);

        let query = Query::with_params(
            format!(
                "WITH downloaded_items AS (
                    -- Playable items with completed downloads
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN downloads d ON i.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type IN ('Audio', 'Movie', 'Episode')

                    UNION

                    -- Containers with downloaded children
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                    INNER JOIN downloads d ON children.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
                )
                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
                        i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
                        i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
                        i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
                        i.parent_index_number, i.is_folder, i.premiere_date
                FROM items i
                 INNER JOIN downloaded_items di ON i.id = di.id
                 WHERE i.server_id = ? AND i.library_id = ?
                 ORDER BY i.synced_at DESC
                 LIMIT {}", limit_val
            ),
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(parent_id.to_string()),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        Ok(items)
    }

    async fn get_resume_items(
        &self,
        parent_id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(12);

        // Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
        let (sql, params) = if let Some(pid) = parent_id {
            (
                format!(
                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                            i.overview, i.genres, i.runtime_ticks, i.production_year,
                            i.community_rating, i.official_rating, i.primary_image_tag,
                            i.album_id, i.album_name, i.album_artist, i.artists,
                            i.index_number, i.series_id, i.series_name, i.season_id,
                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
                    FROM items i
                     JOIN user_data ud ON i.id = ud.item_id
                     INNER JOIN downloads d ON i.id = d.item_id
                     WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
                       AND d.status = 'completed'
                       AND i.item_type IN ('Movie', 'Episode')
                     ORDER BY ud.last_played_at DESC
                     LIMIT {}",
                    limit_val
                ),
                vec![
                    QueryParam::String(self.server_id.clone()),
                    QueryParam::String(self.user_id.clone()),
                    QueryParam::String(pid.to_string()),
                ],
            )
        } else {
            (
                format!(
                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                            i.overview, i.genres, i.runtime_ticks, i.production_year,
                            i.community_rating, i.official_rating, i.primary_image_tag,
                            i.album_id, i.album_name, i.album_artist, i.artists,
                            i.index_number, i.series_id, i.series_name, i.season_id,
                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
                    FROM items i
                     JOIN user_data ud ON i.id = ud.item_id
                     INNER JOIN downloads d ON i.id = d.item_id
                     WHERE i.server_id = ? AND ud.user_id = ?
                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
                       AND d.status = 'completed'
                       AND i.item_type IN ('Movie', 'Episode')
                     ORDER BY ud.last_played_at DESC
                     LIMIT {}",
                    limit_val
                ),
                vec![
                    QueryParam::String(self.server_id.clone()),
                    QueryParam::String(self.user_id.clone()),
                ],
            )
        };

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

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        Ok(items)
    }

    async fn get_next_up_episodes(
        &self,
        _series_id: Option<&str>,
        _limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        // Next up is complex - would need to track watched episodes and find the next unwatched
        // For now, return empty for offline mode
        Ok(Vec::new())
    }

    async fn get_recently_played_audio(
        &self,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(12);

        // Use CTE to intelligently group by playback context and filter by downloads
        // Shows containers (albums) when context_type='container', individual tracks when context_type='single'
        // Falls back to album grouping for legacy data (NULL context)
        // Only shows items that are downloaded or have downloaded children
        let query = Query::with_params(
            format!(
                "WITH downloaded_items AS (
                    -- Playable items with completed downloads (Audio tracks)
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN downloads d ON i.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type = 'Audio'

                    UNION

                    -- Containers with downloaded children (Albums)
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                    INNER JOIN downloads d ON children.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type = 'MusicAlbum'
                ),
                ranked_plays AS (
                    SELECT
                        CASE
                            WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
                            WHEN ud.playback_context_type = 'single' THEN ud.item_id
                            ELSE COALESCE(i.album_id, ud.item_id)
                        END AS display_id,
                        MAX(ud.last_played_at) AS most_recent_play
                    FROM user_data ud
                    JOIN items i ON ud.item_id = i.id
                    WHERE ud.user_id = ? AND i.server_id = ?
                      AND i.item_type = 'Audio'
                      AND ud.last_played_at IS NOT NULL
                    GROUP BY display_id
                    ORDER BY most_recent_play DESC
                    LIMIT {}
                )
                SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                       i.overview, i.genres, i.runtime_ticks, i.production_year,
                       i.community_rating, i.official_rating, i.primary_image_tag,
                       i.album_id, i.album_name, i.album_artist, i.artists,
                       i.index_number, i.series_id, i.series_name, i.season_id,
                       i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
               FROM ranked_plays rp
                JOIN items i ON rp.display_id = i.id
                INNER JOIN downloaded_items di ON i.id = di.id
                ORDER BY rp.most_recent_play DESC",
                limit_val
            ),
            vec![
                QueryParam::String(self.user_id.clone()),
                QueryParam::String(self.server_id.clone()),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        Ok(items)
    }

    async fn get_rediscover_albums(
        &self,
        _parent_id: Option<&str>,
        _limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        // "Rediscover" is a discovery feature over the full server library.
        // Offline only holds downloaded items, so there is nothing meaningful
        // to surface here; the hybrid repo serves this from the server instead.
        Ok(Vec::new())
    }

    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(12);

        // Resume movies are playable items, so simple JOIN with downloads
        let query = Query::with_params(
            format!(
                "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                        i.overview, i.genres, i.runtime_ticks, i.production_year,
                        i.community_rating, i.official_rating, i.primary_image_tag,
                        i.album_id, i.album_name, i.album_artist, i.artists,
                        i.index_number, i.series_id, i.series_name, i.season_id,
                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
                FROM items i
                 JOIN user_data ud ON i.id = ud.item_id
                 INNER JOIN downloads d ON i.id = d.item_id
                 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
                   AND ud.playback_position_ticks > 0 AND ud.is_played = 0
                   AND d.status = 'completed'
                 ORDER BY ud.last_played_at DESC
                 LIMIT {}",
                limit_val
            ),
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(self.user_id.clone()),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        Ok(items)
    }

    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
        // Read the cached server genre catalog (populated by the hybrid repo via
        // save_genres_to_cache). This is the FULL genre list for the library, not
        // just the genres derivable from locally-cached albums — so offline keeps
        // the same variety the server has. library_id NULL is stored as ''.
        let library_id = parent_id.unwrap_or("").to_string();

        let query = Query::with_params(
            "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(library_id),
            ],
        );

        let genres: Vec<Genre> = self
            .db_service
            .query_many(query, |row| {
                Ok(Genre {
                    id: row.get(0)?,
                    name: row.get(1)?,
                    album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
                })
            })
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        Ok(genres)
    }

    async fn search(
        &self,
        query: &str,
        options: Option<SearchOptions>,
    ) -> Result<SearchResult, RepoError> {
        let opts = options.unwrap_or_default();
        let limit = opts.limit.unwrap_or(20);

        // Escape FTS special characters and add prefix matching
        let fts_query = format!("{}*", query.replace('"', "\"\""));

        // Build type filter
        let type_filter = if let Some(include_item_types) = &opts.include_item_types {
            if !include_item_types.is_empty() {
                let types = include_item_types
                    .iter()
                    .map(|t| format!("'{}'", t))
                    .collect::<Vec<_>>()
                    .join(",");
                format!(" AND i.item_type IN ({})", types)
            } else {
                String::new()
            }
        } else {
            String::new()
        };

        // Use CTE to filter by downloads, then search within downloaded content
        let sql = format!(
            "WITH downloaded_items AS (
                -- Playable items with completed downloads
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN downloads d ON i.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('Audio', 'Movie', 'Episode')

                UNION

                -- Containers with downloaded children
                SELECT DISTINCT i.id
                FROM items i
                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                INNER JOIN downloads d ON children.id = d.item_id
                WHERE d.status = 'completed'
                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
            )
            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                    i.overview, i.genres, i.runtime_ticks, i.production_year,
                    i.community_rating, i.official_rating, i.primary_image_tag,
                    i.album_id, i.album_name, i.album_artist, i.artists,
                    i.index_number, i.series_id, i.series_name, i.season_id,
                    i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
            FROM items i
             JOIN items_fts fts ON fts.rowid = i.rowid
             INNER JOIN downloaded_items di ON i.id = di.id
             WHERE i.server_id = ? AND items_fts MATCH ?{}
             ORDER BY rank
             LIMIT {}",
            type_filter, limit
        );

        let db_query = Query::with_params(
            sql,
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(fts_query),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(db_query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        let total_record_count = items.len();

        Ok(SearchResult {
            items,
            total_record_count,
        })
    }

    async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
        // Playback info requires server communication for transcoding decisions
        Err(RepoError::Offline)
    }

    async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
        // Cannot get stream URLs while offline - offline tracks use local paths
        Err(RepoError::Offline)
    }

    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
        // Live TV is inherently online-only.
        Err(RepoError::Offline)
    }

    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
        // Plugin channels are inherently online-only.
        Err(RepoError::Offline)
    }

    async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
        // Live streams cannot be opened offline.
        Err(RepoError::Offline)
    }

    async fn report_playback_start(
        &self,
        _item_id: &str,
        _position_ticks: i64,
    ) -> Result<(), RepoError> {
        // Cannot report to server while offline
        Err(RepoError::Offline)
    }

    async fn report_playback_progress(
        &self,
        _item_id: &str,
        _position_ticks: i64,
    ) -> Result<(), RepoError> {
        // Cannot report to server while offline
        Err(RepoError::Offline)
    }

    async fn report_playback_stopped(
        &self,
        _item_id: &str,
        _position_ticks: i64,
    ) -> Result<(), RepoError> {
        // Cannot report to server while offline
        Err(RepoError::Offline)
    }

    fn get_image_url(
        &self,
        item_id: &str,
        image_type: ImageType,
        options: Option<ImageOptions>,
    ) -> String {
        // Return a placeholder path for offline image retrieval
        // The actual image should be in thumbnail cache
        let type_str = match image_type {
            ImageType::Primary => "Primary",
            ImageType::Backdrop => "Backdrop",
            ImageType::Logo => "Logo",
            ImageType::Thumb => "Thumb",
            ImageType::Banner => "Banner",
        };

        if let Some(opts) = options {
            if let Some(tag) = opts.tag {
                return format!("offline://{}/{}/{}", item_id, type_str, tag);
            }
        }

        format!("offline://{}/{}", item_id, type_str)
    }

    fn get_subtitle_url(
        &self,
        _item_id: &str,
        _media_source_id: &str,
        _stream_index: i32,
        _format: &str,
    ) -> String {
        // Subtitles not available offline
        String::new()
    }

    fn get_video_download_url(
        &self,
        _item_id: &str,
        _quality: &str,
        _media_source_id: Option<&str>,
    ) -> String {
        // Cannot download while offline
        String::new()
    }

    async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
        // Cannot update server while offline
        Err(RepoError::Offline)
    }

    async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
        // Cannot update server while offline
        Err(RepoError::Offline)
    }

    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
        let query = Query::with_params(
            "SELECT id, name, overview, primary_image_tag
             FROM people WHERE id = ?",
            vec![QueryParam::String(person_id.to_string())],
        );

        let person_data = self
            .db_service
            .query_optional(query, |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, Option<String>>(2)?,
                    row.get::<_, Option<String>>(3)?,
                ))
            })
            .await
            .map_err(|e| RepoError::Database { message: e })?
            .ok_or_else(|| RepoError::NotFound {
                message: format!("Person {} not found in cache", person_id),
            })?;

        Ok(MediaItem {
            id: person_data.0,
            name: person_data.1,
            item_type: "Person".to_string(),
            is_folder: false,
            server_id: self.server_id.clone(),
            parent_id: None,
            library_id: None,
            overview: person_data.2,
            genres: None,
            runtime_ticks: None,
            production_year: None,
            premiere_date: None,
            community_rating: None,
            official_rating: None,
            primary_image_tag: person_data.3,
            backdrop_image_tags: None,
            parent_backdrop_image_tags: None,
            album_id: None,
            album_name: None,
            album_artist: None,
            artists: None,
            artist_items: None,
            index_number: None,
            series_id: None,
            series_name: None,
            season_id: None,
            season_name: None,
            parent_index_number: None,
            user_data: None,
            media_streams: None,
            media_sources: None,
            people: None,
        })
    }

    async fn get_items_by_person(
        &self,
        person_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let opts = options.unwrap_or_default();
        let limit = opts.limit.unwrap_or(10000); // Match frontend limit

        // Filter by downloads using CTE
        let query = Query::with_params(
            format!(
                "WITH downloaded_items AS (
                    -- Playable items with completed downloads
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN downloads d ON i.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type IN ('Audio', 'Movie', 'Episode')

                    UNION

                    -- Containers with downloaded children
                    SELECT DISTINCT i.id
                    FROM items i
                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
                    INNER JOIN downloads d ON children.id = d.item_id
                    WHERE d.status = 'completed'
                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
                )
                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
                        i.overview, i.genres, i.runtime_ticks, i.production_year,
                        i.community_rating, i.official_rating, i.primary_image_tag,
                        i.album_id, i.album_name, i.album_artist, i.artists,
                        i.index_number, i.series_id, i.series_name, i.season_id,
                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
                FROM items i
                 JOIN item_people ip ON i.id = ip.item_id
                 INNER JOIN downloaded_items di ON i.id = di.id
                 WHERE i.server_id = ? AND ip.person_id = ?
                 ORDER BY i.production_year DESC, i.sort_name ASC
                 LIMIT {}", limit
            ),
            vec![
                QueryParam::String(self.server_id.clone()),
                QueryParam::String(person_id.to_string()),
            ],
        );

        let cached_items: Vec<CachedItem> = self
            .db_service
            .query_many(query, row_to_cached_item)
            .await
            .map_err(|e| RepoError::Database { message: e })?;

        let mut items = Vec::new();
        for cached in cached_items {
            let user_data = self.get_user_data(&cached.id).await;
            items.push(Self::cached_item_to_media_item(cached, user_data));
        }

        let total_record_count = items.len();

        Ok(SearchResult {
            items,
            total_record_count,
        })
    }

    async fn get_similar_items(
        &self,
        _item_id: &str,
        _limit: Option<usize>,
    ) -> Result<SearchResult, RepoError> {
        // Similar items require server-side computation and are not available offline
        Err(RepoError::Offline)
    }

    // ===== Playlist Methods =====

    async fn create_playlist(
        &self,
        name: &str,
        item_ids: &[String],
    ) -> Result<PlaylistCreatedResult, RepoError> {
        let playlist_id = uuid::Uuid::new_v4().to_string();
        let user_id = self.user_id.clone();
        let name = name.to_string();
        let item_ids = item_ids.to_vec();
        let pid = playlist_id.clone();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                tx.execute(Query::with_params(
                    "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
                    vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
                ))?;

                for (i, item_id) in item_ids.iter().enumerate() {
                    tx.execute(Query::with_params(
                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
                        vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
                    ))?;
                }

                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to create playlist: {}", e),
            })?;

        Ok(PlaylistCreatedResult { id: playlist_id })
    }

    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
        let query = Query::with_params(
            "DELETE FROM playlists WHERE id = ?",
            vec![QueryParam::String(playlist_id.to_string())],
        );
        self.db_service
            .execute(query)
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to delete playlist: {}", e),
            })?;
        Ok(())
    }

    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
        let query = Query::with_params(
            "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
            vec![
                QueryParam::String(name.to_string()),
                QueryParam::String(playlist_id.to_string()),
            ],
        );
        self.db_service
            .execute(query)
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to rename playlist: {}", e),
            })?;
        Ok(())
    }

    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
        let query = Query::with_params(
            "SELECT pi.id, \
                    i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
                    i.parent_index_number, i.is_folder, i.premiere_date \
             FROM playlist_items pi \
             JOIN items i ON pi.item_id = i.id \
             WHERE pi.playlist_id = ? \
             ORDER BY pi.sort_order ASC",
            vec![QueryParam::String(playlist_id.to_string())],
        );

        let items = self
            .db_service
            .query_many(query, |row| {
                let entry_id: i64 = row.get(0)?;
                // Columns offset by 1 because first column is pi.id
                let cached = CachedItem {
                    id: row.get(1)?,
                    name: row.get(2)?,
                    item_type: row.get(3)?,
                    server_id: row.get(4)?,
                    parent_id: row.get(5)?,
                    library_id: row.get(6)?,
                    overview: row.get(7)?,
                    genres: row.get(8)?,
                    runtime_ticks: row.get(9)?,
                    production_year: row.get(10)?,
                    community_rating: row.get(11)?,
                    official_rating: row.get(12)?,
                    primary_image_tag: row.get(13)?,
                    backdrop_image_tags: None,
                    parent_backdrop_image_tags: None,
                    album_id: row.get(14)?,
                    album_name: row.get(15)?,
                    album_artist: row.get(16)?,
                    artists: row.get(17)?,
                    index_number: row.get(18)?,
                    series_id: row.get(19)?,
                    series_name: row.get(20)?,
                    season_id: row.get(21)?,
                    season_name: row.get(22)?,
                    parent_index_number: row.get(23)?,
                    is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
                    premiere_date: row.get(25)?,
                };
                Ok((entry_id.to_string(), cached))
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to get playlist items: {}", e),
            })?;

        Ok(items
            .into_iter()
            .map(|(entry_id, cached)| PlaylistEntry {
                playlist_item_id: entry_id,
                item: Self::cached_item_to_media_item(cached, None),
            })
            .collect())
    }

    async fn add_to_playlist(
        &self,
        playlist_id: &str,
        item_ids: &[String],
    ) -> Result<(), RepoError> {
        // Get current max sort_order
        let max_query = Query::with_params(
            "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
            vec![QueryParam::String(playlist_id.to_string())],
        );
        let max_order: i32 = self
            .db_service
            .query_one(max_query, |row| row.get(0))
            .await
            .unwrap_or(-1);

        let playlist_id = playlist_id.to_string();
        let item_ids = item_ids.to_vec();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                for (i, item_id) in item_ids.iter().enumerate() {
                    tx.execute(Query::with_params(
                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
                        vec![
                            QueryParam::String(playlist_id.clone()),
                            QueryParam::String(item_id.clone()),
                            QueryParam::Int(max_order + 1 + i as i32),
                        ],
                    ))?;
                }
                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to add items to playlist: {}", e),
            })?;

        Ok(())
    }

    async fn remove_from_playlist(
        &self,
        playlist_id: &str,
        entry_ids: &[String],
    ) -> Result<(), RepoError> {
        let playlist_id = playlist_id.to_string();
        let entry_ids = entry_ids.to_vec();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                for entry_id in &entry_ids {
                    tx.execute(Query::with_params(
                        "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
                        vec![
                            QueryParam::String(playlist_id.clone()),
                            QueryParam::String(entry_id.clone()),
                        ],
                    ))?;
                }
                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to remove items from playlist: {}", e),
            })?;

        Ok(())
    }

    async fn move_playlist_item(
        &self,
        playlist_id: &str,
        item_id: &str,
        new_index: u32,
    ) -> Result<(), RepoError> {
        let playlist_id = playlist_id.to_string();
        let item_id = item_id.to_string();

        self.db_service
            .transaction(move |tx| {
                use crate::storage::db_service::{Query, QueryParam};

                // Get all items ordered by sort_order
                let items: Vec<(i64, String)> = tx.query_many(
                    Query::with_params(
                        "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
                        vec![QueryParam::String(playlist_id)],
                    ),
                    |row| Ok((row.get(0)?, row.get(1)?)),
                )?;

                // Find the item to move
                let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
                if let Some(old_pos) = old_idx {
                    let mut ids = items;
                    let entry = ids.remove(old_pos);
                    let insert_at = (new_index as usize).min(ids.len());
                    ids.insert(insert_at, entry);

                    // Renumber all sort_orders
                    for (i, (entry_id, _)) in ids.iter().enumerate() {
                        tx.execute(Query::with_params(
                            "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
                            vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
                        ))?;
                    }
                }

                Ok(())
            })
            .await
            .map_err(|e| RepoError::Database {
                message: format!("Failed to move playlist item: {}", e),
            })?;

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::db_service::RusqliteService;
    use rusqlite::Connection;
    use std::sync::{Arc, Mutex};

    /// Helper to create a test database with the necessary schema
    fn create_test_db() -> Arc<RusqliteService> {
        let conn = Connection::open_in_memory().unwrap();

        // Enable foreign key constraints (they're disabled by default in SQLite)
        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();

        // Create minimal schema for testing
        conn.execute_batch(
            r#"
            CREATE TABLE servers (
                id TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                url TEXT NOT NULL UNIQUE
            );

            CREATE TABLE items (
                id TEXT PRIMARY KEY,
                server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
                library_id TEXT,
                parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
                name TEXT NOT NULL,
                item_type TEXT NOT NULL,
                is_folder INTEGER DEFAULT 0,
                overview TEXT,
                genres TEXT,
                runtime_ticks INTEGER,
                production_year INTEGER,
                premiere_date TEXT,
                community_rating REAL,
                official_rating TEXT,
                primary_image_tag TEXT,
                backdrop_image_tags TEXT,
                album_id TEXT,
                album_name TEXT,
                album_artist TEXT,
                artists TEXT,
                index_number INTEGER,
                series_id TEXT,
                series_name TEXT,
                season_id TEXT,
                season_name TEXT,
                parent_index_number INTEGER,
                synced_at TEXT,
                sort_name TEXT
            );

            CREATE TABLE user_data (
                user_id TEXT NOT NULL,
                item_id TEXT NOT NULL,
                playback_position_ticks INTEGER,
                is_played INTEGER,
                is_favorite INTEGER,
                play_count INTEGER,
                last_played_at TEXT,
                playback_context_type TEXT,
                playback_context_id TEXT,
                PRIMARY KEY (user_id, item_id)
            );

            CREATE TABLE playlists (
                id TEXT PRIMARY KEY,
                user_id TEXT NOT NULL,
                name TEXT NOT NULL,
                is_local INTEGER DEFAULT 0,
                jellyfin_id TEXT,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                updated_at TEXT
            );

            CREATE TABLE playlist_items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
                item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
                sort_order INTEGER NOT NULL,
                added_at TEXT DEFAULT CURRENT_TIMESTAMP,
                UNIQUE(playlist_id, item_id)
            );

            CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);

            CREATE TABLE downloads (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                item_id TEXT NOT NULL,
                status TEXT NOT NULL,
                file_size INTEGER
            );

            CREATE TABLE libraries (
                id TEXT PRIMARY KEY,
                server_id TEXT NOT NULL,
                name TEXT NOT NULL,
                collection_type TEXT,
                image_tag TEXT,
                sort_order INTEGER DEFAULT 0,
                synced_at TEXT
            );

            CREATE TABLE genres (
                id TEXT NOT NULL,
                server_id TEXT NOT NULL,
                library_id TEXT,
                name TEXT NOT NULL,
                album_count INTEGER,
                synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (server_id, library_id, name)
            );
        "#,
        )
        .unwrap();

        // Insert a test server
        conn.execute(
            "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
            [],
        ).unwrap();

        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
    }

    /// Helper to create a test MediaItem
    fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
        MediaItem {
            id: id.to_string(),
            name: name.to_string(),
            item_type: "Audio".to_string(),
            is_folder: false,
            server_id: "test-server".to_string(),
            parent_id: parent_id.map(|s| s.to_string()),
            library_id: None,
            overview: None,
            genres: None,
            runtime_ticks: None,
            production_year: None,
            premiere_date: None,
            community_rating: None,
            official_rating: None,
            primary_image_tag: None,
            backdrop_image_tags: None,
            parent_backdrop_image_tags: None,
            album_id: None,
            album_name: None,
            album_artist: None,
            artists: None,
            artist_items: None,
            index_number: None,
            series_id: None,
            series_name: None,
            season_id: None,
            season_name: None,
            parent_index_number: None,
            user_data: None,
            media_streams: None,
            media_sources: None,
            people: None,
        }
    }

    #[tokio::test]
    async fn test_save_to_cache_with_missing_parent_fk() {
        let db_service = create_test_db();

        // Verify FK constraints are actually enabled
        let fk_enabled: i32 = db_service
            .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
            .await
            .unwrap();
        println!("Foreign keys enabled: {}", fk_enabled);
        assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");

        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        // Create items where CHILDREN come BEFORE their PARENTS in the list
        // This simulates the real-world scenario where Jellyfin's API
        // returns items in an arbitrary order (e.g., alphabetically)
        let items = vec![
            // Tracks from Album 1 (album-1 doesn't exist yet!)
            create_test_item("track-1", "Track 1", Some("album-1")),
            create_test_item("track-2", "Track 2", Some("album-1")),
            // Tracks from Album 2 (album-2 doesn't exist yet!)
            create_test_item("track-3", "Track 3", Some("album-2")),
            create_test_item("track-4", "Track 4", Some("album-2")),
            // Albums come later in the list
            create_test_item("album-1", "Album One", Some("library-123")),
            create_test_item("album-2", "Album Two", Some("library-123")),
        ];

        println!("Attempting to save {} items...", items.len());
        for (i, item) in items.iter().enumerate() {
            println!("  Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
        }

        // This should fail with the current implementation because:
        // 1. It only creates a stub for "library-123" (the parent_id parameter)
        // 2. When it tries to insert track-1 with parent_id = "album-1",
        //    album-1 doesn't exist yet, causing FK constraint failure
        let result = repo.save_to_cache("library-123", &items).await;

        // After the fix, this should succeed and preserve parent relationships
        match &result {
            Ok(count) => {
                println!("✓ Saved {} items", count);
                assert_eq!(*count, 6);

                // Debug: Check all items in the database
                let all_items: Vec<(String, Option<String>)> = db_service
                    .query_many(
                        Query::new("SELECT id, parent_id FROM items ORDER BY id"),
                        |row| Ok((row.get(0)?, row.get(1)?)),
                    )
                    .await
                    .unwrap();

                println!("\nAll items in database:");
                for (id, parent) in &all_items {
                    println!("  {} -> parent: {:?}", id, parent);
                }

                // Verify the ACTUAL parent_ids in the database are preserved correctly
                let track1_parent: Option<String> = db_service
                    .query_optional(
                        Query::with_params(
                            "SELECT parent_id FROM items WHERE id = ?",
                            vec![QueryParam::String("track-1".to_string())],
                        ),
                        |row| row.get(0),
                    )
                    .await
                    .unwrap()
                    .flatten();

                println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
                println!("track-1 expected parent_id: Some(\"album-1\")");

                // Verify parent relationships are preserved
                assert_eq!(
                    track1_parent,
                    Some("album-1".to_string()),
                    "track-1 should have parent_id='album-1'"
                );

                // Verify album-1 has the correct parent too
                let album1_parent: Option<String> = db_service
                    .query_optional(
                        Query::with_params(
                            "SELECT parent_id FROM items WHERE id = ?",
                            vec![QueryParam::String("album-1".to_string())],
                        ),
                        |row| row.get(0),
                    )
                    .await
                    .unwrap()
                    .flatten();

                assert_eq!(
                    album1_parent,
                    Some("library-123".to_string()),
                    "album-1 should have parent_id='library-123'"
                );
            }
            Err(e) => panic!("Unexpected error: {:?}", e),
        }
    }

    #[tokio::test]
    async fn test_save_to_cache_simple_case() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        // Simple case: all items have the same parent_id as the parameter
        let items = vec![
            create_test_item("item-1", "Item 1", Some("parent-123")),
            create_test_item("item-2", "Item 2", Some("parent-123")),
            create_test_item("item-3", "Item 3", Some("parent-123")),
        ];

        let result = repo.save_to_cache("parent-123", &items).await;
        assert!(result.is_ok(), "Simple case should work: {:?}", result);
        assert_eq!(result.unwrap(), 3);
    }

    /// Regression: a MusicAlbum whose tracks link via `album_id` (and have a
    /// NULL `parent_id`, which is how the Jellyfin cache actually stores them)
    /// must be recognized as available offline when a track is downloaded.
    ///
    /// Before the fix, `get_item(album_id)` only matched children by
    /// `children.parent_id = i.id`, so a fully-downloaded album returned
    /// NotFound offline and playback fell through to the (unreachable) server.
    #[tokio::test]
    async fn test_get_item_album_available_via_album_id_link() {
        use crate::storage::db_service::DatabaseService;
        let db_service = create_test_db();

        for sql in [
            // Album container (no children by parent_id).
            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
             VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
            // Track linked to the album ONLY via album_id, parent_id NULL.
            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
             VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
            "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
        ] {
            db_service.execute(Query::new(sql)).await.unwrap();
        }

        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        // The downloaded track itself resolves offline.
        assert!(
            repo.get_item("track-1").await.is_ok(),
            "downloaded track should be available offline"
        );

        // The album must also resolve offline because it has a downloaded child
        // linked by album_id (not parent_id).
        let album = repo.get_item("album-1").await;
        assert!(
            album.is_ok(),
            "album with an album_id-linked downloaded track should be available offline, got {:?}",
            album.err()
        );
        assert_eq!(album.unwrap().id, "album-1");

        // Browsing into the album (get_items) must return its tracks even though
        // they link by album_id and have a NULL parent_id. This is the call
        // play_album_track makes to build the queue.
        let tracks = repo.get_items("album-1", None).await.unwrap();
        assert_eq!(
            tracks.items.len(),
            1,
            "get_items(album_id) should return the track"
        );
        assert_eq!(tracks.items[0].id, "track-1");
    }

    /// Regression: offline library pages must honor the "Show all server media"
    /// toggle. With `include_catalog_browse` off, `get_items` returns only
    /// downloaded media — not the whole synced catalog. With it on, the full
    /// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
    /// offline library pages showed every server item regardless of the toggle.
    ///
    /// TRACES: UR-052 | DR-078 | UT-067
    #[tokio::test]
    async fn test_get_items_toggle_gates_synced_catalog() {
        use crate::storage::db_service::DatabaseService;
        let db_service = create_test_db();

        for sql in [
            // Two movies in a library, both merely synced (browsed) — no download.
            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
             VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
             VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
            // Only the first movie is actually downloaded.
            "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
            // A library row so the library-parent EXISTS clause matches.
            "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
        ] {
            db_service.execute(Query::new(sql)).await.unwrap();
        }

        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        let opts = Some(GetItemsOptions {
            include_item_types: Some(vec!["Movie".to_string()]),
            ..Default::default()
        });

        // Toggle OFF: only the downloaded movie is returned.
        set_include_catalog_browse(false);
        let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
        let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
        assert_eq!(
            ids,
            vec!["movie-dl"],
            "toggle off should show downloaded media only"
        );

        // Toggle ON: both the downloaded and the catalog-only movie are returned.
        set_include_catalog_browse(true);
        let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
        let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
        ids.sort();
        assert_eq!(
            ids,
            vec!["movie-cat", "movie-dl"],
            "toggle on should reveal the full catalog"
        );

        // Restore default for other tests sharing this process-global flag.
        set_include_catalog_browse(true);
    }

    /// Regression: TV episodes link to their season/series via `season_id` /
    /// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded
    /// episode must make both its Season and Series available offline, and
    /// browsing either container must return the episode.
    #[tokio::test]
    async fn test_get_item_tv_available_via_season_series_link() {
        use crate::storage::db_service::DatabaseService;
        let db_service = create_test_db();

        for sql in [
            "INSERT INTO items (id, server_id, name, item_type, parent_id) \
             VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
            "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
             VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
            // Episode links to both season and series; parent_id NULL.
            "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
             VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
            "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
        ] {
            db_service.execute(Query::new(sql)).await.unwrap();
        }

        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        assert!(
            repo.get_item("ep-1").await.is_ok(),
            "downloaded episode available offline"
        );
        assert!(
            repo.get_item("season-1").await.is_ok(),
            "season with a season_id-linked downloaded episode should be available offline"
        );
        assert!(
            repo.get_item("series-1").await.is_ok(),
            "series with a series_id-linked downloaded episode should be available offline"
        );

        // Browsing the season returns the episode.
        let season_items = repo.get_items("season-1", None).await.unwrap();
        assert!(
            season_items.items.iter().any(|i| i.id == "ep-1"),
            "get_items(season_id) should return the episode"
        );

        // Browsing the series returns the episode (via series_id link).
        let series_items = repo.get_items("series-1", None).await.unwrap();
        assert!(
            series_items.items.iter().any(|i| i.id == "ep-1"),
            "get_items(series_id) should surface the downloaded episode"
        );
    }

    /// Regression: offline startup must list cached libraries. Previously
    /// `get_libraries` joined on `items.library_id` (always NULL in the cache),
    /// so it returned nothing offline and the app showed no libraries at all.
    /// The list must round-trip through `save_libraries_to_cache`.
    #[tokio::test]
    async fn test_libraries_cache_roundtrip_available_offline() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        // Empty cache → no libraries (this is the state that fell through to the
        // server and hung offline).
        assert!(repo.get_libraries().await.unwrap().is_empty());

        // Simulate the online path persisting the server's library list.
        let server_libs = vec![
            Library {
                id: "music".into(),
                name: "Music".into(),
                collection_type: "music".into(),
                image_tag: None,
            },
            Library {
                id: "movies".into(),
                name: "Movies".into(),
                collection_type: "movies".into(),
                image_tag: Some("tag".into()),
            },
        ];
        let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
        assert_eq!(saved, 2);

        // Now offline get_libraries returns them without touching the server.
        let offline_libs = repo.get_libraries().await.unwrap();
        let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["Music", "Movies"],
            "cached libraries available offline in sort order"
        );

        // Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
        repo.save_libraries_to_cache(&server_libs).await.unwrap();
        assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
    }

    /// Regression: the music/TV/movie landing pages lost genre variety because
    /// offline `get_genres` derived genres from cached albums only — so the
    /// hybrid cache-first race pinned the list to whatever sparse set the local
    /// albums yielded instead of the server's full catalog. The full genre list
    /// must round-trip through `save_genres_to_cache` and come back scoped by
    /// library.
    #[tokio::test]
    async fn test_genres_cache_roundtrip_scoped_by_library() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        // Empty cache → no genres.
        assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());

        // Simulate the online path persisting the server's full genre catalog.
        let server_genres = vec![
            Genre {
                id: "g1".into(),
                name: "Rock".into(),
                album_count: Some(42),
            },
            Genre {
                id: "g2".into(),
                name: "Jazz".into(),
                album_count: Some(17),
            },
            Genre {
                id: "g3".into(),
                name: "Ambient".into(),
                album_count: None,
            },
        ];
        let saved = repo
            .save_genres_to_cache(Some("music-lib"), &server_genres)
            .await
            .unwrap();
        assert_eq!(saved, 3);

        // Offline get_genres returns the full set for that library, counts intact.
        let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
        offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
        let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
        assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
        let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
        assert_eq!(rock.album_count, Some(42));

        // Genres are scoped: a different library sees nothing.
        assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());

        // Re-saving replaces the scope's rows (server removed "Jazz").
        let updated = vec![Genre {
            id: "g1".into(),
            name: "Rock".into(),
            album_count: Some(50),
        }];
        repo.save_genres_to_cache(Some("music-lib"), &updated)
            .await
            .unwrap();
        let after = repo.get_genres(Some("music-lib")).await.unwrap();
        assert_eq!(after.len(), 1, "stale genres removed on refresh");
        assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
    }

    // ===== Playlist Tests =====

    /// Helper to seed items into the DB for playlist tests
    async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
        let items: Vec<MediaItem> = ids
            .iter()
            .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
            .collect();
        repo.save_to_cache("library-1", &items).await.unwrap();
    }

    /// Insert a fully-formed item row of a given type (bypasses save_to_cache's
    /// stub-parent machinery so containers/leaves can be linked precisely).
    async fn insert_item(
        db: &Arc<RusqliteService>,
        id: &str,
        item_type: &str,
        album_id: Option<&str>,
        series_id: Option<&str>,
        season_id: Option<&str>,
    ) {
        db.execute(Query::with_params(
            "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
             VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
            vec![
                QueryParam::String(id.to_string()),
                QueryParam::String(format!("Name {id}")),
                QueryParam::String(item_type.to_string()),
                album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
                series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
                season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
            ],
        ))
        .await
        .unwrap();
    }

    async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
        db.execute(Query::with_params(
            "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
            vec![
                QueryParam::String(item_id.to_string()),
                QueryParam::Int64(file_size),
            ],
        ))
        .await
        .unwrap();
    }

    async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
        db.execute(Query::with_params(
            "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
             VALUES (?1, 'test-server', ?2, ?3, 0)",
            vec![
                QueryParam::String(id.to_string()),
                QueryParam::String(format!("Lib {id}")),
                QueryParam::String(collection_type.to_string()),
            ],
        ))
        .await
        .unwrap();
    }

    fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
        OfflineRepository::new(
            db.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        )
    }

    /// UT: downloaded-only browse returns a downloaded leaf AND its container,
    /// filtered to the requested album parent. A non-downloaded sibling is omitted.
    ///
    /// TRACES: UR-055 | DR-082, DR-083 | UT-046
    #[tokio::test]
    async fn test_get_downloaded_items_returns_leaf_and_container() {
        let db = create_test_db();
        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
        // track-1 downloaded; track-2 is NOT downloaded.
        seed_completed_download(&db, "track-1", 1000).await;

        let repo = make_repo(&db);

        // Browsing the album shows only the downloaded track.
        let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
        let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
        assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
    }

    /// UT: an empty downloaded-only browse is authoritative — no rows, no error,
    /// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
    ///
    /// TRACES: UR-055 | DR-082 | UT-047
    #[tokio::test]
    async fn test_get_downloaded_items_empty_is_authoritative() {
        let db = create_test_db();
        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
        // Nothing downloaded, and the catalog-browse flag is ON (online default).
        set_include_catalog_browse(true);
        let repo = make_repo(&db);

        let result = repo.get_downloaded_items("album-1", None).await.unwrap();
        assert!(
            result.items.is_empty(),
            "empty downloaded browse returns no items even with catalog-browse on"
        );
    }

    /// UT: only libraries with downloaded content are listed; an empty one is omitted.
    ///
    /// TRACES: UR-055 | DR-082 | UT-048
    #[tokio::test]
    async fn test_get_downloaded_libraries_omits_empty() {
        let db = create_test_db();
        seed_library(&db, "music-lib", "music").await;
        seed_library(&db, "movie-lib", "movies").await;
        insert_item(&db, "track-1", "Audio", None, None, None).await;
        seed_completed_download(&db, "track-1", 500).await;

        let repo = make_repo(&db);
        let libs = repo.get_downloaded_libraries().await.unwrap();
        let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
        assert_eq!(
            ids,
            vec!["music-lib"],
            "movie library with no downloads omitted"
        );
    }

    /// UT: disk usage reports a leaf's own size, a container's summed descendants,
    /// and reconciles the device total with the sum of leaves.
    ///
    /// TRACES: UR-056 | DR-085 | UT-049
    #[tokio::test]
    async fn test_download_disk_usage_aggregates_containers() {
        let db = create_test_db();
        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
        seed_completed_download(&db, "track-1", 1000).await;
        seed_completed_download(&db, "track-2", 2000).await;

        let repo = make_repo(&db);
        let usage = repo.get_download_disk_usage().await.unwrap();

        assert_eq!(usage.item_count, 2, "two leaf downloads");
        assert_eq!(
            usage.device_total_bytes, 3000,
            "device total is the leaf sum"
        );
        assert_eq!(usage.sizes.get("track-1"), Some(&1000));
        assert_eq!(
            usage.sizes.get("album-1"),
            Some(&3000),
            "container = sum of children"
        );
        // Both children downloaded ⇒ album is NOT partial.
        assert_eq!(
            usage.partial_containers.get("album-1"),
            None,
            "fully downloaded album is not partial"
        );
        // Device total reconciles with the sum of the listed leaves.
        let leaf_sum: i64 = ["track-1", "track-2"]
            .iter()
            .map(|id| usage.sizes[*id])
            .sum();
        assert_eq!(leaf_sum, usage.device_total_bytes);
    }

    /// UT: a container with a downloaded child AND a non-downloaded cached child
    /// is flagged partial. TRACES: UR-055 | DR-083 | UT-051
    #[tokio::test]
    async fn test_download_disk_usage_flags_partial_container() {
        let db = create_test_db();
        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
        // Only track-1 downloaded; track-2 is cached but not downloaded.
        seed_completed_download(&db, "track-1", 1000).await;

        let repo = make_repo(&db);
        let usage = repo.get_download_disk_usage().await.unwrap();
        assert_eq!(
            usage.partial_containers.get("album-1"),
            Some(&true),
            "album with a missing child is partial"
        );
    }

    #[tokio::test]
    async fn test_playlist_create_empty() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        let result = repo.create_playlist("My Playlist", &[]).await;
        assert!(result.is_ok());
        let created = result.unwrap();
        assert!(
            !created.id.is_empty(),
            "Should return a non-empty playlist ID"
        );

        // Verify playlist exists in DB
        let name: String = db_service
            .query_one(
                Query::with_params(
                    "SELECT name FROM playlists WHERE id = ?",
                    vec![QueryParam::String(created.id.clone())],
                ),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(name, "My Playlist");
    }

    #[tokio::test]
    async fn test_playlist_create_with_items() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["t1", "t2", "t3"]).await;

        let created = repo
            .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
            .await
            .unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        assert_eq!(items.len(), 3);
        assert_eq!(items[0].item.id, "t1");
        assert_eq!(items[1].item.id, "t2");
        assert_eq!(items[2].item.id, "t3");
    }

    #[tokio::test]
    async fn test_playlist_delete() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["t1"]).await;

        let created = repo
            .create_playlist("To Delete", &["t1".into()])
            .await
            .unwrap();

        // Delete it
        repo.delete_playlist(&created.id).await.unwrap();

        // Verify playlist is gone
        let count: i32 = db_service
            .query_one(
                Query::with_params(
                    "SELECT COUNT(*) FROM playlists WHERE id = ?",
                    vec![QueryParam::String(created.id.clone())],
                ),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(count, 0);

        // Verify cascade deleted playlist_items
        let item_count: i32 = db_service
            .query_one(
                Query::with_params(
                    "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
                    vec![QueryParam::String(created.id)],
                ),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(item_count, 0);
    }

    #[tokio::test]
    async fn test_playlist_rename() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        let created = repo.create_playlist("Original Name", &[]).await.unwrap();
        repo.rename_playlist(&created.id, "New Name").await.unwrap();

        let name: String = db_service
            .query_one(
                Query::with_params(
                    "SELECT name FROM playlists WHERE id = ?",
                    vec![QueryParam::String(created.id)],
                ),
                |row| row.get(0),
            )
            .await
            .unwrap();
        assert_eq!(name, "New Name");
    }

    #[tokio::test]
    async fn test_playlist_get_items_preserves_order() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["a", "b", "c"]).await;

        let created = repo
            .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
            .await
            .unwrap();
        let items = repo.get_playlist_items(&created.id).await.unwrap();

        assert_eq!(items.len(), 3);
        // Order should match insertion order: c, a, b
        assert_eq!(items[0].item.id, "c");
        assert_eq!(items[1].item.id, "a");
        assert_eq!(items[2].item.id, "b");
        // Each entry should have a unique playlist_item_id
        assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
        assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
    }

    #[tokio::test]
    async fn test_playlist_get_items_empty_playlist() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );

        let created = repo.create_playlist("Empty", &[]).await.unwrap();
        let items = repo.get_playlist_items(&created.id).await.unwrap();
        assert!(items.is_empty());
    }

    #[tokio::test]
    async fn test_playlist_add_items() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["t1", "t2", "t3"]).await;

        let created = repo
            .create_playlist("Addable", &["t1".into()])
            .await
            .unwrap();

        // Add two more tracks
        repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
            .await
            .unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        assert_eq!(items.len(), 3);
        assert_eq!(items[0].item.id, "t1");
        assert_eq!(items[1].item.id, "t2");
        assert_eq!(items[2].item.id, "t3");
    }

    #[tokio::test]
    async fn test_playlist_add_duplicate_items_ignored() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["t1"]).await;

        let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();

        // Try to add the same item again
        repo.add_to_playlist(&created.id, &["t1".into()])
            .await
            .unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        assert_eq!(
            items.len(),
            1,
            "Duplicate should be ignored (UNIQUE constraint)"
        );
    }

    #[tokio::test]
    async fn test_playlist_remove_items() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["t1", "t2", "t3"]).await;

        let created = repo
            .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
            .await
            .unwrap();
        let items = repo.get_playlist_items(&created.id).await.unwrap();
        assert_eq!(items.len(), 3);

        // Remove the middle track by its entry ID
        let entry_id_to_remove = items[1].playlist_item_id.clone();
        repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
            .await
            .unwrap();

        let items_after = repo.get_playlist_items(&created.id).await.unwrap();
        assert_eq!(items_after.len(), 2);
        assert_eq!(items_after[0].item.id, "t1");
        assert_eq!(items_after[1].item.id, "t3");
    }

    #[tokio::test]
    async fn test_playlist_move_item_forward() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["a", "b", "c", "d"]).await;

        let created = repo
            .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
            .await
            .unwrap();

        // Move 'a' (index 0) to index 2: expect b, c, a, d
        repo.move_playlist_item(&created.id, "a", 2).await.unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
        assert_eq!(ids, vec!["b", "c", "a", "d"]);
    }

    #[tokio::test]
    async fn test_playlist_move_item_backward() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["a", "b", "c", "d"]).await;

        let created = repo
            .create_playlist(
                "Reorder2",
                &["a".into(), "b".into(), "c".into(), "d".into()],
            )
            .await
            .unwrap();

        // Move 'd' (index 3) to index 0: expect d, a, b, c
        repo.move_playlist_item(&created.id, "d", 0).await.unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
        assert_eq!(ids, vec!["d", "a", "b", "c"]);
    }

    #[tokio::test]
    async fn test_playlist_move_item_to_end() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["a", "b", "c"]).await;

        let created = repo
            .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
            .await
            .unwrap();

        // Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
        repo.move_playlist_item(&created.id, "a", 99).await.unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
        assert_eq!(ids, vec!["b", "c", "a"]);
    }

    #[tokio::test]
    async fn test_playlist_move_nonexistent_item_is_noop() {
        let db_service = create_test_db();
        let repo = OfflineRepository::new(
            db_service.clone(),
            "test-server".to_string(),
            "test-user".to_string(),
        );
        seed_items(&repo, &["a", "b"]).await;

        let created = repo
            .create_playlist("NoOp", &["a".into(), "b".into()])
            .await
            .unwrap();

        // Move a nonexistent item - should not error, just no-op
        repo.move_playlist_item(&created.id, "nonexistent", 0)
            .await
            .unwrap();

        let items = repo.get_playlist_items(&created.id).await.unwrap();
        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
        assert_eq!(ids, vec!["a", "b"]);
    }
}
