//! SEARCH query AST, builder, and response types.
//!
//! Contains the data structures for constructing and representing SEARCH queries
//! and their results. No execution logic lives here -- see `executor.rs` for the
//! 8-stage pipeline.

use std::collections::HashMap;

use crate::entities::CollectionId;
use crate::query::retrieve::{Cursor, ProfileRef, QueryError, Signal};
use crate::query::stats::QueryStats;
use crate::ranking::diversity::DiversityConstraints;
use crate::schema::{EntityId, EntityKind};
use crate::session::{SessionId, SessionSnapshot};
use crate::storage::indexes::filter::FilterExpr;

// ── WithinScope ──────────────────────────────────────────────────────────────

/// Scope constraint for SEARCH queries.
///
/// `ScopeResolver` (Stage 0) converts this into a `RoaringBitmap` that
/// restricts both BM25 and ANN retrieval to the candidate set.
#[derive(Debug, Clone)]
pub enum WithinScope {
    /// Items whose view+share velocity is above the p75 percentile in the window.
    ///
    /// `window_hours` maps to the nearest discrete `Window` variant
    /// (1h, 24h, 7d, `AllTime`). A value of `0` is treated as `1`.
    Trending { window_hours: u64 },
    /// Items in a named cohort above the p75 velocity threshold.
    ///
    /// Returns `QueryError::NotFound` if the cohort is not registered.
    /// `window_hours` is subject to the same rounding as `Trending`.
    CohortTrending { cohort: String, window_hours: u64 },
    /// Items from creators the querying user follows (requires `for_user`).
    Following,
    /// Items matching the given category name.
    Category { name: String },
    /// Items in the specified collection.
    Collection { id: CollectionId },
}

// ── Search ───────────────────────────────────────────────────────────────────

/// The typed AST for a SEARCH query.
///
/// Captures the full query intent: text or vector input (or both for hybrid),
/// optional personalization, filters, diversity, and pagination. Constructed
/// via `SearchBuilder`.
#[derive(Debug, Clone)]
pub struct Search {
    /// Which entity kind to search (usually `Item`).
    pub entity_kind: EntityKind,
    /// Optional free-text query string for BM25 retrieval.
    pub query_text: Option<String>,
    /// Optional embedding vector for ANN retrieval.
    pub query_vector: Option<Vec<f32>>,
    /// User ID for personalized ranking.
    pub for_user: Option<u64>,
    /// Session ID for FOR SESSION ranking boost.
    pub for_session: Option<SessionId>,
    /// Which ranking profile to apply for signal-based re-ranking.
    pub profile: ProfileRef,
    /// Zero or more filter expressions, AND-combined.
    pub filters: Vec<FilterExpr>,
    /// Optional diversity constraints.
    pub diversity: Option<DiversityConstraints>,
    /// Maximum number of results to return (`1..=1000`).
    pub limit: u32,
    /// Entity IDs to exclude from results (e.g., already-seen items).
    pub exclude: Vec<EntityId>,
    /// Opaque pagination cursor.
    pub cursor: Option<Cursor>,
    /// Entity ID to use as the vector query source ("creators like X").
    /// When set, `TidalDb::search()` reads this entity's stored embedding
    /// and populates `query_vector` before executing.
    pub similar_to: Option<EntityId>,
    /// Optional scope pre-filter applied before BM25+ANN retrieval.
    ///
    /// When set, `ScopeResolver` converts this into a `RoaringBitmap` that
    /// restricts the candidate set before both text and vector retrieval.
    pub within_scope: Option<WithinScope>,
}

impl Search {
    /// Create a `SearchBuilder` with sensible defaults.
    ///
    /// Default: `entity_kind = Item`, `profile = "search"`, `limit = 20`.
    #[must_use]
    pub fn builder() -> SearchBuilder {
        SearchBuilder::new()
    }

    /// Combine all filter expressions into a single `FilterExpr`.
    ///
    /// - Zero filters: `None`
    /// - One filter: returns it directly
    /// - Multiple filters: wraps in `FilterExpr::And`
    #[must_use]
    pub fn combined_filter(&self) -> Option<FilterExpr> {
        match self.filters.len() {
            0 => None,
            1 => Some(self.filters[0].clone()),
            _ => Some(FilterExpr::And(self.filters.clone())),
        }
    }
}

