//! Shared deferred post-filter application for RETRIEVE and SEARCH.
//!
//! The deferred filter variants — `InCollection`, `MinSignal`/`MaxSignal`,
//! `NearLocation`, and `SocialGraph` — evaluate to the *full universe* at the
//! [`FilterEvaluator`] level (see [`crate::storage::indexes::filter::expr`]).
//! They carry no index-backed predicate and must be applied as **post-filters**
//! after candidate generation/retrieval, against the live ledger, item
//! metadata, collection index, and social graph.
//!
//! Both the RETRIEVE pipeline (Stages 2.2/2.3/2.4 and the `SocialGraph` arm of
//! 2.5) and the SEARCH pipeline (Stage 2.5) apply the *same* predicates. Before
//! this module existed, SEARCH applied only the user-state inclusion filters
//! (`Saved`/`Liked`/`InProgress`) and silently ignored the four deferred
//! variants above — a well-formed SEARCH with `min_signal`/`near_location`/
//! `in_collection`/`social_graph` returned items that violated the filter, with
//! no error and nothing logged. Extracting the predicates here means RETRIEVE
//! and SEARCH cannot drift again: both call one function.
//!
//! [`FilterEvaluator`]: crate::storage::indexes::filter::FilterEvaluator

use crate::{
    entities::{CreatorItemsBitmap, UserStateIndex},
    load::DegradationLevel,
    query::{
        executor::{social_filter, user_filter},
        retrieve::QueryError,
    },
    schema::EntityId,
    signals::SignalLedger,
    storage::{StorageEngine, Tag, encode_key, indexes::filter::FilterExpr},
};

/// Borrowed infrastructure needed to apply the index/ledger/metadata-backed
/// deferred post-filters (`InCollection`, `MinSignal`/`MaxSignal`,
/// `NearLocation`).
///
/// The collection index and item storage are optional because a filter is only
/// enforced when the corresponding index/store is wired. When that handle is
/// missing the corresponding filter is a no-op (the candidate set is left
/// unchanged) — matching the RETRIEVE pipeline's pre-extraction behavior
/// byte-for-byte and the graceful-degradation contract the rest of the executor
/// follows. The real `TidalDb` paths always wire the collection index and item
/// storage, so the no-op branch is only reachable from minimally-constructed
/// executors (e.g. unit tests that exercise other stages).
///
/// `SocialGraph` is *not* applied through this context: it lives in each
/// pipeline's user-context block via [`apply_social_graph_filter`], so its
/// `UserStateIndex`/`CreatorItemsBitmap` handles are passed there directly.
pub(crate) struct PostFilterCtx<'a> {
    /// Signal ledger — required for `MinSignal`/`MaxSignal`. Always present.
    pub ledger: &'a SignalLedger,
    /// Collection index — required for `InCollection`.
    pub collection_index: Option<&'a crate::entities::CollectionIndex>,
    /// Item metadata storage — required for `NearLocation` (lat/lng lookup).
    pub items_storage: Option<&'a dyn StorageEngine>,
    /// Load-degradation level, threaded into ledger reads for `MinSignal`/`MaxSignal`.
    pub degradation_level: DegradationLevel,
}

/// Apply the `InCollection`, `MinSignal`/`MaxSignal`, and `NearLocation`
/// deferred post-filters to `candidates`, in that order.
///
/// This is the shared core of RETRIEVE Stages 2.2/2.3/2.4 and the equivalent
/// SEARCH stage. The `SocialGraph` arm is intentionally **not** applied here:
/// it lives inside each pipeline's user-context block (after the
/// `Saved`/`Liked`/`InProgress` inclusion arms) so the RETRIEVE ordering stays
/// byte-for-byte identical. Call [`apply_social_graph_filter`] from that block.
///
/// # Errors
///
/// Returns [`QueryError::InvalidFilter`] if a `MinSignal`/`MaxSignal` filter
/// names a signal the schema does not define — the same loud-failure contract
/// RETRIEVE has: an unknown signal must fail the query, never silently retain or
/// drop every candidate.
pub(crate) fn apply_deferred_post_filters(
    filter_expr: &FilterExpr,
    candidates: &mut Vec<EntityId>,
    ctx: &PostFilterCtx<'_>,
) -> Result<(), QueryError> {
    apply_in_collection(filter_expr, candidates, ctx.collection_index);
    apply_signal_thresholds(filter_expr, candidates, ctx.ledger, ctx.degradation_level)?;
    apply_near_location(filter_expr, candidates, ctx.items_storage);
    Ok(())
}

