//! Library folders the user has chosen to keep out of browsing.
//!
//! Some people file things inside a library that they never want to see while
//! browsing it — a "Podcasts" folder sitting in the music library is the
//! canonical case: its albums and tracks leak into album, artist, track and
//! playlist listings even though the user thinks of them as a different medium.
//!
//! This is a *domain* rule, not a presentation one: what an item belongs to, and
//! therefore whether a query should return it, is decided here in the repository
//! layer so every query path agrees. The predecessor of this module was a
//! frontend filter that dropped anything literally named "Podcasts" — one user's
//! folder layout, keyed on an English string, shipped to everyone. Excluding by
//! **id** instead of name is what makes the setting survive a rename, a
//! translation, or two folders sharing a name.
//!
//! The excluded set is process-wide rather than a field on a repository for the
//! same reason as `online::STREAMING_QUALITY`: it is a preference about *this
//! user's browsing*, not about a server session, so it must survive a repository
//! being rebuilt on re-login. It is written by the settings command and restored
//! from the database at startup.
//!
//! TRACES: UR-076 | DR-209

use std::collections::HashSet;
use std::sync::RwLock;

use super::types::{MediaItem, SearchResult};
use crate::utils::lock::RwLockSafe;

/// Ids (normalised — see [`normalise_id`]) of items the user has hidden.
///
/// Empty by default: nobody inherits somebody else's folder layout.
///
/// TRACES: UR-076 | DR-209
static EXCLUDED_IDS: RwLock<Vec<String>> = RwLock::new(Vec::new());

/// Jellyfin writes the same GUID both dashed and undashed depending on the
/// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a
/// canonical form means a stored id keeps matching whichever spelling a query
/// happens to return.
fn normalise_id(id: &str) -> String {
    id.trim().replace('-', "").to_ascii_lowercase()
}

/// Replace the excluded set. Ids are normalised, de-duplicated and blanks
/// dropped, so a malformed value can never hide more than it names.
///
/// TRACES: UR-076 | DR-209
pub fn set_excluded_item_ids(ids: &[String]) {
    let mut normalised: Vec<String> = Vec::with_capacity(ids.len());
    for id in ids {
        let id = normalise_id(id);
        if id.is_empty() || normalised.contains(&id) {
            continue;
        }
        normalised.push(id);
    }
    *EXCLUDED_IDS.write_safe() = normalised;
}

/// The excluded set as currently applied, normalised.
///
/// TRACES: UR-076 | DR-209
pub fn excluded_item_ids() -> Vec<String> {
    EXCLUDED_IDS.read_safe().clone()
}

/// Snapshot of the excluded set, taken once per list so a long listing does not
/// re-lock per item.
fn excluded_snapshot() -> HashSet<String> {
    EXCLUDED_IDS.read_safe().iter().cloned().collect()
}

/// Whether `item` falls under one of `excluded`.
///
/// The set is passed in rather than read from the global so the rule itself is a
/// pure function and can be tested without touching process state.
///
/// An item matches on its own id or on any of the *links* it carries back to a
/// container: parent, album, library, series or season, and its artist entries.
/// That covers the shapes a hidden folder actually reaches a listing in — the
/// folder itself in a container listing, its albums (whose `parent_id` is the
/// folder), and their tracks (whose `album_id` is the album). It is deliberately
/// link-based rather than a full ancestry walk: the repository has no ancestor
/// index, and walking one would cost a round trip per row.
///
/// TRACES: UR-076 | DR-209
pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool {
    if excluded.is_empty() {
        return false;
    }

    fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
        excluded.contains(&normalise_id(id))
    }

    fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> bool {
        match id {
            Some(id) => hidden(excluded, id),
            None => false,
        }
    }

    hidden(excluded, &item.id)
        || hidden_opt(excluded, &item.parent_id)
        || hidden_opt(excluded, &item.album_id)
        || hidden_opt(excluded, &item.library_id)
        || hidden_opt(excluded, &item.series_id)
        || hidden_opt(excluded, &item.season_id)
        || match &item.artist_items {
            Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)),
            None => false,
        }
}

/// Drop the user's hidden items from a repository result.
///
/// Implemented as a trait so the hybrid repository's generic result helpers —
/// where the cache and server legs of every cache-first race converge — can
/// apply it to whatever they are carrying, instead of each query having to
/// remember to.
///
/// TRACES: UR-076 | DR-209
pub trait ExcludeHidden: Sized {
    fn without_excluded(self) -> Self;
}

impl ExcludeHidden for Vec<MediaItem> {
    fn without_excluded(mut self) -> Self {
        let excluded = excluded_snapshot();
        if excluded.is_empty() {
            return self;
        }
        self.retain(|item| !is_excluded_by(&excluded, item));
        self
    }
}

impl ExcludeHidden for SearchResult {
    fn without_excluded(mut self) -> Self {
        let before = self.items.len();
        self.items = self.items.without_excluded();
        // `total_record_count` is what the UI shows as "N results" and what
        // paging is built against; leaving the server's count would advertise
        // rows that were just removed.
        let removed = before.saturating_sub(self.items.len());
        self.total_record_count = self.total_record_count.saturating_sub(removed);
        self
    }
}

