//! Relevance ranking for search results.
//!
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
//! in an order that ignores *where* in the name the query matched: a server
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
//! the query "parks". Neither backend is going to change, so the app imposes its
//! own ordering on the union.
//!
//! Ranking is domain logic, not presentation: it encodes what a "better match"
//! means and which media kinds outrank which. The frontend only renders the
//! order it is given.
//!
//! Two rules, in priority order:
//!
//! 1. **Match position** — a prefix match beats a word-start match, which beats
//!    a mid-word substring match. This is what makes "parks" find
//!    "Parks and Recreation" before "Sparks of Love".
//! 2. **Kind** — containers before their contents at equal match quality, so a
//!    series outranks its own episodes.
//!
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
//! `rank`) still breaks ties it was never overruled on.

use crate::domain::MediaKind;
use crate::repository::types::MediaItem;

/// How well a query matched an item's name — better matches sort first.
///
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
/// comparison for free, so adding a tier in the right position is all it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchQuality {
    /// The name starts with the query — "parks" in "Parks and Recreation".
    Prefix,
    /// Some later *word* starts with the query — "recreation" in "Parks and
    /// Recreation". Still a deliberate hit: users type whole words.
    WordStart,
    /// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
    /// that still counts as a match.
    Substring,
    /// No match on the name at all. The backend returned it for some other
    /// reason (overview, artist, album), so it is kept but sorted last.
    None,
}

/// Rank of a media kind when match quality ties — lower sorts first.
///
/// Containers outrank the items they contain: searching a show's name should
/// surface the show, not an arbitrary episode of it. Within a tier the order is
/// arbitrary but stable, and equal ranks fall through to input order.
fn kind_rank(kind: MediaKind) -> u8 {
    match kind {
        // Top-level containers a user is most likely to be looking for.
        MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
        // Sub-containers and standalone collections.
        MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
        // Leaves — an episode/track is a match *inside* something bigger.
        MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
            2
        }
        // Peripheral matches.
        MediaKind::Person | MediaKind::Other => 3,
    }
}

/// Classify how `query` matches `name`, case-insensitively.
///
/// Both sides are trimmed and lowercased; an empty query matches everything
/// equally (`Prefix`), which leaves the input order untouched.
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
    let query = query.trim().to_lowercase();
    if query.is_empty() {
        return MatchQuality::Prefix;
    }
    let name = name.trim().to_lowercase();

    let Some(index) = name.find(&query) else {
        return MatchQuality::None;
    };

    if index == 0 {
        return MatchQuality::Prefix;
    }

    // A word start is any match preceded by a non-alphanumeric character, so
    // "the-office" and "The Office" behave the same. Indexing back one char is
    // safe on the byte index `find` returned only via `char_indices`, since a
    // multi-byte char would panic on a raw slice.
    let preceded_by_boundary = name[..index]
        .chars()
        .next_back()
        .is_some_and(|c| !c.is_alphanumeric());

    if preceded_by_boundary {
        MatchQuality::WordStart
    } else {
        MatchQuality::Substring
    }
}

