//! Autocomplete suggestion index.
//!
//! `SuggestionIndex` maintains a sorted `Vec<String>` of title terms extracted
//! from item metadata. It supports fast prefix-match autocomplete via binary
//! search and incremental updates on `write_item_with_metadata`.
//!
//! Trending search tracking: a `DashMap<String, AtomicU64>` counter is
//! incremented on each `db.search()` call. When `suggest()` is called with
//! an empty prefix, the top N most-queried terms are returned.

use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};

use dashmap::DashMap;

use crate::schema::EntityId;

/// Autocomplete suggestion result.
#[derive(Debug, Clone)]
pub struct Suggestion {
    /// The suggested term or query text.
    pub text: String,
    /// How many times this term has been queried (for trending suggestions).
    pub frequency: u64,
}

/// Input to the `db.suggest()` API.
#[derive(Debug, Clone)]
pub struct Suggest {
    /// Prefix to autocomplete. Empty string returns trending searches.
    pub prefix: String,
    /// Optional user for personalized suggestion ordering (reserved for future use).
    pub for_user: Option<EntityId>,
    /// Maximum number of suggestions to return (1..=50).
    pub limit: u32,
}

impl Suggest {
    /// Create a new suggest request with the given prefix.
    #[must_use]
    pub fn new(prefix: impl Into<String>) -> Self {
        Self {
            prefix: prefix.into(),
            for_user: None,
            limit: 10,
        }
    }

    /// Set the maximum number of suggestions (1..=50).
    #[must_use]
    pub const fn limit(mut self, n: u32) -> Self {
        self.limit = n;
        self
    }

    /// Set the user for personalized suggestion ordering.
    #[must_use]
    pub const fn for_user(mut self, id: EntityId) -> Self {
        self.for_user = Some(id);
        self
    }
}

/// Maximum number of distinct query strings tracked for trending.
///
/// Caps the `query_freq` map size. When the limit is reached, `record_query`
/// only increments existing entries — new entries are silently dropped. This
/// prevents unbounded memory growth from diverse user-supplied query strings.
const MAX_TRENDING_KEYS: usize = 10_000;

/// Index of title terms for prefix autocomplete.
///
/// Terms are extracted from the `"title"` metadata field: whitespace-split,
/// lowercased, deduplicated. The sorted `Vec<String>` enables O(log n) binary
/// search for prefix matching.
pub struct SuggestionIndex {
    /// Sorted, deduplicated term list.
    terms: RwLock<Vec<String>>,
    /// Query frequency counter for trending searches.
    query_freq: DashMap<String, AtomicU64>,
}

impl SuggestionIndex {
    /// Create a new empty suggestion index.
    #[must_use]
    pub fn new() -> Self {
        Self {
            terms: RwLock::new(Vec::new()),
            query_freq: DashMap::new(),
        }
    }

    /// Add or update terms extracted from an item's title.
    ///
    /// Extracts whitespace-split tokens from `title`, lowercases them, and
    /// merges into the sorted term list (maintains sort invariant, deduplicates).
    /// No-op if `title` is empty or all tokens are too short.
    pub fn index_title(&self, title: &str) {
        let tokens: Vec<String> = title
            .split_whitespace()
            .filter(|t| t.len() >= 2)
            .map(str::to_lowercase)
            .collect();

        if tokens.is_empty() {
            return;
        }

        let mut terms = self
            .terms
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        for token in tokens {
            // Binary search insertion (maintain sorted order).
            if let Err(pos) = terms.binary_search(&token) {
                terms.insert(pos, token);
            }
            // If Ok(_), already exists -- skip.
        }
    }

    /// Record a search query for trending tracking.
    ///
    /// Increments the frequency counter for the normalized query string.
    /// Normalization: trim, collapse internal whitespace, lowercase.
    ///
    /// When the map reaches `MAX_TRENDING_KEYS` entries, new keys are silently
    /// dropped — only existing keys are incremented. This bounds memory use.
    ///
    /// Uses `Relaxed` ordering because trending counts are inherently approximate
    /// -- exact consistency is not required.
    pub fn record_query(&self, query_text: &str) {
        let normalized: String = query_text
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
            .to_lowercase();
        if normalized.is_empty() {
            return;
        }
        // Fast path: entry already exists — just increment.
        if let Some(entry) = self.query_freq.get(&normalized) {
            entry.fetch_add(1, Ordering::Relaxed); // Approximate: Relaxed is correct, no sync needed
            return;
        }
        // Slow path: insert new entry only if below the cap.
        if self.query_freq.len() < MAX_TRENDING_KEYS {
            self.query_freq
                .entry(normalized)
                .or_insert_with(|| AtomicU64::new(0))
                .fetch_add(1, Ordering::Relaxed); // Approximate: Relaxed is correct, no sync needed
        }
    }