// ── SearchBuilder ────────────────────────────────────────────────────────────

/// Builder for `Search` queries.
///
/// Provides a fluent API for constructing SEARCH queries. At least one of
/// `query()` (text) or `vector()` (embedding) must be set before calling
/// `build()`.
///
/// # Examples
///
/// ```
/// use tidaldb::query::search::{Search, SearchBuilder, WithinScope};
///
/// let search = Search::builder()
///     .query("Rust tutorial")
///     .within(WithinScope::Trending { window_hours: 24 })
///     .limit(20)
///     .build()
///     .unwrap();
/// assert!(search.query_text.is_some());
/// assert!(search.within_scope.is_some());
/// ```
pub struct SearchBuilder {
    entity_kind: EntityKind,
    query_text: Option<String>,
    query_vector: Option<Vec<f32>>,
    for_user: Option<u64>,
    for_session: Option<SessionId>,
    profile: ProfileRef,
    filters: Vec<FilterExpr>,
    diversity: Option<DiversityConstraints>,
    limit: u32,
    exclude: Vec<EntityId>,
    cursor: Option<Cursor>,
    similar_to: Option<EntityId>,
    within_scope: Option<WithinScope>,
}

impl SearchBuilder {
    /// Create a new builder with defaults.
    ///
    /// Default: `entity_kind = Item`, `profile = "search"`, `limit = 20`.
    #[must_use]
    pub fn new() -> Self {
        Self {
            entity_kind: EntityKind::Item,
            query_text: None,
            query_vector: None,
            for_user: None,
            for_session: None,
            profile: ProfileRef::new("search"),
            filters: Vec::new(),
            diversity: None,
            limit: 20,
            exclude: Vec::new(),
            cursor: None,
            similar_to: None,
            within_scope: None,
        }
    }

    /// Set the entity kind to search. Default is `Item`.
    #[must_use]
    pub const fn entity_kind(mut self, kind: crate::schema::EntityKind) -> Self {
        self.entity_kind = kind;
        self
    }

    /// Set the free-text query string for BM25 retrieval.
    #[must_use]
    pub fn query(mut self, text: impl Into<String>) -> Self {
        self.query_text = Some(text.into());
        self
    }

    /// Set the embedding vector for ANN retrieval.
    #[must_use]
    pub fn vector(mut self, vec: impl Into<Vec<f32>>) -> Self {
        self.query_vector = Some(vec.into());
        self
    }

    /// Set the user ID for personalized ranking.
    #[must_use]
    pub const fn for_user(mut self, user_id: u64) -> Self {
        self.for_user = Some(user_id);
        self
    }

    /// Apply FOR SESSION ranking boost using the given session ID.
    #[must_use]
    pub const fn for_session(mut self, session_id: SessionId) -> Self {
        self.for_session = Some(session_id);
        self
    }

    /// Set the ranking profile by name.
    #[must_use]
    pub fn using_profile(mut self, name: impl Into<String>) -> Self {
        self.profile = ProfileRef::new(name);
        self
    }

    /// Add a filter expression. Multiple filters are AND-combined.
    #[must_use]
    pub fn filter(mut self, expr: FilterExpr) -> Self {
        self.filters.push(expr);
        self
    }

    /// Set diversity constraints.
    #[must_use]
    pub const fn diversity(mut self, constraints: DiversityConstraints) -> Self {
        self.diversity = Some(constraints);
        self
    }

    /// Set the maximum number of results. Must be in `[1, 1000]`.
    #[must_use]
    pub const fn limit(mut self, n: u32) -> Self {
        self.limit = n;
        self
    }

    /// Exclude entity IDs from results.
    #[must_use]
    pub fn exclude(mut self, ids: Vec<EntityId>) -> Self {
        self.exclude = ids;
        self
    }

    /// Set a pagination cursor from a previous response.
    #[must_use]
    pub const fn cursor(mut self, c: Cursor) -> Self {
        self.cursor = Some(c);
        self
    }

