//! `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};
use crate::entities::{CollectionIndex, CreatorItemsBitmap, UserStateIndex};
use crate::query::retrieve::QueryError;
use crate::query::search::types::WithinScope;
use crate::schema::Window;
use crate::signals::SignalLedger;
use crate::storage::indexes::bitmap::BitmapIndex;

/// 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.
    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.
        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);
            *entity_velocity.entry(entity_id.as_u64()).or_insert(0) += 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());
        }

        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 != 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);
            *entity_velocity.entry(entity_id.as_u64()).or_insert(0) += 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
    }
}

/// 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.
    //
    // SAFETY NOTE: `RoaringBitmap` uses u32 keys. This database is single-node
    // and designed for content platforms where entity counts will not exceed
    // u32::MAX (4.29 billion). The debug_assert catches violations in testing.
    let mut bitmap = RoaringBitmap::new();
    for &(id, vel) in &sorted {
        if vel >= cutoff_velocity && vel > 0 {
            debug_assert!(
                u32::try_from(id).is_ok(),
                "entity ID {id} exceeds u32::MAX; RoaringBitmap key truncation would produce incorrect scope results"
            );
            #[allow(clippy::cast_possible_truncation)]
            bitmap.insert(id as u32);
        }
    }
    bitmap
}

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