/// RETRIEVE Stage 2.2 / SEARCH equivalent: retain only items in every named
/// collection. A missing/empty *collection* retains nothing for that filter —
/// an `InCollection(c)` for a collection with no items matches the empty set,
/// never the full set. An absent *index handle* is a no-op (matching RETRIEVE's
/// pre-extraction behavior); the real DB path always wires it.
fn apply_in_collection(
    filter_expr: &FilterExpr,
    candidates: &mut Vec<EntityId>,
    collection_index: Option<&crate::entities::CollectionIndex>,
) {
    let coll_filters = user_filter::extract_collection_filters(filter_expr);
    if coll_filters.is_empty() {
        return;
    }
    let Some(coll_idx) = collection_index else {
        // No collection index wired: leave candidates unchanged, matching the
        // RETRIEVE pipeline's `if !empty && let Some(idx)` guard byte-for-byte.
        return;
    };
    for cf in &coll_filters {
        if let FilterExpr::InCollection(cid) = cf {
            if let Some(bm) = coll_idx.bitmap(*cid) {
                // Inclusion test (same polarity as the SocialGraph arm): keep an
                // id only if the collection bitmap holds it. Bitmaps are 32-bit, so
                // an id > u32::MAX can never be a member and must be DROPPED — route
                // through the checked conversion rather than a raw `as u32`
                // truncation, which would alias a high id onto a low slot and could
                // wrongly retain it.
                candidates.retain(|id| {
                    crate::query::executor::entity_as_u32(*id).is_some_and(|i| bm.contains(i))
                });
            } else {
                // Collection not found or empty -- retain nothing.
                candidates.clear();
            }
        }
    }
    tracing::trace!(
        candidates = candidates.len(),
        "post-filter: InCollection applied"
    );
}

/// RETRIEVE Stage 2.3 / SEARCH equivalent: retain only items whose `AllTime`
/// windowed count for the named signal satisfies the threshold (>= for
/// `MinSignal`, <= for `MaxSignal`). Thresholds are conjunctive.
///
/// # Errors
///
/// Returns [`QueryError::InvalidFilter`] when the signal name is not in the
/// schema (`read_agg` propagates the schema error); this fails the query loudly
/// rather than silently retaining or dropping every candidate.
fn apply_signal_thresholds(
    filter_expr: &FilterExpr,
    candidates: &mut Vec<EntityId>,
    ledger: &SignalLedger,
    degradation_level: DegradationLevel,
) -> Result<(), QueryError> {
    let sig_filters = user_filter::extract_signal_threshold_filters(filter_expr);
    if sig_filters.is_empty() {
        return Ok(());
    }
    for sf in &sig_filters {
        let (signal, threshold, keep_if_ge) = match sf {
            FilterExpr::MinSignal { signal, threshold } => (signal, *threshold, true),
            FilterExpr::MaxSignal { signal, threshold } => (signal, *threshold, false),
            _ => continue,
        };
        let mut kept = Vec::with_capacity(candidates.len());
        for &eid in &*candidates {
            let val = crate::ranking::executor::helpers::read_agg(
                eid,
                signal,
                &crate::ranking::profile::SignalAgg::Value,
                crate::schema::Window::AllTime,
                ledger,
                degradation_level,
                crate::schema::Timestamp::now().as_nanos(),
            )
            .map_err(|e| QueryError::InvalidFilter {
                field: signal.clone(),
                reason: e.to_string(),
            })?;
            if (keep_if_ge && val >= threshold) || (!keep_if_ge && val <= threshold) {
                kept.push(eid);
            }
        }
        *candidates = kept;
    }
    tracing::trace!(
        candidates = candidates.len(),
        "post-filter: signal thresholds applied"
    );
    Ok(())
}

