//! Execute stage — the 8-stage SEARCH pipeline.
//!
//! [`SearchExecutor::execute`] orchestrates the pipeline by delegating each
//! stage to a focused, independently-readable helper:
//!
//! - [`SearchExecutor::retrieve_bm25`] — Stage 1a text retrieval.
//! - [`SearchExecutor::retrieve_ann`] — Stage 1b vector retrieval.
//! - [`SearchExecutor::apply_metadata_filter`] — Stage 2 index/predicate filter.
//! - [`SearchExecutor::apply_user_context_filter`] — Stage 2.5 suppression + inclusion.
//! - [`SearchExecutor::score_candidates`] — Stage 3 profile scoring (relevance-seeded).
//!
//! Stage 1c (fusion) is intentionally inline in `execute` because it is the
//! join point where the retrieved lists become the candidate set and the
//! normalized relevance map that seeds scoring.

use std::{
    collections::{HashMap, HashSet},
    time::Instant,
};

use roaring::RoaringBitmap;

use super::{
    super::{
        executor_helpers::{
            build_user_context, evaluate_metadata_filter, has_creator_metadata_filter,
        },
        scope::ScopeResolver,
        types::{Search, SearchResultItem, SearchResults},
    },
    ANN_K_CEILING_REDUCED, ANN_K_FLOOR, ANN_K_FLOOR_REDUCED, ANN_K_MULTIPLIER_FULL,
    ANN_K_MULTIPLIER_REDUCED, BM25_CAP_FLOOR_REDUCED, BM25_CAP_MULTIPLIER_REDUCED, SearchExecutor,
};
use crate::{
    db::deserialize_metadata as deserialize_item_metadata,
    query::{
        fusion::{
            HybridFusion, RetrievalMode, ann_to_ranked, normalize_fused_scores, route_results,
        },
        retrieve::{Cursor, QueryError, Signal},
        stats::QueryStats,
    },
    ranking::{diversity::DiversitySelector, executor::ProfileExecutor},
    schema::{EntityId, EntityKind, Timestamp},
    storage::{
        Tag, encode_key,
        indexes::{
            bitmap::BitmapIndex,
            filter::{FilterEvaluator, FilterExpr, FilterResult},
            range::RangeIndex,
        },
    },
    text::collectors::BoundedScoresCollector,
};

/// Canonical default embedding-slot name.
///
/// Used when neither the schema nor the executor threads an explicit item slot
/// name. Centralizes the literal so the `"content"` default is declared once
/// instead of being duplicated at each `unwrap_or("content")` call site.
const DEFAULT_EMBEDDING_SLOT: &str = "content";

/// Full-fidelity BM25 over-fetch multiplier: the bounded collector keeps at most
/// `limit * this` documents (floored at [`BM25_TOP_K_FLOOR`]). Mirrors
/// [`ANN_K_MULTIPLIER_FULL`] so the text and vector candidate pools stay
/// comparable before fusion.
const BM25_TOP_K_MULTIPLIER: usize = 20;

/// Minimum BM25 top-K regardless of `limit`, so tiny-limit queries still seed a
/// usable text candidate pool. Matches the spec's `top_k_text` default
/// (06-text-retrieval.md Sec 11.2) and [`ANN_K_FLOOR`].
const BM25_TOP_K_FLOOR: usize = 200;

/// Convert a 64-bit entity id to the 32-bit key space used by every
/// `RoaringBitmap` in the suppression/inclusion path.
///
/// Returns `None` when the id does not fit in `u32`. Bitmaps only ever hold
/// 32-bit ids, so an out-of-range id can never be a member — but a naive
/// `id as u32` truncation would *alias* a high id onto a low one and could
/// either leak a suppressed item or wrongly drop a legitimate one. Callers must
/// treat `None` per the filter's polarity (excluded ids overflow → keep,
/// included ids overflow → drop); the shared suppression stage
/// [`crate::query::executor::apply_user_context_suppression`] applies exactly
/// that polarity.
#[inline]
fn entity_as_u32(id: EntityId) -> Option<u32> {
    u32::try_from(id.as_u64()).ok()
}