impl ExcludeHidden for MediaItem {
    /// A single item fetched by id is never hidden.
    ///
    /// Exclusion hides things from *browsing*. An item asked for by id was
    /// navigated to deliberately, or is being resolved by the player or a
    /// download — answering "not found" there would break playback of anything
    /// inside a hidden folder rather than merely tidying a listing.
    fn without_excluded(self) -> Self {
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::repository::types::ArtistItem;
    use crate::utils::lock::MutexSafe;
    use std::sync::Mutex;

    /// Serialises the tests that write the process-global excluded set. Cargo
    /// runs a crate's tests in one process, so without this two of them racing
    /// would see each other's ids.
    static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(());

    fn item(id: &str) -> MediaItem {
        MediaItem {
            id: id.to_string(),
            name: format!("item {id}"),
            ..MediaItem::default()
        }
    }

    fn excluded(ids: &[&str]) -> HashSet<String> {
        ids.iter().map(|id| normalise_id(id)).collect()
    }

    /// The default is empty: nobody inherits another user's folder layout, which
    /// is exactly what the hardcoded "Podcasts" name filter did.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_no_exclusions_by_default_keeps_everything() {
        let empty = HashSet::new();
        assert!(!is_excluded_by(&empty, &item("anything")));

        let items = vec![item("a"), item("b")];
        assert_eq!(items.without_excluded().len(), 2);
    }

    /// The folder itself, and anything linking back to it, is hidden.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_excludes_the_folder_and_what_points_at_it() {
        let set = excluded(&["folder-1"]);

        assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself");

        let album = MediaItem {
            parent_id: Some("folder-1".to_string()),
            ..item("album-1")
        };
        assert!(is_excluded_by(&set, &album), "an album inside the folder");

        let track = MediaItem {
            album_id: Some("folder-1".to_string()),
            ..item("track-1")
        };
        assert!(is_excluded_by(&set, &track), "a track of the folder");

        let elsewhere = MediaItem {
            parent_id: Some("folder-2".to_string()),
            ..item("album-2")
        };
        assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album");
    }

    /// A whole library, a series/season and an artist are all excludable by the
    /// same check — the setting is "hide this container", not "hide albums".
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_excludes_via_every_container_link() {
        let set = excluded(&["container"]);

        let by_library = MediaItem {
            library_id: Some("container".to_string()),
            ..item("x")
        };
        assert!(is_excluded_by(&set, &by_library));

        let by_series = MediaItem {
            series_id: Some("container".to_string()),
            ..item("x")
        };
        assert!(is_excluded_by(&set, &by_series));

        let by_season = MediaItem {
            season_id: Some("container".to_string()),
            ..item("x")
        };
        assert!(is_excluded_by(&set, &by_season));

        let by_artist = MediaItem {
            artist_items: Some(vec![
                ArtistItem {
                    id: "other".to_string(),
                    name: "Other".to_string(),
                },
                ArtistItem {
                    id: "container".to_string(),
                    name: "Hidden".to_string(),
                },
            ]),
            ..item("x")
        };
        assert!(is_excluded_by(&set, &by_artist));
    }

    /// Ids are matched by identity, not spelling: Jellyfin serves the same GUID
    /// dashed on one endpoint and undashed on another, and a stored id that
    /// stopped matching would silently un-hide the folder.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_id_matching_ignores_dashes_case_and_padding() {
        let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]);
        assert!(is_excluded_by(
            &set,
            &item("a1b2c3d4-0000-0000-0000-000000000000")
        ));
        assert!(is_excluded_by(
            &set,
            &item("A1B2C3D4000000000000000000000000")
        ));
        assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1")));
    }

    /// Filtering a `SearchResult` must also correct its count — the listing
    /// header reads it, and a stale total advertises rows that are not there.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_search_result_count_follows_the_filter() {
        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
        set_excluded_item_ids(&["hidden".to_string()]);

        let result = SearchResult {
            items: vec![item("keep"), item("hidden"), item("keep-2")],
            total_record_count: 3,
        }
        .without_excluded();

        set_excluded_item_ids(&[]);

        assert_eq!(result.items.len(), 2);
        assert_eq!(result.total_record_count, 2);
        assert!(result.items.iter().all(|i| i.id != "hidden"));
    }

    /// A single item asked for by id is never withheld: exclusion hides things
    /// from browsing, and refusing it here would break playback and downloads of
    /// anything inside a hidden folder.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_direct_item_lookup_is_never_hidden() {
        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
        set_excluded_item_ids(&["hidden".to_string()]);

        let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden"));
        let survives = item("hidden").without_excluded();

        set_excluded_item_ids(&[]);

        assert!(still_matches, "the predicate still matches the item");
        assert_eq!(survives.id, "hidden", "but a direct lookup keeps it");
    }

    /// The stored set is sanitised on the way in: blanks dropped, duplicates
    /// collapsed, spellings normalised.
    ///
    /// TRACES: UR-076 | DR-209 | UT-203
    #[test]
    fn test_set_excluded_item_ids_sanitises() {
        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
        set_excluded_item_ids(&[
            "  ".to_string(),
            "AB-CD".to_string(),
            "abcd".to_string(),
            "ef".to_string(),
        ]);
        let stored = excluded_item_ids();

        set_excluded_item_ids(&[]);
        let cleared = excluded_item_ids();

        assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]);
        assert!(cleared.is_empty());
    }
}
