//! `ScopeResolver`: converts `WithinScope` into a `RoaringBitmap` pre-filter.
//!
//! Each variant is resolved independently using its appropriate data source.
//! The returned bitmap is used as a pre-filter for both BM25 and ANN retrieval.

use std::collections::HashMap;

use roaring::RoaringBitmap;

use crate::{
    cohort::{CohortRegistry, CohortSignalLedger},
    entities::{CollectionIndex, CreatorItemsBitmap, UserStateIndex},
    query::{retrieve::QueryError, search::types::WithinScope},
    schema::{Timestamp, Window},
    signals::SignalLedger,
    storage::indexes::bitmap::BitmapIndex,
};

/// Maximum number of distinct entities tracked while computing a trending scope.
///
/// `resolve_trending` / `resolve_cohort_trending` scan the *entire* signal ledger
/// once per query — there is no candidate set to bound the work, so cost is
/// O(ledger entries) in time and, without this cap, O(distinct view/share
/// entities) in transient memory for the velocity map. On a large catalog with
/// millions of distinct items that map could balloon per query. The cap bounds
/// it: once `MAX_TRENDING_ENTITIES` distinct entities have been seen, the scan
/// only increments velocity for entities already in the map and stops inserting
/// new ones. This mirrors the `MAX_TRENDING_KEYS` policy in `suggest.rs`. The
/// `50_000` ceiling comfortably exceeds the top-percentile set any realistic
/// trending query returns while keeping a pathological ledger from blowing up
/// query latency or memory.
const MAX_TRENDING_ENTITIES: usize = 50_000;

/// Resolves a `WithinScope` into a `RoaringBitmap` of matching item IDs.
///
/// Each variant is resolved independently using its appropriate data source.
/// The returned bitmap is used as a pre-filter for both BM25 and ANN retrieval.
pub struct ScopeResolver<'a> {
    ledger: &'a SignalLedger,
    cohort_ledger: Option<&'a CohortSignalLedger>,
    cohort_registry: Option<&'a CohortRegistry>,
    user_state: Option<&'a UserStateIndex>,
    creator_items: Option<&'a CreatorItemsBitmap>,
    category_index: Option<&'a BitmapIndex>,
    collection_index: Option<&'a CollectionIndex>,
    /// Full universe bitmap (all entity IDs).
    universe: RoaringBitmap,
}

impl<'a> ScopeResolver<'a> {
    /// Create a new resolver with the core signal ledger and universe bitmap.
    #[must_use]
    #[allow(clippy::missing_const_for_fn)] // RoaringBitmap field prevents const
    pub fn new(ledger: &'a SignalLedger, universe: RoaringBitmap) -> Self {
        Self {
            ledger,
            cohort_ledger: None,
            cohort_registry: None,
            user_state: None,
            creator_items: None,
            category_index: None,
            collection_index: None,
            universe,
        }
    }

    /// Attach cohort ledger and registry for `CohortTrending` resolution.
    #[must_use]
    pub const fn with_cohort(
        mut self,
        ledger: &'a CohortSignalLedger,
        registry: &'a CohortRegistry,
    ) -> Self {
        self.cohort_ledger = Some(ledger);
        self.cohort_registry = Some(registry);
        self
    }

    /// Attach user state and creator-items bitmap for `Following` resolution.
    #[must_use]
    pub const fn with_user_context(
        mut self,
        user_state: &'a UserStateIndex,
        creator_items: &'a CreatorItemsBitmap,
    ) -> Self {
        self.user_state = Some(user_state);
        self.creator_items = Some(creator_items);
        self
    }

    /// Attach the category bitmap index for `Category` resolution.
    #[must_use]
    pub const fn with_category_index(mut self, idx: &'a BitmapIndex) -> Self {
        self.category_index = Some(idx);
        self
    }

    /// Attach the collection index for `Collection` resolution.
    #[must_use]
    pub const fn with_collection_index(mut self, idx: &'a CollectionIndex) -> Self {
        self.collection_index = Some(idx);
        self
    }

