//! Library browsing preferences — currently, which folders are hidden.
//!
//! The setting replaces a hardcoded frontend filter that dropped any item
//! literally named "Podcasts", which was one user's folder layout keyed on an
//! English string and shipped to everyone. What is hidden is now a user choice
//! made of stable ids, applied in the repository layer
//! (`repository::exclusions`) so every query path agrees; the frontend only
//! renders a picker over the candidates this module serves.
//!
//! TRACES: UR-076 | DR-209

use std::sync::Arc;

use log::{debug, info, warn};
use tauri::{Manager, State};

use crate::commands::repository::RepositoryManagerWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::repository::exclusions;
use crate::repository::types::{GetItemsOptions, SearchScope};
use crate::repository::MediaRepository;
use crate::settings::LibrarySettings;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::utils::lock::MutexSafe;

/// `app_settings` key holding the persisted library preferences (JSON).
///
/// Persisted for the same reason the streaming cap is: a hidden folder that
/// silently comes back on the next launch is a setting the user has to keep
/// re-applying, and they would have no way to tell it had been forgotten.
const LIBRARY_SETTINGS_KEY: &str = "library_settings";

/// How many immediate children of a library the picker will consider.
///
/// A music library's root listing is folders and (on some layouts) artists, not
/// the whole catalog, so this is generous. It exists to stop a pathological
/// library from turning the settings page into an unbounded fetch.
const CANDIDATE_SCAN_LIMIT: usize = 500;

/// Something the user may choose to hide: a library, or a folder directly
/// inside one.
///
/// Which containers are *offerable* is a domain question (it depends on the
/// library's Jellyfin collection type and on what counts as a folder), so the
/// list is assembled here and the frontend renders it verbatim.
///
/// TRACES: UR-076 | DR-209
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExclusionCandidate {
    /// Stable Jellyfin item id — what gets stored when the user picks it.
    pub id: String,
    /// Display name of the folder (or of the library, for a whole-library entry).
    pub name: String,
    /// Library this candidate lives in, so the picker can group and disambiguate
    /// two folders that share a name.
    pub library_name: String,
    /// True when the candidate *is* a library rather than a folder inside one.
    pub is_library: bool,
}

/// The library preferences currently in force.
///
/// Read from the in-memory exclusion set rather than the database: that set is
/// what queries actually consult, so reading it is the only answer that cannot
/// disagree with what the user is seeing.
///
/// TRACES: UR-076 | DR-209
#[tauri::command]
#[specta::specta]
pub async fn library_get_settings() -> Result<LibrarySettings, String> {
    Ok(LibrarySettings {
        excluded_item_ids: exclusions::excluded_item_ids(),
    })
}

/// Replace the library preferences: apply them to every subsequent query and
/// persist them.
///
/// Returns the sanitised value actually applied, so the picker shows what was
/// stored rather than what it sent.
///
/// TRACES: UR-076 | DR-209
#[tauri::command]
#[specta::specta]
pub async fn library_set_settings(
    db: State<'_, DatabaseWrapper>,
    settings: LibrarySettings,
) -> Result<LibrarySettings, String> {
    let sanitised = settings.sanitised();
    exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
    persist_library_settings(&db, &sanitised).await;
    info!(
        "[Library] {} folder(s) hidden from browsing",
        sanitised.excluded_item_ids.len()
    );
    Ok(sanitised)
}

/// The folders the user may choose to hide.
///
/// Offers each music library and the folders directly inside it. Music is the
/// only scope offered because it is the one where a foreign folder — podcasts,
/// audiobooks, sound effects — routinely shares a library with the media the
/// user actually browses; the scope is decided here rather than in the UI so the
/// collection-type table stays out of the frontend
/// (see `SearchScope::for_collection_type`).
///
/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
/// *already* hidden still appear — otherwise the setting could never be undone.
///
/// TRACES: UR-076 | DR-209
#[tauri::command]
#[specta::specta]
pub async fn library_get_exclusion_candidates(
    manager: State<'_, RepositoryManagerWrapper>,
    handle: String,
) -> Result<Vec<ExclusionCandidate>, String> {
    let repo = manager.0.get(&handle).ok_or("Repository not found")?;

    let libraries = repo
        .as_ref()
        .get_libraries()
        .await
        .map_err(|e| format!("{:?}", e))?;

    let mut candidates: Vec<ExclusionCandidate> = Vec::new();

    for library in libraries {
        if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
            continue;
        }

        candidates.push(ExclusionCandidate {
            id: library.id.clone(),
            name: library.name.clone(),
            library_name: library.name.clone(),
            is_library: true,
        });

        let options = GetItemsOptions {
            recursive: Some(false),
            sort_by: Some("SortName".to_string()),
            sort_order: Some("Ascending".to_string()),
            limit: Some(CANDIDATE_SCAN_LIMIT),
            ..Default::default()
        };

        match repo.get_items_unfiltered(&library.id, Some(options)).await {
            Ok(result) => {
                for item in result.items {
                    if !item.is_folder {
                        continue;
                    }
                    candidates.push(ExclusionCandidate {
                        id: item.id,
                        name: item.name,
                        library_name: library.name.clone(),
                        is_library: false,
                    });
                }
            }
            Err(e) => {
                // One unreachable library must not cost the user the picker for
                // the others — an empty section is recoverable, an error is not.
                warn!(
                    "[Library] Could not list folders in {}: {:?}",
                    library.name, e
                );
            }
        }
    }

    debug!("[Library] {} exclusion candidate(s)", candidates.len());
    Ok(candidates)
}

