//! 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,
    atomic::{AtomicU64, AtomicUsize, Ordering},
};

use dashmap::{DashMap, mapref::entry::Entry};

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.
    ///
    /// When set, it is forwarded to [`SuggestionIndex::suggest`] and biases the
    /// tie-break among equal-frequency trending suggestions (different users see a
    /// stable-but-slightly-varied order). It does not change which suggestions
    /// appear — only the relative order of ties — and has no effect on prefix
    /// (non-trending) suggestions, which are ordered by the sorted term list.
    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>,
    /// Distinct-key count for `query_freq`, used to enforce the
    /// `MAX_TRENDING_KEYS` cap atomically.
    ///
    /// `DashMap::len()` is racy against concurrent inserts on other shards, so a
    /// plain `len() < cap` check-then-insert can let several threads each pass
    /// the check and overshoot the cap. This counter is reserved via a CAS
    /// *before* the insert is committed, making "is there room?" and "insert"
    /// a single atomic decision. It is kept in lockstep with the map: the only
    /// inserts go through [`SuggestionIndex::record_query`] and the only removals
    /// through [`SuggestionIndex::evict_low_frequency`], both of which adjust it.
    key_count: AtomicUsize,
}

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

    /// 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;
        }
        // `entry` holds the shard lock for `normalized`, so the Occupied/Vacant
        // decision and the insert are atomic *for this key*. To make the GLOBAL
        // cap atomic too, a vacant key reserves a slot via CAS on `key_count`
        // BEFORE committing the insert: if the reservation would exceed
        // `MAX_TRENDING_KEYS`, the key is dropped without inserting. This closes
        // the check-then-insert race where two threads both saw `len() < cap`
        // and both inserted, overshooting the cap.
        match self.query_freq.entry(normalized) {
            Entry::Occupied(occupied) => {
                // Approximate: Relaxed is correct, no cross-thread sync needed.
                occupied.get().fetch_add(1, Ordering::Relaxed);
            }
            Entry::Vacant(vacant) => {
                if Self::reserve_key_slot(&self.key_count) {
                    vacant.insert(AtomicU64::new(1));
                }
                // Cap reached: drop the vacant entry without inserting.
            }
        }
    }

    /// Atomically reserve one slot under the `MAX_TRENDING_KEYS` cap.
    ///
    /// Returns `true` and increments `key_count` iff there was room; returns
    /// `false` without mutating on a full map. The CAS loop guarantees no two
    /// threads can both reserve the last slot.
    fn reserve_key_slot(key_count: &AtomicUsize) -> bool {
        let mut current = key_count.load(Ordering::Relaxed);
        loop {
            if current >= MAX_TRENDING_KEYS {
                return false;
            }
            match key_count.compare_exchange_weak(
                current,
                current + 1,
                Ordering::Relaxed,
                Ordering::Relaxed,
            ) {
                Ok(_) => return true,
                Err(observed) => current = observed,
            }
        }
    }

    /// 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. The optional
    /// `for_user` biases the tie-break among equal-frequency trending terms so
    /// different users see a stable-but-slightly-varied ordering (the
    /// "personalized suggestion ordering" the [`Suggest::for_user`] field
    /// documents); it does not change which terms appear, only the order of ties.
    #[must_use]
    pub fn suggest(
        &self,
        prefix: &str,
        limit: usize,
        for_user: Option<EntityId>,
    ) -> Vec<Suggestion> {
        if prefix.is_empty() {
            return self.trending_suggestions(limit, for_user);
        }

        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).
    ///
    /// Ties on frequency are broken DETERMINISTICALLY rather than by `DashMap`
    /// iteration order (which is process-randomized), so the returned top-N is
    /// stable across runs for the same data (the same determinism guard applied
    /// to RRF fusion, W14). When `for_user` is set, the tie-break is seeded by the
    /// user id so different users see a stable-but-slightly-varied ordering among
    /// equal-frequency terms — the per-user ordering the [`Suggest::for_user`]
    /// field documents. Without `for_user` the tie-break is a plain ascending sort
    /// of the term string.
    fn trending_suggestions(&self, limit: usize, for_user: Option<EntityId>) -> 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();
        let seed = for_user.map_or(0, EntityId::as_u64);
        pairs.sort_by(|a, b| {
            b.1.cmp(&a.1)
                .then_with(|| Self::tie_break_key(&a.0, seed).cmp(&Self::tie_break_key(&b.0, seed)))
        });
        pairs.truncate(limit);
        pairs
            .into_iter()
            .map(|(text, frequency)| Suggestion { text, frequency })
            .collect()
    }

    /// Deterministic tie-break key for a trending term under an optional per-user
    /// `seed`. With `seed == 0` (no user) it is the term bytes themselves, giving
    /// a plain ascending tie order; with a user seed it is a stable hash of
    /// `(seed, term)`, so equal-frequency terms order consistently per user but
    /// differ between users. Either way the key is a pure function of its inputs,
    /// so the order is reproducible across process runs.
    fn tie_break_key(term: &str, seed: u64) -> u64 {
        use std::hash::{Hash, Hasher};
        if seed == 0 {
            // No user: a stable hash of the term alone yields a deterministic,
            // user-agnostic tie order (the bytes themselves would also work, but a
            // single u64 key keeps the comparator uniform with the seeded path).
            let mut h = std::collections::hash_map::DefaultHasher::new();
            term.hash(&mut h);
            return h.finish();
        }
        let mut h = std::collections::hash_map::DefaultHasher::new();
        seed.hash(&mut h);
        term.hash(&mut h);
        h.finish()
    }

    /// 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, breaking ties DETERMINISTICALLY on a stable
        // hash of the term rather than on `DashMap` iteration order (which is
        // process-randomized) — so WHICH equal-frequency terms get evicted is
        // reproducible across runs rather than depending on the map seed (W14).
        pairs.sort_by(|a, b| {
            a.1.cmp(&b.1)
                .then_with(|| Self::tie_break_key(&a.0, 0).cmp(&Self::tie_break_key(&b.0, 0)))
        });
        let remove_count = pairs.len() / 2;
        for (key, _) in pairs.into_iter().take(remove_count) {
            // Keep `key_count` in lockstep with the map: decrement only when a
            // key was actually present and removed, so a concurrent
            // `record_query` removing/inserting the same key cannot double-count.
            if self.query_freq.remove(&key).is_some() {
                self.key_count.fetch_sub(1, Ordering::Relaxed);
            }
        }
    }
}

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, None).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, None);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].text, "rust");

        let results = idx.suggest("pro", 10, None);
        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, None);
        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, None);
        assert!(results.is_empty());

        // "am" is exactly 2 chars.
        let results = idx.suggest("am", 10, None);
        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, None);
        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, None);
        assert!(results.len() <= 2);

        // All 4 terms start with different prefixes; requesting "a" gives 1.
        let results = idx.suggest("a", 10, None);
        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, None);
        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, None);
        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, None);
        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, None);
        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);
        // The reserved-slot counter must track the map exactly.
        assert_eq!(idx.key_count.load(Ordering::Relaxed), 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);
        assert_eq!(idx.key_count.load(Ordering::Relaxed), MAX_TRENDING_KEYS);
    }

    /// Accuracy-S regression: the cap must hold under concurrent inserts. The
    /// previous `len() < cap` check-then-insert let multiple threads each pass
    /// the check and overshoot `MAX_TRENDING_KEYS`. With the atomic
    /// reserve-then-insert path, the distinct-key count can NEVER exceed the cap,
    /// no matter how many threads race.
    #[test]
    fn record_query_cap_holds_under_concurrency() {
        use std::{sync::Arc, thread};

        let idx = Arc::new(SuggestionIndex::new());
        // Pre-fill to one slot below the cap so every racing insert competes for
        // the SAME remaining headroom — the worst case for the old race.
        for i in 0..(MAX_TRENDING_KEYS - 1) {
            idx.record_query(&format!("seed_{i}"));
        }
        assert_eq!(idx.query_freq.len(), MAX_TRENDING_KEYS - 1);

        // Many threads each try to insert a DISTINCT brand-new key. At most ONE
        // may succeed (one slot left); all others must be rejected.
        let threads: Vec<_> = (0..32)
            .map(|t| {
                let idx = Arc::clone(&idx);
                thread::spawn(move || {
                    idx.record_query(&format!("racer_{t}"));
                })
            })
            .collect();
        for h in threads {
            h.join().unwrap();
        }

        assert_eq!(
            idx.query_freq.len(),
            MAX_TRENDING_KEYS,
            "concurrent inserts must not exceed the cap"
        );
        assert_eq!(
            idx.key_count.load(Ordering::Relaxed),
            MAX_TRENDING_KEYS,
            "reserved-slot counter must equal the map size at the cap"
        );
    }

    #[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, None);
        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, None);
        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, None);
        assert_eq!(results.len(), 1); // "rust" deduplicated

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

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

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

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

    /// W14 regression: trending ties (equal frequency) must order
    /// DETERMINISTICALLY across runs, not by randomized `DashMap` iteration.
    /// Repeating the query against unchanged data must return identical order.
    #[test]
    fn trending_tie_order_is_deterministic() {
        let idx = SuggestionIndex::new();
        // Five distinct terms, each queried exactly once → all tie on frequency 1.
        for t in &["alpha", "bravo", "charlie", "delta", "echo"] {
            idx.record_query(t);
        }
        let first: Vec<String> = idx
            .suggest("", 10, None)
            .into_iter()
            .map(|s| s.text)
            .collect();
        let second: Vec<String> = idx
            .suggest("", 10, None)
            .into_iter()
            .map(|s| s.text)
            .collect();
        assert_eq!(
            first, second,
            "equal-frequency trending order must be deterministic across calls"
        );
        assert_eq!(first.len(), 5, "all tied terms returned");
    }

    /// SUGGESTION regression: `for_user` is actually CONSULTED — it biases the
    /// tie-break among equal-frequency terms, so two different users can see a
    /// different (but each-stable) tie order. The set of terms is unchanged.
    #[test]
    fn for_user_biases_trending_tie_order() {
        let idx = SuggestionIndex::new();
        // Many tied terms so the per-user permutation is very likely to differ.
        for t in &["aa", "bb", "cc", "dd", "ee", "ff", "gg", "hh"] {
            idx.record_query(t);
        }
        let global: Vec<String> = idx
            .suggest("", 8, None)
            .into_iter()
            .map(|s| s.text)
            .collect();
        let user_a: Vec<String> = idx
            .suggest("", 8, Some(EntityId::new(1)))
            .into_iter()
            .map(|s| s.text)
            .collect();
        let user_b: Vec<String> = idx
            .suggest("", 8, Some(EntityId::new(2)))
            .into_iter()
            .map(|s| s.text)
            .collect();

        // Same SET of terms regardless of user (only the tie order may differ).
        let set = |v: &[String]| {
            let mut s = v.to_vec();
            s.sort();
            s
        };
        assert_eq!(set(&global), set(&user_a));
        assert_eq!(set(&global), set(&user_b));

        // Each user's order is stable across repeated calls (deterministic seed).
        let user_a_again: Vec<String> = idx
            .suggest("", 8, Some(EntityId::new(1)))
            .into_iter()
            .map(|s| s.text)
            .collect();
        assert_eq!(user_a, user_a_again, "per-user tie order is deterministic");

        // The knob is actually CONSULTED: across a handful of user seeds the tie
        // order is not forced identical for everyone (a no-op `for_user` would
        // produce the same order for all users). Comparing several seeds makes the
        // assertion robust even though the `DefaultHasher` outcome is itself fixed.
        let orders: Vec<Vec<String>> = [1u64, 2, 3, 7, 42]
            .iter()
            .map(|&u| {
                idx.suggest("", 8, Some(EntityId::new(u)))
                    .into_iter()
                    .map(|s| s.text)
                    .collect()
            })
            .collect();
        assert!(
            orders.iter().any(|o| *o != global),
            "for_user must influence the tie order for at least some user; \
             a silently-ignored knob would yield the global order for everyone"
        );
        let _ = (user_a, user_b);
    }
}