    /// Find terms matching the given prefix (case-insensitive).
    ///
    /// Uses binary search to find the start of the prefix range in the sorted
    /// term list, then collects consecutive matches. Returns at most `limit` terms.
    ///
    /// If `prefix` is empty, returns top-N trending queries instead.
    #[must_use]
    pub fn suggest(&self, prefix: &str, limit: usize) -> Vec<Suggestion> {
        if prefix.is_empty() {
            return self.trending_suggestions(limit);
        }

        let prefix_lower = prefix.to_lowercase();
        let terms = self
            .terms
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner);

        // Binary search for the first entry >= prefix.
        let start = terms.partition_point(|t| t.as_str() < prefix_lower.as_str());

        terms[start..]
            .iter()
            .take_while(|t| t.starts_with(&prefix_lower))
            .take(limit)
            .map(|t| Suggestion {
                text: t.clone(),
                frequency: self
                    .query_freq
                    .get(t.as_str())
                    .map_or(0, |v| v.load(Ordering::Relaxed)), // Approximate: Relaxed is correct, no sync needed
            })
            .collect()
    }

    /// Return top N trending search queries (by query frequency).
    fn trending_suggestions(&self, limit: usize) -> Vec<Suggestion> {
        let mut pairs: Vec<(String, u64)> = self
            .query_freq
            .iter()
            .map(|entry| (entry.key().clone(), entry.value().load(Ordering::Relaxed))) // Approximate: Relaxed is correct, no sync needed
            .collect();
        pairs.sort_by(|a, b| b.1.cmp(&a.1));
        pairs.truncate(limit);
        pairs
            .into_iter()
            .map(|(text, frequency)| Suggestion { text, frequency })
            .collect()
    }

    /// Evict low-frequency entries when the trending map is near the cap.
    ///
    /// Removes the bottom half of entries by frequency when `query_freq` exceeds
    /// 80% of `MAX_TRENDING_KEYS`. This creates headroom for new queries without
    /// discarding the most popular trending terms.
    ///
    /// This is a maintenance operation intended to be called periodically (e.g.,
    /// from a background thread). `record_query` caps insertions at
    /// `MAX_TRENDING_KEYS`, so this is only needed to refresh the set over time.
    pub fn evict_low_frequency(&self) {
        if self.query_freq.len() < MAX_TRENDING_KEYS * 4 / 5 {
            return;
        }
        // Collect all (key, frequency) pairs.
        let mut pairs: Vec<(String, u64)> = self
            .query_freq
            .iter()
            .map(|e| (e.key().clone(), e.value().load(Ordering::Relaxed))) // Approximate: Relaxed is correct
            .collect();
        // Sort ascending by frequency; keep the top half.
        pairs.sort_by_key(|&(_, f)| f);
        let remove_count = pairs.len() / 2;
        for (key, _) in pairs.into_iter().take(remove_count) {
            self.query_freq.remove(&key);
        }
    }
}

impl Default for SuggestionIndex {
    fn default() -> Self {
        Self::new()
    }
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    #[test]
    fn new_index_is_empty() {
        let idx = SuggestionIndex::new();
        assert!(idx.suggest("anything", 10).is_empty());
    }