    /// Set "similar to" entity -- reads the entity's stored embedding and
    /// uses it as the query vector. For creator search, this resolves via
    /// `read_creator_embedding(id)` in `TidalDb::search()`.
    #[must_use]
    pub const fn similar_to(mut self, id: EntityId) -> Self {
        self.similar_to = Some(id);
        self
    }

    /// Restrict the search candidate set to a specific scope before retrieval.
    ///
    /// The scope is resolved into a `RoaringBitmap` in Stage 0 of the search
    /// pipeline and applied as a pre-filter to both BM25 and ANN results.
    #[must_use]
    pub fn within(mut self, scope: WithinScope) -> Self {
        self.within_scope = Some(scope);
        self
    }

    /// Build the `Search` query.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidFilter` if neither `query_text` nor
    /// `query_vector` is set. Returns `QueryError::InvalidLimit` if `limit`
    /// is 0 or greater than 1000.
    pub fn build(self) -> Result<Search, QueryError> {
        if self.query_text.is_none() && self.query_vector.is_none() && self.similar_to.is_none() {
            return Err(QueryError::InvalidFilter {
                field: "query".into(),
                reason: "must provide at least one of query_text, query_vector, or similar_to"
                    .into(),
            });
        }
        #[allow(clippy::cast_possible_truncation)]
        if self.limit == 0 || self.limit > 1000 {
            return Err(QueryError::InvalidLimit {
                requested: self.limit as usize,
                min: 1,
                max: 1000,
            });
        }
        Ok(Search {
            entity_kind: self.entity_kind,
            query_text: self.query_text,
            query_vector: self.query_vector,
            for_user: self.for_user,
            for_session: self.for_session,
            profile: self.profile,
            filters: self.filters,
            diversity: self.diversity,
            limit: self.limit,
            exclude: self.exclude,
            cursor: self.cursor,
            similar_to: self.similar_to,
            within_scope: self.within_scope,
        })
    }
}

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

// ── SearchResults ────────────────────────────────────────────────────────────

/// The response from executing a SEARCH query.
#[derive(Debug)]
pub struct SearchResults {
    /// Ranked results in descending score order.
    pub items: Vec<SearchResultItem>,
    /// Cursor for fetching the next page. `None` if no more results.
    pub next_cursor: Option<Cursor>,
    /// Total candidates that matched filters before limit/diversity enforcement.
    pub total_candidates: usize,
    /// Whether all diversity constraints were satisfied.
    pub constraints_satisfied: bool,
    /// Warnings generated during execution (e.g., relaxed constraints).
    pub warnings: Vec<String>,
    /// Session snapshot at query time (populated when `for_session` is set).
    pub session_snapshot: Option<SessionSnapshot>,
    /// The degradation level under which this search was executed.
    pub degradation_level: crate::load::DegradationLevel,
    /// Per-query execution statistics for operational visibility.
    ///
    /// Populated by the executor pipeline. Contains timing, candidate
    /// counts, and the profile name used for scoring.
    pub stats: QueryStats,
}

impl SearchResults {
    /// Number of results returned.
    #[must_use]
    pub const fn len(&self) -> usize {
        self.items.len()
    }

    /// Whether the result set is empty.
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
}

// ── SearchResultItem ─────────────────────────────────────────────────────────

/// A single ranked result from a SEARCH query.
///
/// Includes the entity ID, profile score, 1-based rank, plus BM25 and
/// semantic scores from the retrieval stage for explainability.
#[derive(Debug, Clone)]
pub struct SearchResultItem {
    /// The entity that was ranked.
    pub entity_id: EntityId,
    /// Normalized profile score in `[0.0, 1.0]`.
    pub score: f64,
    /// 1-based rank within the full result set.
    pub rank: usize,
    /// BM25 text relevance score (present when `query_text` matched this entity).
    pub bm25_score: Option<f32>,
    /// ANN L2 distance (present when `query_vector` was set; lower = more similar).
    pub semantic_score: Option<f32>,
    /// Signal values that contributed to the profile score, for explainability.
    pub signals: Vec<Signal>,
    /// Entity metadata (populated for creator results; `None` for item results by default).
    pub metadata: Option<HashMap<String, String>>,
}

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