/// RETRIEVE Stage 2.4 / SEARCH equivalent: retain only items within `radius_km`
/// of every `NearLocation` filter, via Haversine distance against the item's
/// `latitude`/`longitude` metadata. Items missing coordinates (or with no
/// metadata) are excluded. An absent *item storage* handle is a no-op (matching
/// RETRIEVE's pre-extraction behavior); the real DB path always wires it.
fn apply_near_location(
    filter_expr: &FilterExpr,
    candidates: &mut Vec<EntityId>,
    items_storage: Option<&dyn StorageEngine>,
) {
    let geo_filters = user_filter::extract_geo_filters(filter_expr);
    if geo_filters.is_empty() {
        return;
    }
    let Some(storage) = items_storage else {
        // No item storage wired: leave candidates unchanged, matching the
        // RETRIEVE pipeline's `if !empty && let Some(storage)` guard.
        return;
    };
    candidates.retain(|&eid| {
        let key = encode_key(eid, Tag::Meta, b"");
        let meta = storage
            .get(&key)
            .ok()
            .flatten()
            .map(|b| crate::db::deserialize_metadata(&b))
            .unwrap_or_default();
        geo_filters.iter().all(|gf| match gf {
            FilterExpr::NearLocation {
                lat,
                lng,
                radius_km,
            } => {
                let item_lat = meta.get("latitude").and_then(|v| v.parse::<f64>().ok());
                let item_lng = meta.get("longitude").and_then(|v| v.parse::<f64>().ok());
                match (item_lat, item_lng) {
                    (Some(ilat), Some(ilng)) => {
                        crate::ranking::executor::formulas::haversine_km(*lat, *lng, ilat, ilng)
                            <= *radius_km
                    }
                    _ => false,
                }
            }
            _ => true,
        })
    });
    tracing::trace!(
        candidates = candidates.len(),
        "post-filter: NearLocation applied"
    );
}

/// Apply the `SocialGraph` inclusion arm: when the filter contains a
/// `SocialGraph { user_id, depth }` and both `user_state` and `creator_items`
/// are wired, retain only items reachable from the user's social graph.
///
/// Separated from [`apply_deferred_post_filters`] so each pipeline can call it
/// at the exact position inside its user-context block (after the
/// `Saved`/`Liked`/`InProgress` arms), preserving RETRIEVE's ordering.
pub(crate) fn apply_social_graph_filter(
    user_id: u64,
    depth: u8,
    candidates: &mut Vec<EntityId>,
    user_state: &UserStateIndex,
    creator_items: Option<&CreatorItemsBitmap>,
) {
    let Some(ci) = creator_items else {
        return;
    };
    let sg_bitmap = social_filter::social_graph_bitmap(user_id, depth, user_state, ci);
    // Inclusion test: keep an id only if it is in the social-graph bitmap.
    // RoaringBitmaps only ever hold 32-bit ids, so an id > u32::MAX can never be a
    // member — it must be DROPPED. A raw `id.as_u64() as u32` truncation would
    // instead *alias* the high id onto a low slot and could wrongly retain it
    // (contradicting the `entity_as_u32` invariant the suppression stage relies
    // on). Route through the checked conversion so a non-representable id resolves
    // to `None` and is dropped.
    candidates.retain(|id| {
        crate::query::executor::entity_as_u32(*id).is_some_and(|i| sg_bitmap.contains(i))
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn social_graph_filter_drops_overflow_ids_not_aliases_them() {
        // WARNING regression: the inclusion test must DROP a candidate whose id is
        // > u32::MAX (it can never be a 32-bit bitmap member), not truncate it onto
        // a low slot. A raw `id.as_u64() as u32` would alias `2^32 + k` onto `k`
        // and, if `k` is in the social-graph bitmap, WRONGLY retain the overflow
        // id — contradicting the checked `entity_as_u32` invariant the suppression
        // stage relies on.
        let user_state = UserStateIndex::new();
        let creator_items = CreatorItemsBitmap::new();
        // User 1 follows creator 10; creator 10 has item 100.
        user_state.add_follow(1, 10);
        user_state.add_creator_follower(10, 1);
        creator_items.add_item(10, 100);

        let legit = EntityId::new(100);
        // 2^32 + 100 truncates to 100 (a graph member) under a raw `as u32`.
        let overflow = EntityId::new((1u64 << 32) + 100);
        let mut candidates = vec![legit, overflow];

        apply_social_graph_filter(1, 1, &mut candidates, &user_state, Some(&creator_items));

        assert_eq!(
            candidates,
            vec![legit],
            "the in-graph id 100 is kept; the >u32::MAX id aliasing onto 100 must be \
             dropped, not retained"
        );
    }
}