    #[test]
    fn index_title_and_suggest_prefix() {
        let idx = SuggestionIndex::new();
        idx.index_title("Rust Programming Tutorial");

        let results = idx.suggest("rus", 10);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].text, "rust");

        let results = idx.suggest("pro", 10);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].text, "programming");
    }

    #[test]
    fn index_title_deduplicates() {
        let idx = SuggestionIndex::new();
        idx.index_title("Rust Rust Rust");

        let results = idx.suggest("rus", 10);
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn index_title_filters_short_tokens() {
        let idx = SuggestionIndex::new();
        idx.index_title("I am a Rustacean");

        // "I", "a" are too short (< 2 chars).
        let results = idx.suggest("i", 10);
        assert!(results.is_empty());

        // "am" is exactly 2 chars.
        let results = idx.suggest("am", 10);
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn suggest_case_insensitive() {
        let idx = SuggestionIndex::new();
        idx.index_title("Rust TUTORIAL");

        let results = idx.suggest("RUS", 10);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].text, "rust");
    }

    #[test]
    fn suggest_with_limit() {
        let idx = SuggestionIndex::new();
        idx.index_title("alpha beta gamma delta");

        let results = idx.suggest("", 2);
        assert!(results.len() <= 2);

        // All 4 terms start with different prefixes; requesting "a" gives 1.
        let results = idx.suggest("a", 10);
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn suggest_unknown_prefix_returns_empty() {
        let idx = SuggestionIndex::new();
        idx.index_title("Rust Tutorial");

        let results = idx.suggest("zzz", 10);
        assert!(results.is_empty());
    }

    #[test]
    fn record_query_and_trending() {
        let idx = SuggestionIndex::new();
        idx.record_query("rust tutorial");
        idx.record_query("rust tutorial");
        idx.record_query("rust async");

        let trending = idx.suggest("", 10);
        assert_eq!(trending.len(), 2);
        assert_eq!(trending[0].text, "rust tutorial");
        assert_eq!(trending[0].frequency, 2);
        assert_eq!(trending[1].text, "rust async");
        assert_eq!(trending[1].frequency, 1);
    }

    #[test]
    fn record_query_normalizes_whitespace_and_case() {
        let idx = SuggestionIndex::new();
        idx.record_query("  Rust Tutorial  "); // leading/trailing whitespace
        idx.record_query("rust tutorial");

        let trending = idx.suggest("", 10);
        assert_eq!(trending.len(), 1);
        assert_eq!(trending[0].frequency, 2);
    }

    #[test]
    fn record_query_empty_is_noop() {
        let idx = SuggestionIndex::new();
        idx.record_query("");
        idx.record_query("  ");

        let trending = idx.suggest("", 10);
        assert!(trending.is_empty());
    }

    #[test]
    fn record_query_caps_at_max_trending_keys() {
        let idx = SuggestionIndex::new();
        // Fill map to MAX_TRENDING_KEYS.
        for i in 0..MAX_TRENDING_KEYS {
            idx.record_query(&format!("query_{i}"));
        }
        assert_eq!(idx.query_freq.len(), MAX_TRENDING_KEYS);
        // New unique key should be silently dropped.
        idx.record_query("brand_new_unique_query");
        assert_eq!(idx.query_freq.len(), MAX_TRENDING_KEYS);
    }

    #[test]
    fn evict_low_frequency_reduces_map_size() {
        let idx = SuggestionIndex::new();
        // Fill above 80% threshold.
        let threshold = MAX_TRENDING_KEYS * 4 / 5;
        for i in 0..=threshold {
            // Give different frequencies so eviction has something to sort.
            for _ in 0..=(i % 10) {
                idx.record_query(&format!("query_{i}"));
            }
        }
        let before = idx.query_freq.len();
        idx.evict_low_frequency();
        let after = idx.query_freq.len();
        assert!(after < before, "eviction should reduce map size");
    }

    #[test]
    fn record_query_normalizes_internal_whitespace() {
        let idx = SuggestionIndex::new();
        idx.record_query("rust  tutorial"); // double space
        idx.record_query("rust tutorial"); // single space
        let trending = idx.suggest("", 10);
        assert_eq!(trending.len(), 1, "internal whitespace should be collapsed");
        assert_eq!(trending[0].frequency, 2);
    }

    #[test]
    fn suggest_includes_frequency_for_prefix_matches() {
        let idx = SuggestionIndex::new();
        idx.index_title("rust programming");
        idx.record_query("rust");
        idx.record_query("rust");

        let results = idx.suggest("rus", 10);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].text, "rust");
        assert_eq!(results[0].frequency, 2);
    }

    #[test]
    fn suggest_request_builder() {
        let req = Suggest::new("jazz").limit(5);
        assert_eq!(req.prefix, "jazz");
        assert_eq!(req.limit, 5);
        assert!(req.for_user.is_none());

        let req2 = Suggest::new("").for_user(EntityId::new(42)).limit(20);
        assert_eq!(req2.for_user, Some(EntityId::new(42)));
        assert_eq!(req2.limit, 20);
    }

    #[test]
    fn multiple_titles_merge_terms() {
        let idx = SuggestionIndex::new();
        idx.index_title("Rust Tutorial");
        idx.index_title("Rust Async Programming");

        let results = idx.suggest("rus", 10);
        assert_eq!(results.len(), 1); // "rust" deduplicated

        let results = idx.suggest("a", 10);
        assert_eq!(results.len(), 1); // "async"

        let results = idx.suggest("t", 10);
        assert_eq!(results.len(), 1); // "tutorial"

        let results = idx.suggest("p", 10);
        assert_eq!(results.len(), 1); // "programming"
    }

    #[test]
    fn default_trait() {
        let idx = SuggestionIndex::default();
        assert!(idx.suggest("x", 10).is_empty());
    }
}