impl SearchExecutor<'_> {
    /// Execute a SEARCH query through the 8-stage pipeline.
    ///
    /// # Errors
    ///
    /// Returns `QueryError` on validation failure, missing index, profile not
    /// found, or storage error during BM25/ANN retrieval.
    #[allow(clippy::too_many_lines)] // the 8-stage SEARCH pipeline reads best linearly
    pub fn execute(&self, query: &Search) -> Result<SearchResults, QueryError> {
        let query_start = Instant::now();
        let mut stats = QueryStats::new(query.profile.name.clone());
        stats.degradation_level = self.degradation_level as u8;

        let mode =
            RetrievalMode::determine(query.query_text.is_some(), query.query_vector.is_some())
                .ok_or_else(|| QueryError::InvalidFilter {
                    field: "query".into(),
                    reason: "must provide at least one of query_text or query_vector".into(),
                })?;

        let profile = self.resolve_profile(query)?;

        // Compute the combined filter tree ONCE for the whole pipeline. Each
        // stage helper borrows it (`Option<&FilterExpr>`) instead of calling
        // `query.combined_filter()` again — that method deep-clones the filter
        // Vec (and allocates a fresh `FilterExpr::And` for >1 filter), so the
        // previous five independent calls did up to five full tree clones per
        // query on the hot path.
        let combined_filter = query.combined_filter();
        let combined_filter = combined_filter.as_ref();

        // Reject filter shapes with no sound semantics before any stage runs:
        // negated deferred post-filters (would return the inverse set) and any
        // deferred post-filter under an OR (would be silently evaluated as AND —
        // see `reject_or_of_deferred_filters`). Shares the RETRIEVE-path
        // validators so both query surfaces fail identically.
        if let Some(filter_expr) = combined_filter {
            crate::query::executor::user_filter::reject_negated_deferred_filters(filter_expr)?;
            crate::query::executor::user_filter::reject_or_of_deferred_filters(filter_expr)?;
        }

        let mut warnings: Vec<String> = Vec::new();

        // ── Stage 0: Scope Pre-Filter ─────────────────────────────────────
        let scope_bitmap = self.resolve_scope(query, &mut warnings)?;

        // ── Stage 1a: Text Retrieval (BM25) ────────────────────────────────
        let mut bm25_results = self.retrieve_bm25(query, &mut warnings)?;

        // ── Stage 1b: ANN Retrieval ─────────────────────────────────────────
        let ann_results = self.retrieve_ann(query, scope_bitmap.as_ref(), &mut warnings)?;

        // Apply scope pre-filter to BM25 results (BM25 has no predicate callback API).
        // ANN results are already scope-filtered via filtered_search.
        if let Some(ref bitmap) = scope_bitmap {
            bm25_results.retain(|(id, _)| entity_as_u32(*id).is_some_and(|i| bitmap.contains(i)));
        }

        // M7p2: ReducedCandidates — truncate BM25 to cap downstream scoring work.
        if self.degradation_level.reduces_candidates() {
            let bm25_cap =
                (query.limit as usize * BM25_CAP_MULTIPLIER_REDUCED).max(BM25_CAP_FLOOR_REDUCED);
            bm25_results.truncate(bm25_cap);
        }

        // Build per-entity score maps for explainability in Stage 5.
        let bm25_map: HashMap<u64, f32> = bm25_results
            .iter()
            .map(|(id, s)| (id.as_u64(), *s))
            .collect();
        let ann_map: HashMap<u64, f32> = ann_results
            .iter()
            .map(|(id, d)| (id.as_u64(), *d))
            .collect();

        // ── Stage 1c: Fusion ────────────────────────────────────────────────
        // `route_results` returns a fully-ordered relevance list. We keep BOTH
        // the candidate ID set (for filtering/scoring) AND a normalized relevance
        // map keyed by entity id, which seeds the ranking executor's base term so
        // the fused order anchors the final ranking instead of being discarded.
        let fusion = HybridFusion::default();
        let fused = route_results(mode, &bm25_results, &ann_results, &fusion);
        let retrieval_scores = normalize_fused_scores(&fused);
        let mut candidates: Vec<EntityId> = fused.iter().map(|(id, _)| *id).collect();

        // Apply exclude list.
        if !query.exclude.is_empty() {
            let exclude_set: HashSet<u64> = query.exclude.iter().map(|id| id.as_u64()).collect();
            candidates.retain(|id| !exclude_set.contains(&id.as_u64()));
        }

        stats.candidates_considered = candidates.len();

        tracing::trace!(
            bm25_count = bm25_results.len(),
            ann_count = ann_results.len(),
            fused_count = candidates.len(),
            mode = ?mode,
            "search stage 1: retrieval complete"
        );

        // ── Stage 2 + 2b: Metadata Filters ──────────────────────────────────
        self.apply_metadata_filter(
            query.entity_kind,
            combined_filter,
            &mut candidates,
            &mut warnings,
        );
        self.apply_creator_metadata_filter(query, combined_filter, &mut candidates);

        // ── Stage 2.2/2.3/2.4: Deferred Post-Filters ────────────────────────
        self.apply_deferred_post_filters(combined_filter, &mut candidates)?;

        // ── Stage 2.5: User-Context Filtering ────────────────────────────────
        self.apply_user_context_filter(query, combined_filter, &mut candidates);

        stats.candidates_after_filter = candidates.len();
        stats.filters_applied = query.filters.len();

        // ── Stage 3: Profile Scoring ─────────────────────────────────────────
        let scoring_start = Instant::now();
        let now = Timestamp::now();
        let scored = self.score_candidates(query, profile, &candidates, &retrieval_scores, now)?;
        let total_scored = scored.len();
        stats.scoring_time_us = scoring_start.elapsed().as_micros() as u64;

        tracing::trace!(scored = scored.len(), "search stage 3: scored");

        // ── Stage 4: Diversity Enforcement ───────────────────────────────────
        let diversity_start = Instant::now();
        let (final_candidates, constraints_satisfied) = if self.degradation_level.skips_diversity()
        {
            warnings.push("diversity enforcement skipped due to load degradation".to_string());
            (scored, true)
        } else if let Some(ref diversity) = query.diversity {
            let result = DiversitySelector::select(&scored, diversity, scored.len());
            let satisfied = result.constraints_satisfied;
            if !satisfied {
                for v in &result.violations {
                    warnings.push(format!(
                        "diversity constraint '{}' relaxed: {}",
                        v.constraint, v.detail
                    ));
                }
            }
            (result.selected, satisfied)
        } else {
            (scored, true)
        };

        stats.diversity_time_us = diversity_start.elapsed().as_micros() as u64;
        stats.candidates_after_diversity = final_candidates.len();

        tracing::trace!(
            final_count = final_candidates.len(),
            "search stage 4: diversity enforced"
        );

        // ── Stage 5: Result Assembly ──────────────────────────────────────────
        self.assemble_results(
            query,
            &final_candidates,
            &bm25_map,
            &ann_map,
            total_scored,
            constraints_satisfied,
            warnings,
            query_start,
            stats,
        )
    }

    /// Resolve the ranking profile referenced by the query (Stage 1 setup).
    fn resolve_profile<'p>(
        &'p self,
        query: &Search,
    ) -> Result<&'p crate::ranking::profile::RankingProfile, QueryError> {
        #[allow(clippy::option_if_let_else)]
        match query.profile.version {
            Some(v) => self
                .profile_registry
                .get_version(&query.profile.name, v)
                .map_err(|_| QueryError::ProfileNotFound(query.profile.name.clone())),
            None => self
                .profile_registry
                .get(&query.profile.name)
                .map_err(|_| QueryError::ProfileNotFound(query.profile.name.clone())),
        }
    }

    /// Stage 0: resolve the optional scope pre-filter into a bitmap.
    ///
    /// On a poisoned universe lock we recover the last-known bitmap via
    /// [`std::sync::PoisonError::into_inner`] (the same poison-recovery pattern
    /// `suggest.rs` uses) and push a warning, rather than silently degrading to
    /// an EMPTY universe — which would make `WithinScope::Trending` resolve to
    /// nothing and return zero results with no diagnostic.
    fn resolve_scope(
        &self,
        query: &Search,
        warnings: &mut Vec<String>,
    ) -> Result<Option<RoaringBitmap>, QueryError> {
        let Some(ref scope) = query.within_scope else {
            return Ok(None);
        };
        let universe = self.universe.map_or_else(RoaringBitmap::new, |u| {
            u.read().map_or_else(
                |poisoned| {
                    warnings.push(
                        "universe lock poisoned; scope pre-filter recovered last-known universe"
                            .to_string(),
                    );
                    poisoned.into_inner().clone()
                },
                |g| g.clone(),
            )
        });

        let mut resolver = ScopeResolver::new(self.ledger, universe);
        if let (Some(cl), Some(cr)) = (self.cohort_ledger, self.cohort_registry) {
            resolver = resolver.with_cohort(cl, cr);
        }
        if let (Some(us), Some(ci)) = (self.user_state, self.creator_items) {
            resolver = resolver.with_user_context(us, ci);
        }
        if let Some(cat) = self.category_index {
            resolver = resolver.with_category_index(cat);
        }
        if let Some(coll) = self.collection_index {
            resolver = resolver.with_collection_index(coll);
        }
        Ok(Some(resolver.resolve(scope, query.for_user)?))
    }

    /// Stage 1a: BM25 text retrieval, sorted descending by score.
    fn retrieve_bm25(
        &self,
        query: &Search,
        warnings: &mut Vec<String>,
    ) -> Result<Vec<(EntityId, f32)>, QueryError> {
        let Some(ref query_text) = query.query_text else {
            return Ok(Vec::new());
        };
        // Route to creator or item text index based on entity_kind.
        let effective_text_index = match query.entity_kind {
            EntityKind::Creator => self.creator_text_index,
            _ => self.text_index,
        };
        let Some(idx) = effective_text_index else {
            warnings.push("text_index unavailable; text retrieval skipped".to_string());
            return Ok(Vec::new());
        };

        let parser = idx.query_parser();
        let tq = parser
            .parse(query_text)
            .map_err(|e| QueryError::StorageError(format!("text query parse: {e}")))?;
        let searcher = idx.searcher();
        // Bound the candidate set to the top-`bm25_cap` by score on the NORMAL
        // path too, not just under degradation (W: BM25 must not return the whole
        // match set). A broad/near-ubiquitous term over a large corpus would
        // otherwise materialize one `(EntityId, f32)` per match plus a downstream
        // `HashMap` — an allocation proportional to corpus match count rather than
        // the requested limit. The bounded collector keeps only the top
        // `bm25_cap` documents via a per-segment min-heap, capping peak memory
        // inside Tantivy. Under `ReducedCandidates` the existing post-fusion
        // truncate trims further.
        let bm25_cap = (query.limit as usize)
            .saturating_mul(BM25_TOP_K_MULTIPLIER)
            .max(BM25_TOP_K_FLOOR);
        let collector = BoundedScoresCollector::new(idx.fields().entity_id, bm25_cap);
        let mut raw = searcher
            .search(tq.as_ref(), &collector)
            .map_err(|e| QueryError::StorageError(format!("BM25 search: {e}")))?;
        // Sort descending by BM25 score (AllScoresCollector does not sort).
        //
        // `partial_cmp(...).unwrap_or(Equal)` treats every comparison against a
        // `NaN` score as "equal", which scrambles the order non-deterministically
        // when a NaN slips through (BM25 over degenerate term statistics can emit
        // one). Neutralize NaN to 0.0 — the same neutral value the ranking module
        // uses (`finite_score`) — then sort with `total_cmp`, a true total order
        // over all finite scores. Break score ties on ASCENDING entity id so the
        // ordering is a true total order even when two documents earn the same
        // BM25 score (Tantivy's doc-id traversal is not a stable secondary key
        // across segment merges / restarts); this is the same determinism guard
        // applied to RRF fusion (W14).
        let finite = |s: f32| if s.is_nan() { 0.0 } else { s };
        raw.sort_by(|a, b| {
            finite(b.1)
                .total_cmp(&finite(a.1))
                .then_with(|| a.0.as_u64().cmp(&b.0.as_u64()))
        });
        Ok(raw)
    }

    /// Stage 1b: ANN vector retrieval over the schema-resolved embedding slot.
    #[allow(clippy::significant_drop_tightening)] // registry read guard held across slot lookup + ANN search
    fn retrieve_ann(
        &self,
        query: &Search,
        scope_bitmap: Option<&RoaringBitmap>,
        warnings: &mut Vec<String>,
    ) -> Result<Vec<(EntityId, f32)>, QueryError> {
        let Some(ref query_vector) = query.query_vector else {
            return Ok(Vec::new());
        };
        let Some(registry_lock) = self.embedding_registry else {
            warnings.push("embedding_registry unavailable; ANN retrieval skipped".to_string());
            return Ok(Vec::new());
        };
        let registry = registry_lock.read().map_err(|_| {
            QueryError::StorageError("embedding registry lock poisoned".to_string())
        })?;

        // Resolve the embedding slot from the schema-threaded name, NOT a
        // hard-coded "content" literal. Writes register the slot under the
        // schema-resolved name (item: first Item embedding slot; creator:
        // "content"), so reading a different literal silently skipped ANN
        // retrieval for any non-"content" schema.
        let slot_name = self.embedding_slot_for(query.entity_kind);
        let Some(slot) = registry.get(query.entity_kind, slot_name) else {
            warnings.push(format!(
                "no '{slot_name}' embedding slot for {:?}; ANN retrieval skipped",
                query.entity_kind
            ));
            return Ok(Vec::new());
        };

        let k = if self.degradation_level.reduces_candidates() {
            (query.limit as usize * ANN_K_MULTIPLIER_REDUCED)
                .clamp(ANN_K_FLOOR_REDUCED, ANN_K_CEILING_REDUCED)
        } else {
            (query.limit as usize * ANN_K_MULTIPLIER_FULL).max(ANN_K_FLOOR)
        };

        // When a scope bitmap is set, use the predicate-filtered ANN search so
        // the filter is applied during graph traversal. `id as u32` here is
        // safe: a `VectorId` (u64) above u32::MAX cannot be present in a 32-bit
        // bitmap, so `try_from` failure correctly excludes it from scope.
        let raw = if let Some(bitmap) = scope_bitmap {
            slot.index
                .filtered_search(query_vector, k, slot.params.ef_search, &|id| {
                    u32::try_from(id).is_ok_and(|i| bitmap.contains(i))
                })
                .map_err(|e| QueryError::StorageError(format!("ANN filtered search: {e}")))?
        } else {
            slot.index
                .search(query_vector, k, slot.params.ef_search)
                .map_err(|e| QueryError::StorageError(format!("ANN search: {e}")))?
        };
        Ok(ann_to_ranked(&raw))
    }

    /// Stage 2: index/predicate metadata filter.
    ///
    /// The `FilterEvaluator` resolves deferred variants
    /// (`MinSignal`/`MaxSignal`/`NearLocation`/`InCollection`/user-state) to the
    /// *universe* and `evaluate_and` intersects every child against it. That is
    /// only sound when `self.universe` is a populated, trustworthy bitmap. When
    /// the universe is unavailable — not wired (`None`) or the read lock is
    /// poisoned — its clone is empty, so the intersection would silently drop
    /// EVERY candidate (including ones that should pass) before the Stage 2.2/2.5
    /// post-filters ever run. The actual deferred filtering happens in
    /// [`Self::apply_deferred_post_filters`], so the correct degraded behavior is
    /// to skip the index intersection entirely (retain all) and surface a warning
    /// rather than collapse to an empty result set.
    ///
    /// # Creator searches are item-only here (C-CRITICAL)
    ///
    /// The bitmap indexes (`category_index`/`format_index`/…) are ITEM-scoped:
    /// they hold item ids keyed by item metadata, always wired regardless of
    /// `entity_kind`. For a creator SEARCH the candidates are CREATOR ids, so
    /// intersecting them against the item indexes is a namespace error — in a
    /// creator-only DB the item category index is empty and EVERY creator is
    /// dropped; in a mixed DB only creators whose id happens to alias an item in
    /// that category survive. A CategoryEq/FormatEq on creators is instead
    /// resolved by [`Self::apply_creator_metadata_filter`] (Stage 2b) against the
    /// per-creator stored metadata. So this stage returns early (retains all) for
    /// `EntityKind::Creator`; the deferred variants are also no-ops for creators
    /// in Stage 2 and are handled correctly downstream, so the early return is
    /// safe for them too.
    #[allow(clippy::significant_drop_tightening)] // universe read guard held across the evaluator
    fn apply_metadata_filter(
        &self,
        entity_kind: EntityKind,
        combined_filter: Option<&FilterExpr>,
        candidates: &mut Vec<EntityId>,
        warnings: &mut Vec<String>,
    ) {
        // Creator candidates are never intersected against the item-scoped bitmap
        // indexes — see the doc above. Stage 2b applies any creator metadata
        // filter against the correct per-creator metadata instead.
        if entity_kind == EntityKind::Creator {
            return;
        }

        let Some(filter_expr) = combined_filter else {
            return;
        };

        // Resolve the universe up front, decoupled from the deferred-filter
        // sentinel: if it is missing or poisoned we must NOT run the bitmap
        // intersection, because every deferred variant resolves to that (empty)
        // universe and would drop all candidates. Fall through to the post-filter
        // stage instead.
        let Some(universe_lock) = self.universe else {
            return;
        };
        let Ok(universe_guard) = universe_lock.read() else {
            warnings.push(
                "universe lock poisoned; metadata index pre-filter degraded — relying on \
                 post-filters"
                    .to_string(),
            );
            return;
        };
        let universe_ref: &RoaringBitmap = &universe_guard;

        let empty_bitmap = BitmapIndex::new("_empty");
        let empty_dur = RangeIndex::<u32>::new("_empty");
        let empty_ts = RangeIndex::<u64>::new("_empty");

        let cat = self.category_index.unwrap_or(&empty_bitmap);
        let fmt = self.format_index.unwrap_or(&empty_bitmap);
        let cre = self.creator_index.unwrap_or(&empty_bitmap);
        let tag = self.tag_index.unwrap_or(&empty_bitmap);
        let dur = self.duration_index.unwrap_or(&empty_dur);
        let ts = self.created_at_index.unwrap_or(&empty_ts);

        let evaluator = FilterEvaluator::new(cat, fmt, cre, tag, dur, ts, universe_ref);
        match evaluator.evaluate(filter_expr) {
            FilterResult::Bitmap(bitmap) => {
                candidates.retain(|id| entity_as_u32(*id).is_some_and(|i| bitmap.contains(i)));
            }
            FilterResult::Predicate(pred) => {
                candidates.retain(|id| pred(id.as_u64()));
            }
        }

        tracing::trace!(
            candidates = candidates.len(),
            "search stage 2: filter applied"
        );
    }

    /// Stage 2b: creator metadata post-filter against stored creator metadata.
    fn apply_creator_metadata_filter(
        &self,
        query: &Search,
        combined_filter: Option<&FilterExpr>,
        candidates: &mut Vec<EntityId>,
    ) {
        if query.entity_kind != EntityKind::Creator || candidates.is_empty() {
            return;
        }
        let Some(combined) = combined_filter else {
            return;
        };
        if !has_creator_metadata_filter(combined) {
            return;
        }
        let Some(storage) = self.creators_storage else {
            return;
        };
        candidates.retain(|&id| {
            let key = encode_key(id, Tag::Meta, b"");
            let meta = storage
                .get(&key)
                .ok()
                .flatten()
                .map(|bytes| {
                    let (_emb, meta) = crate::entities::deserialize_entity(&bytes);
                    meta
                })
                .unwrap_or_default();
            evaluate_metadata_filter(combined, &meta)
        });

        tracing::trace!(
            candidates = candidates.len(),
            "search stage 2b: creator metadata filter applied"
        );
    }

    /// Stage 2.5: user-context filtering (suppression + inclusion).
    /// Apply the deferred post-filters (`InCollection`, `MinSignal`/`MaxSignal`,
    /// `NearLocation`) that evaluate to the full universe at the
    /// `FilterEvaluator` level and so must run as Stage 2.x post-filters. Shared
    /// verbatim with the RETRIEVE pipeline via
    /// `post_filter::apply_deferred_post_filters` — before SEARCH called this, it
    /// silently ignored these filters and returned items that violated them.
    ///
    /// # Errors
    ///
    /// Propagates `QueryError` from the shared applier (e.g. an unknown signal
    /// name referenced by a Min/MaxSignal threshold).
    fn apply_deferred_post_filters(
        &self,
        combined_filter: Option<&FilterExpr>,
        candidates: &mut Vec<EntityId>,
    ) -> Result<(), QueryError> {
        if let Some(filter_expr) = combined_filter {
            let ctx = crate::query::executor::post_filter::PostFilterCtx {
                ledger: self.ledger,
                collection_index: self.collection_index,
                items_storage: self.items_storage,
                degradation_level: self.degradation_level,
            };
            crate::query::executor::post_filter::apply_deferred_post_filters(
                filter_expr,
                candidates,
                &ctx,
            )?;
        }
        Ok(())
    }

    fn apply_user_context_filter(
        &self,
        query: &Search,
        combined_filter: Option<&FilterExpr>,
        candidates: &mut Vec<EntityId>,
    ) {
        let Some(user_id) = query.for_user else {
            return;
        };

        // Suppression + inclusion is shared verbatim with the RETRIEVE pipeline
        // via `crate::query::executor::apply_user_context_suppression`, so the two
        // surfaces cannot drift (a missing arm on one side silently leaks an item
        // the other suppresses). All `u32` conversions inside the helper go
        // through the same checked `try_from` this module's `entity_as_u32` uses:
        // a `>u32` id is retained against an exclusion bitmap and dropped against
        // an inclusion bitmap.
        crate::query::executor::apply_user_context_suppression(
            candidates,
            user_id,
            self.user_state,
            self.creator_items,
            self.hard_negatives,
            combined_filter,
        );

        tracing::trace!(
            candidates = candidates.len(),
            user_id,
            "search stage 2.5: user-context filter applied"
        );
    }

    /// Stage 3: profile scoring, seeding the base with normalized fused relevance.
    fn score_candidates(
        &self,
        query: &Search,
        profile: &crate::ranking::profile::RankingProfile,
        candidates: &[EntityId],
        retrieval_scores: &HashMap<u64, f64>,
        now: Timestamp,
    ) -> Result<Vec<crate::ranking::executor::ScoredCandidate>, QueryError> {
        let mut executor =
            ProfileExecutor::new(self.ledger).with_degradation_level(self.degradation_level);

        // Wire the Shuffle sort's per-user, per-minute seed (spec §11.6) so SEARCH
        // shuffle varies per user; without a FOR USER it stays a stable per-minute
        // global permutation.
        if matches!(profile.sort, Some(crate::ranking::profile::Sort::Shuffle))
            && let Some(user_id) = query.for_user
        {
            executor = executor.with_shuffle_user(user_id);
        }

        // Pre-load item metadata for keyword hint matching when session is active.
        let item_metadata: HashMap<u64, HashMap<String, String>> = if self.session_context.is_some()
        {
            self.items_storage.map_or_else(HashMap::new, |storage| {
                candidates
                    .iter()
                    .filter_map(|&eid| {
                        let key = encode_key(eid, Tag::Meta, b"");
                        storage
                            .get(&key)
                            .ok()
                            .flatten()
                            .map(|bytes| (eid.as_u64(), deserialize_item_metadata(&bytes)))
                    })
                    .collect()
            })
        } else {
            HashMap::new()
        };

        #[allow(clippy::option_if_let_else)]
        let scored = if let Some(user_id) = query.for_user {
            let mut user_ctx =
                build_user_context(self.interaction_ledger, self.creator_items, user_id, now);
            user_ctx.preference_boosts =
                crate::query::executor::personalization::compute_preference_boosts(
                    user_id,
                    candidates,
                    self.preference_vectors,
                    self.items_storage,
                    self.item_embedding_slot.unwrap_or(DEFAULT_EMBEDDING_SLOT),
                );
            executor.score_personalized(
                candidates,
                profile,
                now,
                self.session_context.as_ref(),
                &user_ctx,
                &item_metadata,
                Some(retrieval_scores),
            )
        } else {
            executor.score_with_session(
                candidates,
                profile,
                now,
                self.session_context.as_ref(),
                &item_metadata,
                Some(retrieval_scores),
            )
        }
        // A scoring error means the profile references a signal the schema does
        // not define, or an unimplemented aggregation -- surface it instead of
        // returning a silently-empty result set.
        .map_err(|e| QueryError::InvalidFilter {
            field: format!("profile '{}'", profile.name),
            reason: e.to_string(),
        })?;
        Ok(scored)
    }

    /// Stage 5: paginate, enrich, and build the final `SearchResults`.
    #[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)] // uniform stage-helper signature (siblings are fallible)
    fn assemble_results(
        &self,
        query: &Search,
        final_candidates: &[crate::ranking::executor::ScoredCandidate],
        bm25_map: &HashMap<u64, f32>,
        ann_map: &HashMap<u64, f32>,
        total_scored: usize,
        constraints_satisfied: bool,
        warnings: Vec<String>,
        query_start: Instant,
        mut stats: QueryStats,
    ) -> Result<SearchResults, QueryError> {
        // `Cursor::offset` is fallible: a keyset cursor used as an offset (or a
        // 32-bit-overflowing value) errors loudly rather than misreading silently.
        let offset = query.cursor.as_ref().map_or(Ok(0), Cursor::offset)?;
        let limit = query.limit as usize;
        // Guard against cursor-offset overflow from an attacker-supplied cursor:
        // `offset + limit` could wrap. `saturating_add` clamps to usize::MAX,
        // and the subsequent `.min(len)` keeps the slice in bounds.
        let end = offset.saturating_add(limit).min(final_candidates.len());
        let page = if offset < final_candidates.len() {
            &final_candidates[offset..end]
        } else {
            &[]
        };

        // Load creator metadata for result enrichment when searching creators.
        let creator_meta: HashMap<u64, HashMap<String, String>> =
            if query.entity_kind == EntityKind::Creator {
                self.creators_storage.map_or_else(HashMap::new, |storage| {
                    page.iter()
                        .filter_map(|c| {
                            let key = encode_key(c.entity_id, Tag::Meta, b"");
                            storage.get(&key).ok().flatten().map(|bytes| {
                                let (_emb, meta) = crate::entities::deserialize_entity(&bytes);
                                (c.entity_id.as_u64(), meta)
                            })
                        })
                        .collect()
                })
            } else {
                HashMap::new()
            };

        let items: Vec<SearchResultItem> = page
            .iter()
            .enumerate()
            .map(|(i, c)| {
                let signals: Vec<Signal> = c
                    .signal_snapshot
                    .iter()
                    .map(|(name, value)| Signal {
                        name: name.clone(),
                        value: *value,
                        source: "decay_score".to_string(),
                    })
                    .collect();

                SearchResultItem {
                    entity_id: c.entity_id,
                    score: c.score,
                    rank: offset + i + 1,
                    bm25_score: bm25_map.get(&c.entity_id.as_u64()).copied(),
                    semantic_score: ann_map.get(&c.entity_id.as_u64()).copied(),
                    signals,
                    metadata: creator_meta.get(&c.entity_id.as_u64()).cloned(),
                }
            })
            .collect();

        let next_cursor = if end < final_candidates.len() {
            Some(Cursor::from_offset(end))
        } else {
            None
        };

        tracing::trace!(
            returned = items.len(),
            has_next_cursor = next_cursor.is_some(),
            "search stage 5: results assembled"
        );

        stats.total_time_us = query_start.elapsed().as_micros() as u64;

        Ok(SearchResults {
            items,
            next_cursor,
            total_candidates: total_scored,
            constraints_satisfied,
            warnings,
            session_snapshot: self.session_snapshot.clone(),
            degradation_level: self.degradation_level,
            stats,
        })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::{sync::RwLock, time::Duration};

    use super::*;
    use crate::storage::vector::VectorIndex;
    use crate::{
        ranking::{builtins::register_builtins, registry::ProfileRegistry},
        schema::{DecaySpec, EntityKind, SchemaBuilder, Window},
        signals::{NoopWalWriter, SignalLedger},
        storage::vector::{
            BruteForceIndex, QuantizationLevel, VectorIndexConfig,
            registry::{EmbeddingSlotRegistry, EmbeddingSlotState, EmbeddingSource, HnswParams},
        },
    };

    fn test_schema() -> crate::schema::Schema {
        let mut builder = SchemaBuilder::new();
        for sig in &["view", "like"] {
            let _ = builder
                .signal(
                    sig,
                    EntityKind::Item,
                    DecaySpec::Exponential {
                        half_life: Duration::from_secs(7 * 24 * 3600),
                    },
                )
                .windows(&[Window::AllTime])
                .add();
        }
        builder.build().unwrap()
    }

    fn setup_registry() -> ProfileRegistry {
        let mut reg = ProfileRegistry::new();
        register_builtins(&mut reg).unwrap();
        reg
    }

    /// Build a registry holding a single 2-D Item slot under `slot_name`, with
    /// the given `(id, vector)` pairs inserted.
    fn registry_with_slot(slot_name: &str, vectors: &[(u64, [f32; 2])]) -> EmbeddingSlotRegistry {
        let mut registry = EmbeddingSlotRegistry::new();
        let config = VectorIndexConfig {
            dimensions: 2,
            ..VectorIndexConfig::default()
        };
        let index = BruteForceIndex::new(config);
        for (id, v) in vectors {
            index.insert(*id, v).unwrap();
        }
        let state = EmbeddingSlotState {
            index: Box::new(index),
            dimensions: 2,
            quantization: QuantizationLevel::F32,
            source: EmbeddingSource::External,
            params: HnswParams::default(),
        };
        registry
            .register(EntityKind::Item, slot_name.to_string(), state)
            .unwrap();
        registry
    }

    /// Construct a minimal vector-only `SearchExecutor` (no text index needed).
    fn vector_executor<'a>(
        ledger: &'a SignalLedger,
        profile_reg: &'a ProfileRegistry,
        registry: &'a RwLock<EmbeddingSlotRegistry>,
    ) -> SearchExecutor<'a> {
        SearchExecutor::new(
            ledger,
            profile_reg,
            None,           // text_index
            Some(registry), // embedding_registry
            None,           // category
            None,           // format
            None,           // creator
            None,           // tag
            None,           // duration
            None,           // created_at
            None,           // universe
        )
    }

    /// Like [`vector_executor`] but wires the `universe` bitmap. A FILTER clause
    /// makes Stage 2's `FilterEvaluator` return `self.universe` for deferred
    /// variants (InCollection/MinSignal/…), so the deferred-post-filter tests
    /// must populate the universe with the retrieved item ids or the metadata
    /// stage would intersect against an empty universe and drop everything.
    fn filtered_executor<'a>(
        ledger: &'a SignalLedger,
        profile_reg: &'a ProfileRegistry,
        registry: &'a RwLock<EmbeddingSlotRegistry>,
        universe: &'a RwLock<RoaringBitmap>,
    ) -> SearchExecutor<'a> {
        SearchExecutor::new(
            ledger,
            profile_reg,
            None,           // text_index
            Some(registry), // embedding_registry
            None,           // category
            None,           // format
            None,           // creator
            None,           // tag
            None,           // duration
            None,           // created_at
            Some(universe), // universe
        )
    }

    /// Universe bitmap holding the two ANN-retrieved item ids (1 and 2).
    fn two_item_universe() -> RwLock<RoaringBitmap> {
        let mut bm = RoaringBitmap::new();
        bm.insert(1);
        bm.insert(2);
        RwLock::new(bm)
    }

    /// CRITICAL regression: the top SEARCH result must be the most *relevant*
    /// item (closest vector), not the most-*liked* one. Before the fix the fused
    /// relevance score was discarded and ordering collapsed to signal popularity.
    #[test]
    fn top_result_is_most_relevant_not_most_liked() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();

        // Query vector is [1.0, 0.0]. Item 1 is an exact match (most relevant);
        // item 2 is far away (least relevant) but is heavily liked.
        let registry = RwLock::new(registry_with_slot(
            "content",
            &[(1, [1.0, 0.0]), (2, [0.0, 1.0])],
        ));

        // Item 2 gets ordinary like signals; item 1 gets nothing. The relevance
        // anchor (RELEVANCE_BASE_WEIGHT = 10) is designed so the fused order is
        // primary and a *light* profile boost only breaks near-ties — so a
        // realistic like count must NOT flip the exact vector match out of first.
        let now = Timestamp::now();
        ledger
            .record_signal("like", EntityId::new(2), 3.0, now)
            .unwrap();

        let exec = vector_executor(&ledger, &profile_reg, &registry);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(results.items.len(), 2, "both items retrieved");
        // The exact vector match (item 1) must rank first despite item 2's likes.
        assert_eq!(
            results.items[0].entity_id,
            EntityId::new(1),
            "most-relevant item must outrank the most-liked-but-irrelevant item"
        );
        assert_eq!(results.items[1].entity_id, EntityId::new(2));
    }

    /// CRITICAL regression: ANN retrieval must use the schema-resolved slot name,
    /// not a hard-coded "content" literal. An Item slot named "emb" must be
    /// searchable when the executor is threaded that slot name.
    #[test]
    fn vector_search_uses_threaded_non_content_slot() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();

        // Slot is named "emb", NOT "content".
        let registry = RwLock::new(registry_with_slot("emb", &[(7, [1.0, 0.0])]));

        let exec =
            vector_executor(&ledger, &profile_reg, &registry).with_item_embedding_slot("emb");
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            results.items.len(),
            1,
            "ANN must retrieve from the threaded 'emb' slot; got {} results, warnings: {:?}",
            results.items.len(),
            results.warnings
        );
        assert_eq!(results.items[0].entity_id, EntityId::new(7));
        assert!(
            results.warnings.is_empty(),
            "no 'ANN retrieval skipped' warning expected: {:?}",
            results.warnings
        );
    }

    /// The previous hard-coded "content" lookup against an "emb"-only registry
    /// silently skipped ANN retrieval and returned nothing. Proves the bug is
    /// fixed: leaving the slot at its "content" default while the registry only
    /// has "emb" yields the skip warning (and no results), confirming the lookup
    /// is driven by the threaded name rather than always succeeding.
    #[test]
    fn vector_search_skips_when_slot_name_mismatches() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = RwLock::new(registry_with_slot("emb", &[(7, [1.0, 0.0])]));

        // Do NOT thread "emb"; the executor defaults to "content".
        let exec = vector_executor(&ledger, &profile_reg, &registry);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert!(results.items.is_empty());
        assert!(
            results
                .warnings
                .iter()
                .any(|w| w.contains("content") && w.contains("ANN retrieval skipped")),
            "expected a slot-resolution skip warning, got {:?}",
            results.warnings
        );
    }

    /// Cursor-offset overflow must not panic: a near-`usize::MAX` offset
    /// saturates and yields an empty page rather than wrapping.
    #[test]
    fn huge_cursor_offset_does_not_overflow() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = RwLock::new(registry_with_slot("content", &[(1, [1.0, 0.0])]));
        let exec = vector_executor(&ledger, &profile_reg, &registry);

        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .limit(1000)
            .cursor(Cursor::from_offset(usize::MAX - 1))
            .build()
            .unwrap();

        // Must not panic on `offset + limit`.
        let results = exec.execute(&query).unwrap();
        assert!(results.items.is_empty());
        assert!(results.next_cursor.is_none());
    }

    // ── Deferred post-filter regression coverage ─────────────────────────────
    //
    // SEARCH previously applied only Saved/Liked/InProgress and silently ignored
    // MinSignal/MaxSignal/NearLocation/InCollection/SocialGraph: a well-formed
    // SEARCH with those filters returned items that VIOLATED the filter, with no
    // error. These tests prove SEARCH now EXCLUDES the violating items, exactly
    // like RETRIEVE. Each retrieves two equidistant items via the ANN path and
    // asserts the filter drops the one that does not satisfy it.

    // `EntityId`, `Timestamp`, `Tag`, `encode_key`, and `FilterExpr` are already
    // in scope via `use super::*`; only the entities and storage backend below
    // are new for these tests.
    use crate::{
        entities::{
            CollectionId, CollectionIndex, CreatorItemsBitmap, HardNegIndex, InteractionLedger,
            UserStateIndex,
        },
        storage::{StorageEngine, memory::InMemoryBackend},
    };

    /// Items-only length-prefixed metadata encoding that `db::deserialize_metadata`
    /// (used by the `NearLocation` post-filter) reads back. Mirrors
    /// `db::metadata::serialize_metadata`, which is not visible outside `db`.
    fn encode_meta(pairs: &[(&str, &str)]) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
        for (k, v) in pairs {
            buf.extend_from_slice(&(k.len() as u32).to_le_bytes());
            buf.extend_from_slice(k.as_bytes());
            buf.extend_from_slice(&(v.len() as u32).to_le_bytes());
            buf.extend_from_slice(v.as_bytes());
        }
        buf
    }

    /// Two equidistant items on the unit vector so the ANN path retrieves both;
    /// the deferred filter, not relevance, decides which survives.
    fn two_item_registry() -> RwLock<EmbeddingSlotRegistry> {
        RwLock::new(registry_with_slot(
            "content",
            &[(1, [1.0, 0.0]), (2, [1.0, 0.0])],
        ))
    }

    fn ids_of(results: &SearchResults) -> Vec<u64> {
        let mut v: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
        v.sort_unstable();
        v
    }

    #[test]
    fn search_min_signal_excludes_under_threshold() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // Item 1 has 5 likes (>= 3), item 2 has 1 like (< 3).
        let now = Timestamp::now();
        for _ in 0..5 {
            ledger
                .record_signal("like", EntityId::new(1), 1.0, now)
                .unwrap();
        }
        ledger
            .record_signal("like", EntityId::new(2), 1.0, now)
            .unwrap();

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::min_signal("like", 3.0))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "min_signal(like,3) must drop item 2 (1 like); before the fix both were returned"
        );
    }

    #[test]
    fn search_max_signal_excludes_over_threshold() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // Item 1 has 1 view (<= 2), item 2 has 5 views (> 2).
        let now = Timestamp::now();
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
        for _ in 0..5 {
            ledger
                .record_signal("view", EntityId::new(2), 1.0, now)
                .unwrap();
        }

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::max_signal("view", 2.0))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "max_signal(view,2) must drop item 2 (5 views); before the fix both were returned"
        );
    }

    #[test]
    fn search_unknown_signal_threshold_errors_loudly() {
        // An unknown signal name in a threshold must fail the query, never
        // silently retain or drop every candidate.
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::min_signal("not_a_signal", 1.0))
            .limit(10)
            .build()
            .unwrap();

        let err = exec.execute(&query).expect_err("unknown signal must error");
        assert!(matches!(err, QueryError::InvalidFilter { .. }));
    }

    #[test]
    fn search_near_location_excludes_out_of_radius() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // Item 1 in NYC, item 2 in LA.
        let storage = InMemoryBackend::new();
        let put_geo = |id: u64, lat: &str, lng: &str| {
            let key = encode_key(EntityId::new(id), Tag::Meta, b"");
            storage
                .put(&key, &encode_meta(&[("latitude", lat), ("longitude", lng)]))
                .unwrap();
        };
        put_geo(1, "40.7128", "-74.0060");
        put_geo(2, "34.0522", "-118.2437");

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe)
            .with_items_storage(&storage);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::near_location(40.7128, -74.0060, 50.0))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "near_location(NYC,50km) must drop item 2 (LA); before the fix both were returned"
        );
    }

    #[test]
    fn search_in_collection_excludes_outside_collection() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // Collection 7 contains item 1 only.
        let collections = CollectionIndex::new();
        let cid = CollectionId::new(7);
        collections.add_item(cid, 1);

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe)
            .with_collection_index(&collections);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::in_collection(cid))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "in_collection(7) must drop item 2 (not in collection); before the fix both returned"
        );
    }

    /// BLOCKER 5: SEARCH must reject an `OR` containing a deferred post-filter
    /// rather than silently collapsing `saved OR jazz` to `saved`. The guard is
    /// shared verbatim with the RETRIEVE pipeline
    /// (`user_filter::reject_or_of_deferred_filters`), so the two surfaces cannot
    /// diverge; this test proves it fires through the public `execute()`.
    #[test]
    fn search_or_of_deferred_filter_rejected() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();
        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);

        // `saved OR jazz` — previously degraded to `saved` (an intersection).
        let saved_or_jazz = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .for_user(42)
            .filter(FilterExpr::Or(vec![
                FilterExpr::Saved(42),
                FilterExpr::CategoryEq("jazz".into()),
            ]))
            .limit(10)
            .build()
            .unwrap();
        let err = exec
            .execute(&saved_or_jazz)
            .expect_err("OR over a deferred Saved filter must be rejected");
        assert!(matches!(err, QueryError::InvalidFilter { .. }));

        // OR-of-thresholds is the same trap and must error identically.
        let or_thresholds = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::Or(vec![
                FilterExpr::min_signal("view", 1.0),
                FilterExpr::min_signal("like", 1.0),
            ]))
            .limit(10)
            .build()
            .unwrap();
        let err = exec
            .execute(&or_thresholds)
            .expect_err("OR-of-thresholds must be rejected");
        assert!(matches!(err, QueryError::InvalidFilter { .. }));
    }

    /// A legitimate index-backed `Or` (no deferred variant) must still succeed
    /// through SEARCH — the guard rejects only deferred variants under `Or`.
    #[test]
    fn search_or_of_index_filters_allowed() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();
        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);

        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::Or(vec![
                FilterExpr::CategoryEq("jazz".into()),
                FilterExpr::CategoryEq("blues".into()),
            ]))
            .limit(10)
            .build()
            .unwrap();

        // No category index is wired, so the filter matches nothing, but the
        // query must SUCCEED (no rejection) — proving the guard does not fire on
        // a pure index-backed OR.
        assert!(exec.execute(&query).is_ok());
    }

    #[test]
    fn search_social_graph_scopes_to_followed_creator_items() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // User 42 follows creator 10; creator 10 owns item 1 only.
        let user_state = UserStateIndex::new();
        user_state.add_follow(42, 10);
        user_state.add_creator_follower(10, 42);
        let creator_items = CreatorItemsBitmap::new();
        creator_items.add_item(10, 1);
        let hard_neg = HardNegIndex::new();
        let interaction = InteractionLedger::new();

        let universe = two_item_universe();
        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe)
            .with_user_context(&user_state, &hard_neg, &interaction, &creator_items);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .for_user(42)
            .filter(FilterExpr::social_graph(42, 1))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "social_graph(42,1) must drop item 2 (not from a followed creator); before the fix \
             the SocialGraph arm was swallowed and both were returned"
        );
    }

    /// Accuracy-W regression: a deferred `MinSignal` filter with NO universe
    /// wired must still return candidates from retrieval and let the Stage 2.2
    /// post-filter trim them — not collapse to an empty set.
    ///
    /// Before the fix, `apply_metadata_filter` ran the `FilterEvaluator`, which
    /// resolves `MinSignal` to `self.universe`; with the universe unwired the
    /// clone was empty, so `evaluate_and` intersected against nothing and
    /// `candidates.retain` dropped EVERY candidate before the post-filter could
    /// run. Now the index pre-filter is skipped (retain all) when the universe is
    /// `None`, and the deferred post-filter does the real trimming.
    #[test]
    fn search_min_signal_with_no_universe_still_postfilters() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        // Two equidistant items so the ANN path retrieves both; the deferred
        // MinSignal filter (not relevance) decides which survives.
        let registry = RwLock::new(registry_with_slot(
            "content",
            &[(1, [1.0, 0.0]), (2, [1.0, 0.0])],
        ));

        // Item 1 has 5 likes (>= 3), item 2 has 1 like (< 3).
        let now = Timestamp::now();
        for _ in 0..5 {
            ledger
                .record_signal("like", EntityId::new(1), 1.0, now)
                .unwrap();
        }
        ledger
            .record_signal("like", EntityId::new(2), 1.0, now)
            .unwrap();

        // NOTE: `vector_executor` wires NO universe — the exact condition that
        // previously dropped everything.
        let exec = vector_executor(&ledger, &profile_reg, &registry);
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .filter(FilterExpr::min_signal("like", 3.0))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert_eq!(
            ids_of(&results),
            vec![1],
            "with no universe wired, min_signal(like,3) must keep item 1 and drop item 2 via the \
             post-filter; before the fix the empty-universe intersection dropped BOTH"
        );
    }

    /// C-CRITICAL regression (creator metadata filter): `apply_metadata_filter`
    /// must NOT intersect CREATOR candidates against the ITEM-scoped bitmap
    /// indexes. With `EntityKind::Creator` it retains all candidates (the creator
    /// path relies on Stage 2b); with `EntityKind::Item` the item index still
    /// applies. Before the fix, a creator-only DB dropped every creator because
    /// the item category index was empty / held a different namespace.
    #[test]
    fn creator_search_skips_item_index_intersection() {
        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = two_item_registry();

        // Item-scoped category index: item id 1 ∈ "jazz". Creator candidate id 1
        // would alias onto this slot if the item index were (wrongly) applied.
        let category = BitmapIndex::new("category");
        category.insert(1, "jazz");
        let universe = two_item_universe();

        // Build an executor wiring the item category index.
        let exec = SearchExecutor::new(
            &ledger,
            &profile_reg,
            None,            // text_index
            Some(&registry), // embedding_registry
            Some(&category), // category (ITEM-scoped)
            None,            // format
            None,            // creator
            None,            // tag
            None,            // duration
            None,            // created_at
            Some(&universe), // universe
        );

        // Creator candidates (ids 1 and 2). The filter asks for category "news",
        // which no ITEM is in — so an item-index intersection would drop BOTH (or
        // keep only the aliasing id 1). The creator guard must retain both and let
        // Stage 2b decide against per-creator metadata.
        let mut creator_candidates = vec![EntityId::new(1), EntityId::new(2)];
        let mut warnings = Vec::new();
        let filter = FilterExpr::CategoryEq("news".into());
        exec.apply_metadata_filter(
            EntityKind::Creator,
            Some(&filter),
            &mut creator_candidates,
            &mut warnings,
        );
        let mut ids: Vec<u64> = creator_candidates.iter().map(|e| e.as_u64()).collect();
        ids.sort_unstable();
        assert_eq!(
            ids,
            vec![1, 2],
            "creator candidates must NOT be intersected against the item category index; \
             both survive Stage 2 and Stage 2b resolves the real per-creator filter"
        );

        // Sanity: for ITEM candidates the same index DOES apply — only the jazz
        // item (id 1) survives a CategoryEq(jazz) filter; id 2 is dropped.
        let mut item_candidates = vec![EntityId::new(1), EntityId::new(2)];
        let mut warnings = Vec::new();
        let jazz = FilterExpr::CategoryEq("jazz".into());
        exec.apply_metadata_filter(
            EntityKind::Item,
            Some(&jazz),
            &mut item_candidates,
            &mut warnings,
        );
        assert_eq!(
            item_candidates,
            vec![EntityId::new(1)],
            "item candidates ARE intersected against the item category index"
        );
    }

    /// Completeness-W regression: a poisoned universe lock must not silently
    /// return an empty result set. `resolve_scope` recovers the last-known
    /// universe via `PoisonError::into_inner` and surfaces a warning, and
    /// `apply_metadata_filter` skips the index intersection (retain all) with its
    /// own warning rather than dropping every candidate.
    #[test]
    fn search_poisoned_universe_surfaces_warning_not_empty() {
        use std::{panic::catch_unwind, sync::Arc};

        let schema = test_schema();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let registry = RwLock::new(registry_with_slot(
            "content",
            &[(1, [1.0, 0.0]), (2, [1.0, 0.0])],
        ));

        // Poison the universe lock by panicking while holding the write guard.
        let universe = Arc::new(two_item_universe());
        let poison_target = Arc::clone(&universe);
        let _ = catch_unwind(move || {
            let _guard = poison_target.write().unwrap();
            panic!("intentional panic to poison the universe lock");
        });
        assert!(universe.is_poisoned(), "universe lock should be poisoned");

        let exec = filtered_executor(&ledger, &profile_reg, &registry, &universe);
        // `&universe` (`&Arc<RwLock<_>>`) deref-coerces to the `&RwLock<_>` the
        // executor expects.
        let query = Search::builder()
            .vector(vec![1.0f32, 0.0])
            .using_profile("search")
            .within(crate::query::search::WithinScope::Trending { window_hours: 24 })
            .filter(FilterExpr::CategoryEq("jazz".into()))
            .limit(10)
            .build()
            .unwrap();

        let results = exec.execute(&query).unwrap();
        assert!(
            results
                .warnings
                .iter()
                .any(|w| w.contains("universe lock poisoned")),
            "a poisoned universe must surface a warning, got {:?}",
            results.warnings
        );
    }
}