    /// Resolve a scope to a `RoaringBitmap`.
    ///
    /// # Errors
    ///
    /// - `QueryError::NotFound` if `CohortTrending` names an unknown cohort.
    /// - `QueryError::InvalidFilter` if `Following` is used without `for_user`.
    pub fn resolve(
        &self,
        scope: &WithinScope,
        for_user: Option<u64>,
    ) -> Result<RoaringBitmap, QueryError> {
        match scope {
            WithinScope::Trending { window_hours } => Ok(self.resolve_trending(*window_hours)),
            WithinScope::CohortTrending {
                cohort,
                window_hours,
            } => self.resolve_cohort_trending(cohort, *window_hours),
            WithinScope::Following => self.resolve_following(for_user),
            WithinScope::Category { name } => Ok(self.resolve_category(name)),
            WithinScope::Collection { id } => Ok(self.resolve_collection(*id)),
        }
    }

    /// Resolve `Trending`: items with view+share velocity above p75 in the window.
    ///
    /// Iterates all entities in the global `SignalLedger`, summing windowed counts
    /// for "view" and "share" signals. Keeps the top 75th percentile (entities
    /// with velocity above the p25 value). Returns the full universe if no
    /// velocity data exists to avoid starving the query.
    ///
    /// # Cost
    ///
    /// This is an O(ledger entries) full scan per query — there is no candidate
    /// set to bound it, so a trending query is proportional to the total number
    /// of `(entity, signal_type)` pairs in the ledger, not to the result size.
    /// The transient velocity map is bounded by [`MAX_TRENDING_ENTITIES`] so a
    /// pathologically large catalog cannot blow up per-query memory.
    fn resolve_trending(&self, window_hours: u64) -> RoaringBitmap {
        let window = window_to_schema_window(window_hours);

        // Resolve signal type IDs for "view" and "share".
        let view_id = self.ledger.resolve_signal_type("view").ok();
        let share_id = self.ledger.resolve_signal_type("share").ok();

        if view_id.is_none() && share_id.is_none() {
            // No relevant signal types in schema -- return full universe.
            return self.universe.clone();
        }

        // Accumulate per-entity velocity from the ledger entries. Read the clock
        // once for the whole scan and pass it to every windowed read so a viral
        // item that has gone quiet ages out of the trending set instead of
        // reporting its peak velocity forever (CRITICAL #6).
        let now_ns = Timestamp::now().as_nanos();
        let mut entity_velocity: HashMap<u64, u64> = HashMap::new();

        for entry in self.ledger.entries() {
            let (entity_id, type_id) = entry.key();
            let is_view = view_id.is_some_and(|id| *type_id == id);
            let is_share = share_id.is_some_and(|id| *type_id == id);
            if !is_view && !is_share {
                continue;
            }
            let count = entry.value().warm.windowed_count(window, now_ns);
            accumulate_velocity(&mut entity_velocity, entity_id.as_u64(), count);
        }

        if entity_velocity.is_empty() {
            tracing::debug!(
                scope = "Trending",
                "no velocity data in window; returning full universe"
            );
            return self.universe.clone();
        }

        percentile_bitmap(&entity_velocity)
    }

    /// Resolve `CohortTrending`: top-velocity items scoped to a specific cohort.
    fn resolve_cohort_trending(
        &self,
        cohort: &str,
        window_hours: u64,
    ) -> Result<RoaringBitmap, QueryError> {
        let registry = self.cohort_registry.ok_or_else(|| {
            QueryError::NotFound(format!("cohort: {cohort} (no cohort registry available)"))
        })?;

        // Verify the cohort exists.
        if registry.get(cohort).is_none() {
            return Err(QueryError::NotFound(format!("cohort: {cohort}")));
        }

        let Some(cohort_ledger) = self.cohort_ledger else {
            // Cohort is registered but the ledger was not attached to the resolver.
            // Return an error rather than silently ignoring the scope constraint.
            return Err(QueryError::NotFound(format!(
                "cohort: {cohort} (cohort ledger not available)"
            )));
        };

        let window = window_to_schema_window(window_hours);

        let view_id = cohort_ledger.resolve_signal_type("view").ok();
        let share_id = cohort_ledger.resolve_signal_type("share").ok();

        if view_id.is_none() && share_id.is_none() {
            return Ok(self.universe.clone());
        }

        // Read the clock once for the whole scan and pass it to every windowed
        // read so quiet cohort items age out of CohortTrending (CRITICAL #6).
        let now_ns = Timestamp::now().as_nanos();
        let mut entity_velocity: HashMap<u64, u64> = HashMap::new();

        for entry in cohort_ledger.iter_entries() {
            let (ref entry_cohort, entity_id, type_id) = *entry.key();
            if entry_cohort.as_ref() != cohort {
                continue;
            }
            let is_view = view_id.is_some_and(|id| type_id == id);
            let is_share = share_id.is_some_and(|id| type_id == id);
            if !is_view && !is_share {
                continue;
            }
            let count = entry.value().warm.windowed_count(window, now_ns);
            accumulate_velocity(&mut entity_velocity, entity_id.as_u64(), count);
        }

        if entity_velocity.is_empty() {
            tracing::debug!(
                cohort = cohort,
                scope = "CohortTrending",
                "no cohort velocity data in window; returning full universe"
            );
            return Ok(self.universe.clone());
        }

        Ok(percentile_bitmap(&entity_velocity))
    }