/// Write the preferences to `app_settings`.
///
/// Failure is logged, not returned: the setting has already been applied in
/// memory, and failing the whole call because the write failed would leave the
/// picker showing a state that *is* in force.
///
/// TRACES: UR-076 | DR-209
async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
    let db_service = {
        let database = db.0.lock_safe();
        Arc::new(database.service())
    };

    let encoded = match serde_json::to_string(settings) {
        Ok(value) => value,
        Err(e) => {
            warn!("[Library] Failed to encode library settings: {}", e);
            return;
        }
    };

    let query = Query::with_params(
        "INSERT OR REPLACE INTO app_settings (key, value, updated_at)
         VALUES (?, ?, CURRENT_TIMESTAMP)",
        vec![
            QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
            QueryParam::String(encoded),
        ],
    );

    if let Err(e) = db_service.execute(query).await {
        warn!("[Library] Failed to persist library settings: {}", e);
    }
}

/// Restore the persisted preferences at startup, into the exclusion set the
/// repository consults.
///
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
/// default — nothing hidden — in place, so a database problem shows the user
/// more than they asked for rather than less.
///
/// TRACES: UR-076 | DR-209
pub async fn restore_library_settings(app: &tauri::AppHandle) {
    let db_service = {
        let Some(db) = app.try_state::<DatabaseWrapper>() else {
            warn!("[Library] No database available; nothing hidden from browsing");
            return;
        };
        let database = db.0.lock_safe();
        Arc::new(database.service())
    };

    let query = Query::with_params(
        "SELECT value FROM app_settings WHERE key = ?",
        vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
    );

    let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
        Ok(value) => value,
        Err(e) => {
            warn!("[Library] Failed to read library settings: {}", e);
            return;
        }
    };

    let Some(stored) = stored else { return };
    let settings: LibrarySettings = match serde_json::from_str(&stored) {
        Ok(settings) => settings,
        Err(e) => {
            warn!(
                "[Library] Ignoring unreadable persisted library settings {:?}: {}",
                stored, e
            );
            return;
        }
    };

    let settings = settings.sanitised();
    exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
    if !settings.excluded_item_ids.is_empty() {
        info!(
            "[Library] Restored {} hidden folder(s)",
            settings.excluded_item_ids.len()
        );
    }
}

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

    /// The persisted form must round-trip through the same camelCase JSON the
    /// IPC boundary uses — a rename here silently un-hides every folder the user
    /// chose, with no setting having been changed.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_library_settings_round_trip_through_json() {
        let settings = LibrarySettings {
            excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
        };

        let json = serde_json::to_string(&settings).expect("serialises");
        assert!(
            json.contains("\"excludedItemIds\""),
            "camelCase on the wire"
        );

        let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
        assert_eq!(parsed, settings);
    }

    /// Settings persisted before this feature existed — and a row with the key
    /// missing entirely — must load as "nothing hidden", never as an error the
    /// caller has to handle or a default that hides something.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_library_settings_default_hides_nothing() {
        let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
        assert!(parsed.excluded_item_ids.is_empty());
        assert!(LibrarySettings::default().excluded_item_ids.is_empty());
    }

    /// Blank and duplicate ids are dropped on the way in, so a half-written or
    /// hand-edited value cannot grow the list without bound or store an id that
    /// matches nothing yet still shows as a selection.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_library_settings_sanitised() {
        let settings = LibrarySettings {
            excluded_item_ids: vec![
                "  folder-1  ".to_string(),
                "".to_string(),
                "   ".to_string(),
                "folder-1".to_string(),
                "folder-2".to_string(),
            ],
        }
        .sanitised();

        assert_eq!(
            settings.excluded_item_ids,
            vec!["folder-1".to_string(), "folder-2".to_string()]
        );
    }
}