/// Sort search results by relevance to `query`, in place.
///
/// Stable, so items the rules rank equally keep the order the backend supplied
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
///
/// TRACES: UR-060 | DR-090
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
    // An empty query carries no relevance signal, so there is nothing to rank
    // by — reordering on kind alone would shuffle the backend's own ordering
    // for no reason.
    if query.trim().is_empty() {
        return;
    }
    items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
}

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

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

    fn names(items: &[MediaItem]) -> Vec<&str> {
        items.iter().map(|i| i.name.as_str()).collect()
    }

    /// UT-085: a prefix match outranks a mid-word substring match.
    #[test]
    fn prefix_match_beats_midword_substring() {
        assert_eq!(
            match_quality("Parks and Recreation", "parks"),
            MatchQuality::Prefix
        );
        assert_eq!(
            match_quality("Sparks of Love", "parks"),
            MatchQuality::Substring
        );
        assert!(MatchQuality::Prefix < MatchQuality::Substring);
    }

    /// UT-085: the reported bug — "parks" must find the show, not "Sparks".
    #[test]
    fn ranks_prefix_match_before_substring_match() {
        let mut items = vec![
            item("Sparks of Love", MediaKind::Series),
            item("Parks and Recreation", MediaKind::Series),
        ];

        rank_search_results(&mut items, "parks");

        assert_eq!(
            names(&items),
            vec!["Parks and Recreation", "Sparks of Love"]
        );
    }

    /// A match at a later word start beats a mid-word one but loses to a prefix.
    #[test]
    fn word_start_ranks_between_prefix_and_substring() {
        assert_eq!(
            match_quality("The Office", "office"),
            MatchQuality::WordStart
        );
        assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);

        let mut items = vec![
            item("Bofficer", MediaKind::Series),
            item("The Office", MediaKind::Series),
            item("Office Space", MediaKind::Movie),
        ];

        rank_search_results(&mut items, "office");

        assert_eq!(
            names(&items),
            vec!["Office Space", "The Office", "Bofficer"]
        );
    }

    /// UT-086: at equal match quality a series outranks an episode.
    #[test]
    fn series_ranks_before_episode_at_equal_match_quality() {
        let mut items = vec![
            item("Parks and Recreation S01E01", MediaKind::Episode),
            item("Parks and Recreation", MediaKind::Series),
        ];

        rank_search_results(&mut items, "parks");

        assert_eq!(
            names(&items),
            vec!["Parks and Recreation", "Parks and Recreation S01E01"]
        );
    }

    /// Albums outrank their tracks for the same reason series outrank episodes.
    #[test]
    fn album_ranks_before_track_at_equal_match_quality() {
        let mut items = vec![
            item("Rumours", MediaKind::Track),
            item("Rumours", MediaKind::Album),
        ];

        rank_search_results(&mut items, "rumours");

        assert_eq!(items[0].kind, MediaKind::Album);
    }

    /// Match quality dominates kind: a better-matching episode beats a
    /// worse-matching series, so kind never drags an irrelevant show to the top.
    #[test]
    fn match_quality_outranks_kind() {
        let mut items = vec![
            item("Sparks of Love", MediaKind::Series),
            item("Parks Cleanup", MediaKind::Episode),
        ];

        rank_search_results(&mut items, "parks");

        assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
    }

    /// Items the backend returned for a non-name reason (overview, artist) are
    /// kept, but sort below everything that actually matched the name.
    #[test]
    fn non_matching_names_sort_last_without_being_dropped() {
        let mut items = vec![
            item("Unrelated Documentary", MediaKind::Movie),
            item("Parks and Recreation", MediaKind::Series),
        ];

        rank_search_results(&mut items, "parks");

        assert_eq!(
            names(&items),
            vec!["Parks and Recreation", "Unrelated Documentary"]
        );
    }

    /// Ranking is stable: equally-ranked items keep the backend's order, so the
    /// FTS/server relevance signal still breaks ties.
    #[test]
    fn equal_rank_preserves_input_order() {
        let mut items = vec![
            item("Parks A", MediaKind::Series),
            item("Parks B", MediaKind::Series),
            item("Parks C", MediaKind::Series),
        ];

        rank_search_results(&mut items, "parks");

        assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
    }

    /// Case and surrounding whitespace never change the tier.
    #[test]
    fn matching_is_case_and_whitespace_insensitive() {
        assert_eq!(
            match_quality("PARKS AND RECREATION", "  parks "),
            MatchQuality::Prefix
        );
        assert_eq!(
            match_quality("Parks and Recreation", "PARKS"),
            MatchQuality::Prefix
        );
    }

    /// An empty query leaves the order alone rather than reshuffling on kind.
    #[test]
    fn empty_query_preserves_input_order() {
        let mut items = vec![
            item("Zebra", MediaKind::Episode),
            item("Apple", MediaKind::Series),
        ];

        rank_search_results(&mut items, "");

        assert_eq!(names(&items), vec!["Zebra", "Apple"]);
    }

    /// A multi-byte name must not panic when the match is mid-string — the
    /// boundary check walks chars rather than slicing raw bytes.
    #[test]
    fn handles_multibyte_names_without_panicking() {
        assert_eq!(
            match_quality("Pokémon Journeys", "journeys"),
            MatchQuality::WordStart
        );
        assert_eq!(
            match_quality("Café Parks", "parks"),
            MatchQuality::WordStart
        );
    }

    /// Punctuation counts as a word boundary, so "office" hits "The-Office".
    #[test]
    fn punctuation_counts_as_a_word_boundary() {
        assert_eq!(
            match_quality("The-Office", "office"),
            MatchQuality::WordStart
        );
        assert_eq!(
            match_quality("Show: Parks", "parks"),
            MatchQuality::WordStart
        );
    }
}