    /// Resolve `Following`: items from creators the querying user follows.
    fn resolve_following(&self, for_user: Option<u64>) -> Result<RoaringBitmap, QueryError> {
        let user_id = for_user.ok_or_else(|| QueryError::InvalidFilter {
            field: "within".into(),
            reason: "Following scope requires for_user".into(),
        })?;

        let Some(user_state) = self.user_state else {
            return Ok(RoaringBitmap::new());
        };
        let Some(creator_items) = self.creator_items else {
            return Ok(RoaringBitmap::new());
        };

        let followed = user_state.followed_creators_vec(user_id);
        Ok(creator_items.union_for(&followed))
    }

    /// Resolve `Category`: items matching the category bitmap index.
    ///
    /// Returns an empty bitmap when the index is unavailable (not yet built) or
    /// when no items are tagged with `name`. Logs a warning when the index is
    /// absent so that silent-zero results are visible in diagnostics.
    fn resolve_category(&self, name: &str) -> RoaringBitmap {
        self.category_index.map_or_else(
            || {
                tracing::warn!(
                    category = %name,
                    "Category scope requested but category_index is unavailable; returning empty bitmap"
                );
                RoaringBitmap::new()
            },
            |idx| idx.get(name).unwrap_or_default(),
        )
    }

    /// Resolve `Collection`: items in the specified collection's bitmap.
    fn resolve_collection(&self, id: crate::entities::CollectionId) -> RoaringBitmap {
        self.collection_index
            .and_then(|idx| idx.bitmap(id))
            .unwrap_or_default()
    }
}

/// Map a `window_hours` value to the closest `Window` enum variant.
///
/// The `BucketedCounter` works with discrete `Window` variants, not arbitrary
/// durations. We route to the best match:
/// - <= 1h -> `OneHour`
/// - <= 24h -> `TwentyFourHours`
/// - <= 168h -> `SevenDays`
/// - > 168h -> `AllTime`
const fn window_to_schema_window(window_hours: u64) -> Window {
    if window_hours <= 1 {
        Window::OneHour
    } else if window_hours <= 24 {
        Window::TwentyFourHours
    } else if window_hours <= 168 {
        Window::SevenDays
    } else {
        Window::AllTime
    }
}

/// Add `count` to an entity's trending velocity, enforcing [`MAX_TRENDING_ENTITIES`].
///
/// Existing entities are always updated, so an entity already being tracked keeps
/// accumulating its true velocity. Only *new* entities are dropped once the map
/// is at capacity — the same bounded-insert policy `suggest.rs::record_query`
/// uses. This keeps a single trending query's transient memory bounded even when
/// the ledger holds millions of distinct items.
fn accumulate_velocity(entity_velocity: &mut HashMap<u64, u64>, entity_id: u64, count: u64) {
    if let Some(vel) = entity_velocity.get_mut(&entity_id) {
        *vel += count;
    } else if entity_velocity.len() < MAX_TRENDING_ENTITIES {
        entity_velocity.insert(entity_id, count);
    }
}

/// Build a bitmap of entity IDs that are in the top 75th percentile by velocity.
///
/// Sorts entities by velocity descending and keeps those at or above the 25th
/// percentile velocity value (i.e., roughly the top 75% by count).
/// Any entity with `vel > 0` that falls at or above the p25 cutoff is included.
///
/// # Percentile calculation
///
/// The 25th percentile index in an ascending sort is `(n - 1) * 25 / 100`
/// (using the "floor" / Type 1 interpolation method). For small datasets
/// (n < 4) this always resolves to index 0 = the minimum velocity, so all
/// entities with `vel > 0` are kept — intentionally avoiding over-filtering
/// on thin data. For large datasets (n ≥ 100) the cutoff converges to the
/// genuine 25th percentile velocity value, keeping roughly the top 75%.
fn percentile_bitmap(entity_velocity: &HashMap<u64, u64>) -> RoaringBitmap {
    let mut sorted: Vec<(u64, u64)> = entity_velocity
        .iter()
        .map(|(&id, &vel)| (id, vel))
        .collect();
    sorted.sort_by(|a, b| b.1.cmp(&a.1)); // descending

    // Compute the p25 velocity cutoff using ascending-sort index `(n-1)*25/100`.
    // Translate to descending-sort index: `(n-1) - (n-1)*25/100`.
    // This means for small n (< 4): ascending index = 0 → descending index = n-1
    // (the minimum velocity), so all positive-velocity items are kept.
    let n = sorted.len();
    let p25_asc_idx = (n.saturating_sub(1)) * 25 / 100;
    let p25_desc_idx = n.saturating_sub(1).saturating_sub(p25_asc_idx);
    let cutoff_velocity = sorted.get(p25_desc_idx).map_or(0, |&(_, v)| v);

    // Keep entities with velocity >= cutoff (top ~75%) and vel > 0.
    //
    // `RoaringBitmap` uses u32 keys but `EntityId` wraps an arbitrary
    // caller-supplied u64 with no u32 cap. A bare `id as u32` truncates a high id
    // onto a LOW slot in *release* builds (the debug_assert is compiled out), so
    // the trending scope would silently admit whatever low-id item the high id
    // aliased onto and drop the real one — a wrong-results leak with no
    // diagnostic. Skipping a non-representable id is the correct polarity for an
    // inclusion bitmap: a > u32::MAX id cannot be a member, so excluding it is
    // sound and can never alias onto another item. This matches the checked
    // `u32::try_from` both consumers (BM25 retain, ANN filtered_search) already
    // use. (CRITICAL fix.)
    let mut bitmap = RoaringBitmap::new();
    for &(id, vel) in &sorted {
        if vel >= cutoff_velocity && vel > 0 {
            match u32::try_from(id) {
                Ok(key) => {
                    bitmap.insert(key);
                }
                Err(_) => {
                    tracing::warn!(
                        entity_id = id,
                        "trending scope: entity id exceeds the u32 bitmap key space; skipped \
                         (cannot be a scope member without aliasing onto another item)"
                    );
                }
            }
        }
    }
    bitmap
}

// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests;

#[cfg(test)]
mod velocity_cap_tests {
    use std::collections::HashMap;

    use super::{MAX_TRENDING_ENTITIES, accumulate_velocity};

    /// `accumulate_velocity` always updates an already-tracked entity but stops
    /// inserting NEW entities once the map hits `MAX_TRENDING_ENTITIES`, bounding a
    /// single trending query's transient memory on a pathologically large ledger.
    #[test]
    fn accumulate_velocity_caps_distinct_entities_but_keeps_updating_existing() {
        let cap = u64::try_from(MAX_TRENDING_ENTITIES).expect("cap fits u64");
        let mut map: HashMap<u64, u64> = HashMap::new();

        // Fill exactly to capacity with distinct entities.
        for id in 0..cap {
            accumulate_velocity(&mut map, id, 1);
        }
        assert_eq!(map.len(), MAX_TRENDING_ENTITIES);

        // A NEW entity past the cap is dropped (no unbounded growth).
        accumulate_velocity(&mut map, cap, 5);
        assert_eq!(map.len(), MAX_TRENDING_ENTITIES);
        assert!(!map.contains_key(&cap));

        // An EXISTING entity still accumulates even at capacity.
        accumulate_velocity(&mut map, 0, 9);
        assert_eq!(map.get(&0), Some(&10));
        assert_eq!(map.len(), MAX_TRENDING_ENTITIES);
    }
}
