//! RETRIEVE query executor.
//!
//! Implements the 6-stage pipeline that turns a `Retrieve` AST into `Results`:
//!
//! 1. **Candidate generation**: sources entity IDs via the profile's candidate strategy
//! 2. **Filter evaluation**: applies bitmap/predicate filters to narrow candidates
//! 3. **Signal scoring**: scores and ranks candidates via `ProfileExecutor`
//! 4. **Diversity enforcement**: applies per-creator and format-mix constraints
//! 5. **Notification cap enforcement**: applied when `notification_caps` is set; limits
//!    per-creator and total daily notifications using `NotificationTracker`
//! 6. **Result assembly**: slices, paginates, and builds the final `Results`
//!
//! The executor borrows all infrastructure by reference and is constructed
//! per-query by `TidalDb::retrieve()`.

pub mod candidate_gen;
pub mod personalization;
pub mod post_filter;
pub mod social_filter;
pub mod user_filter;

mod helpers;
mod pipeline;

#[cfg(test)]
mod test_support;

#[cfg(test)]
mod tests;

#[cfg(test)]
mod tests_part2;

use std::sync::RwLock;

use roaring::RoaringBitmap;

use crate::{
    entities::{
        CreatorItemsBitmap, HardNegIndex, InteractionLedger, PreferenceVectors, UserStateIndex,
    },
    query::retrieve::QueryError,
    ranking::registry::ProfileRegistry,
    schema::EntityId,
    session::{SessionContext, SessionSnapshot},
    signals::SignalLedger,
    storage::{
        StorageEngine,
        indexes::{bitmap::BitmapIndex, filter::FilterExpr, range::RangeIndex},
    },
};

// ── Executor ────────────────────────────────────────────────────────────────

/// Executes RETRIEVE queries against the database's live state.
///
/// Constructed per-query by `TidalDb::retrieve()`. Holds borrowed references
/// to all infrastructure needed for the 6-stage pipeline.
///
/// Index references are `Option` because the database may not have all indexes
/// available (e.g., no items written yet). The executor degrades gracefully
/// when indexes are absent.
pub struct RetrieveExecutor<'a> {
    ledger: &'a SignalLedger,
    profile_registry: &'a ProfileRegistry,
    category_index: Option<&'a BitmapIndex>,
    format_index: Option<&'a BitmapIndex>,
    creator_index: Option<&'a BitmapIndex>,
    tag_index: Option<&'a BitmapIndex>,
    duration_index: Option<&'a RangeIndex<u32>>,
    created_at_index: Option<&'a RangeIndex<u64>>,
    universe: Option<&'a RwLock<RoaringBitmap>>,
    // ── M3 personalization context ────────────────────────────────────
    user_state: Option<&'a UserStateIndex>,
    hard_negatives: Option<&'a HardNegIndex>,
    interaction_ledger: Option<&'a InteractionLedger>,
    creator_items: Option<&'a CreatorItemsBitmap>,
    /// Per-user preference vectors for embedding-similarity scoring.
    ///
    /// Consumed in Stage 3 by `personalization::compute_preference_boosts`,
    /// which reads each candidate's content embedding from `items_storage` and
    /// adds the cosine similarity to the user's preference vector as an additive
    /// ranking boost. A no-op when the user has no preference vector recorded or
    /// no items storage is wired.
    preference_vectors: Option<&'a PreferenceVectors>,
    // ── M4 session context ────────────────────────────────────────────
    session_context: Option<SessionContext>,
    session_snapshot: Option<SessionSnapshot>,
    /// Items storage engine for metadata lookup during session-boosted scoring.
    items_storage: Option<&'a dyn StorageEngine>,
    /// Item content embedding slot name (schema's first Item slot, fallback
    /// "content"). Threaded from `TidalDb::item_embedding_slot()` so the
    /// retrieve-time preference boost reads embeddings from the same slot
    /// `write_item_embedding` wrote them to. `None` falls back to "content".
    item_embedding_slot: Option<&'a str>,
    // ── M6 cohort context ─────────────────────────────────────────────
    cohort_ledger: Option<&'a crate::cohort::CohortSignalLedger>,
    cohort_registry: Option<&'a crate::cohort::CohortRegistry>,
    // ── M6 co-engagement + social graph ──────────────────────────────
    co_engagement: Option<&'a crate::entities::CoEngagementIndex>,
    user_signal_index: Option<&'a crate::entities::UserSignalIndex>,
    // ── M6p4 collection index ─────────────────────────────────────────
    collection_index: Option<&'a crate::entities::CollectionIndex>,
    // ── M6p6 notification tracker ─────────────────────────────────────
    notification_tracker:
        Option<&'a std::sync::Arc<crate::db::notification_tracker::NotificationTracker>>,
    // ── M7p2 load degradation ─────────────────────────────────────────
    degradation_level: crate::load::DegradationLevel,
}

impl<'a> RetrieveExecutor<'a> {
    /// Create a new executor with all borrowed infrastructure.
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub const fn new(
        ledger: &'a SignalLedger,
        profile_registry: &'a ProfileRegistry,
        category_index: Option<&'a BitmapIndex>,
        format_index: Option<&'a BitmapIndex>,
        creator_index: Option<&'a BitmapIndex>,
        tag_index: Option<&'a BitmapIndex>,
        duration_index: Option<&'a RangeIndex<u32>>,
        created_at_index: Option<&'a RangeIndex<u64>>,
        universe: Option<&'a RwLock<RoaringBitmap>>,
    ) -> Self {
        Self {
            ledger,
            profile_registry,
            category_index,
            format_index,
            creator_index,
            tag_index,
            duration_index,
            created_at_index,
            universe,
            user_state: None,
            hard_negatives: None,
            interaction_ledger: None,
            creator_items: None,
            preference_vectors: None,
            session_context: None,
            session_snapshot: None,
            items_storage: None,
            item_embedding_slot: None,
            cohort_ledger: None,
            cohort_registry: None,
            co_engagement: None,
            user_signal_index: None,
            collection_index: None,
            notification_tracker: None,
            degradation_level: crate::load::DegradationLevel::Full,
        }
    }

    /// Set the degradation level for this query execution.
    ///
    /// Computed by `LoadDetector::enter()` at query entry and threaded through
    /// the executor pipeline. Defaults to `Full` (no degradation).
    #[must_use]
    pub const fn with_degradation_level(mut self, level: crate::load::DegradationLevel) -> Self {
        self.degradation_level = level;
        self
    }

    /// Attach items storage for metadata lookup during session-boosted scoring.
    #[must_use]
    pub fn with_items_storage(mut self, storage: &'a dyn StorageEngine) -> Self {
        self.items_storage = Some(storage);
        self
    }

    /// Attach the item content embedding slot name used by the preference boost.
    ///
    /// Resolved from `TidalDb::item_embedding_slot()` so the boost reads
    /// candidate embeddings from the same slot they were written under. Defaults
    /// to "content" when not set.
    #[must_use]
    pub const fn with_item_embedding_slot(mut self, slot: &'a str) -> Self {
        self.item_embedding_slot = Some(slot);
        self
    }

    /// Attach M3 personalization context to the executor.
    #[must_use]
    pub const fn with_user_context(
        mut self,
        user_state: &'a UserStateIndex,
        hard_negatives: &'a HardNegIndex,
        interaction_ledger: &'a InteractionLedger,
        creator_items: &'a CreatorItemsBitmap,
    ) -> Self {
        self.user_state = Some(user_state);
        self.hard_negatives = Some(hard_negatives);
        self.interaction_ledger = Some(interaction_ledger);
        self.creator_items = Some(creator_items);
        self
    }

    /// Attach preference vectors for Stage-3 embedding-similarity scoring.
    #[must_use]
    pub const fn with_preference_vectors(
        mut self,
        preference_vectors: &'a PreferenceVectors,
    ) -> Self {
        self.preference_vectors = Some(preference_vectors);
        self
    }

    /// Attach M6 co-engagement index for `related` profile scoring.
    #[must_use]
    pub const fn with_co_engagement(
        mut self,
        co_engagement: &'a crate::entities::CoEngagementIndex,
    ) -> Self {
        self.co_engagement = Some(co_engagement);
        self
    }

    /// Attach M6 per-user signal index for social-graph-scoped trending.
    #[must_use]
    pub const fn with_user_signal_index(
        mut self,
        user_signal_index: &'a crate::entities::UserSignalIndex,
    ) -> Self {
        self.user_signal_index = Some(user_signal_index);
        self
    }

    /// Attach M6 cohort context for cohort-scoped signal reads.
    #[must_use]
    pub const fn with_cohort_context(
        mut self,
        cohort_ledger: &'a crate::cohort::CohortSignalLedger,
        cohort_registry: &'a crate::cohort::CohortRegistry,
    ) -> Self {
        self.cohort_ledger = Some(cohort_ledger);
        self.cohort_registry = Some(cohort_registry);
        self
    }

    /// Attach M6p4 collection index for `InCollection` filter evaluation.
    #[must_use]
    pub const fn with_collection_index(
        mut self,
        collection_index: &'a crate::entities::CollectionIndex,
    ) -> Self {
        self.collection_index = Some(collection_index);
        self
    }

    /// Attach M6p6 notification tracker for daily notification capping.
    #[must_use]
    pub const fn with_notification_tracker(
        mut self,
        tracker: &'a std::sync::Arc<crate::db::notification_tracker::NotificationTracker>,
    ) -> Self {
        self.notification_tracker = Some(tracker);
        self
    }

    /// Attach M4 session context for FOR SESSION ranking boost.
    #[must_use]
    pub fn with_session(mut self, context: SessionContext, snapshot: SessionSnapshot) -> Self {
        self.session_context = Some(context);
        self.session_snapshot = Some(snapshot);
        self
    }

    /// Extract and execute Stage 3 (signal scoring) of the pipeline.
    ///
    /// Scores all candidates using the ranking profile, cohort context, and session,
    /// returning the scored candidates and their pre-loaded metadata.
    ///
    /// Called from `pipeline::execute()`.
    #[allow(
        clippy::too_many_lines,
        clippy::type_complexity,
        clippy::too_many_arguments
    )]
    pub(super) fn stage3_score(
        &self,
        candidates: &[crate::schema::EntityId],
        profile: &crate::ranking::profile::RankingProfile,
        query: &crate::query::retrieve::Retrieve,
        combined_filter: Option<&FilterExpr>,
        now: crate::schema::Timestamp,
        _stats: &mut crate::query::stats::QueryStats,
        warnings: &mut Vec<String>,
    ) -> Result<
        (
            Vec<crate::ranking::executor::ScoredCandidate>,
            std::collections::HashMap<u64, std::collections::HashMap<String, String>>,
        ),
        crate::query::retrieve::QueryError,
    > {
        use std::collections::HashMap;

        use crate::{
            db::deserialize_metadata as deserialize_item_metadata,
            ranking::{executor::ProfileExecutor, profile::Sort},
            storage::{Tag, encode_key},
        };

        let mut executor =
            ProfileExecutor::new(self.ledger).with_degradation_level(self.degradation_level);

        // M6: wire co-engagement context when seed_item (similar_to) is available.
        if let Some(co_eng) = self.co_engagement {
            executor = executor.with_co_engagement(co_eng);
        }
        if let Some(seed) = query.similar_to {
            executor = executor.with_seed(seed);
        }

        // M6: wire social-graph-scoped trending when a SocialGraph filter is active
        // and a per-user signal index is available.
        if let Some(usi) = self.user_signal_index {
            executor = executor.with_user_signal_index(usi);

            // Resolve social subgraph users from the SocialGraph filter.
            if let (Some(user_state), Some(filter_expr)) = (self.user_state, combined_filter)
                && let Some((uid, depth)) = extract_social_graph_params(filter_expr)
            {
                let subgraph_users =
                    social_filter::resolve_social_subgraph_users(uid, depth, user_state);
                if !subgraph_users.is_empty() {
                    executor = executor.with_social_graph_users(subgraph_users);
                }
            }
        }

        // Pre-load item metadata for keyword hint matching (session),
        // metadata-based sorts (alphabetical, duration), or diversity / notification
        // cap enforcement (both need creator_id from metadata to group candidates).
        let needs_metadata_for_sort = matches!(
            profile.sort,
            Some(Sort::AlphabeticalAsc | Sort::AlphabeticalDesc | Sort::Shortest | Sort::Longest)
        );
        let needs_metadata_for_creator_grouping =
            profile.diversity.max_per_creator.is_some() || query.notification_caps.is_some();
        let item_metadata: HashMap<u64, HashMap<String, String>> = if self.session_context.is_some()
            || needs_metadata_for_sort
            || needs_metadata_for_creator_grouping
        {
            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()
        };

        // Wire metadata for metadata-based sorts.
        if !item_metadata.is_empty() {
            executor = executor.with_item_metadata(&item_metadata);
        }

        // Wire DateSaved context.
        if matches!(profile.sort, Some(Sort::DateSaved))
            && let (Some(user_id), Some(user_state)) = (query.for_user, self.user_state)
        {
            executor = executor.with_date_saved_context(user_state, user_id);
        }

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

        #[allow(clippy::option_if_let_else)]
        let mut scored = if let Some(user_id) = query.for_user {
            // Personalized scoring: build UserContext from the interaction ledger
            // and creator-items bitmap, then call score_personalized().
            let mut user_ctx = personalization::build_user_context(
                user_id,
                now,
                self.interaction_ledger,
                self.creator_items,
            );
            // Stage 3 preference boost: cosine similarity between the user's
            // learned preference vector and each candidate's content embedding.
            // No-op when the user has no preference vector or embeddings are absent.
            user_ctx.preference_boosts = personalization::compute_preference_boosts(
                user_id,
                candidates,
                self.preference_vectors,
                self.items_storage,
                self.item_embedding_slot.unwrap_or("content"),
            );
            executor.score_personalized(
                candidates,
                profile,
                now,
                self.session_context.as_ref(),
                &user_ctx,
                &item_metadata,
                // RETRIEVE (browse) has no fused text/vector relevance to seed;
                // ranking is driven entirely by the profile.
                None,
            )
        } else {
            // Population-level profiles: no user context needed.
            executor.score_with_session(
                candidates,
                profile,
                now,
                self.session_context.as_ref(),
                &item_metadata,
                None,
            )
        }
        // 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(),
        })?;

        // ── Stage 3b: Cohort-scoped signal override (M6) ────────────────
        // When a named cohort or ad-hoc cohort predicate is specified,
        // re-score candidates using the cohort signal ledger instead of the
        // global ledger. The profile's boosts and sort mode are the same,
        // but signal values come from cohort-specific state.
        //
        // Resolution order:
        //   1. `query.cohort` -- direct named cohort.
        //   2. `query.cohort_predicate` + `query.for_user` -- resolve the
        //      user's metadata, evaluate the predicate, then find the first
        //      registered cohort the user belongs to.
        #[allow(clippy::option_if_let_else)]
        let resolved_cohort: Option<String> = if let Some(ref name) = query.cohort {
            Some(name.clone())
        } else if let (Some(pred), Some(user_id)) = (&query.cohort_predicate, query.for_user) {
            // Ad-hoc cohort predicate: read user metadata, evaluate the
            // predicate, and find matching registered cohorts.
            self.resolve_adhoc_cohort(pred, user_id, warnings)?
        } else if query.cohort_predicate.is_some() {
            return Err(QueryError::InvalidFilter {
                field: "cohort_predicate".to_string(),
                reason: "cohort_predicate requires FOR USER context (set for_user on the query)"
                    .to_string(),
            });
        } else {
            None
        };

        if let Some(ref cohort_name) = resolved_cohort
            && let Some(cohort_ledger) = self.cohort_ledger
        {
            // Validate the cohort exists (for named cohorts).
            let cohort_exists = self
                .cohort_registry
                .is_some_and(|reg| reg.get(cohort_name).is_some());
            if !cohort_exists && query.cohort.is_some() {
                return Err(QueryError::InvalidFilter {
                    field: "cohort".to_string(),
                    reason: format!("cohort '{cohort_name}' not registered"),
                });
            }
            if cohort_exists {
                self.rescore_with_cohort(&mut scored, cohort_name, cohort_ledger, profile)?;
            }
        }

        // Exploration budget: inject random candidates for discovery.
        if profile.exploration > 0.0 {
            candidate_gen::inject_exploration(&mut scored, candidates, profile.exploration);
        }

        // Populate creator_id in scored candidates from pre-loaded metadata.
        // The ranking executor always leaves creator_id = None; this enrichment
        // enables Stage 4 diversity enforcement (DiversitySelector checks creator_id)
        // and Stage 4.5 notification cap enforcement (resolve_creator_id falls back
        // to items_storage, but having it pre-populated is more reliable).
        if needs_metadata_for_creator_grouping && !item_metadata.is_empty() {
            backfill_creator_ids(&mut scored, &item_metadata);
        }

        Ok((scored, item_metadata))
    }
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/// Backfill `creator_id` on each candidate from pre-loaded item metadata.
///
/// The ranking executor always leaves `creator_id = None`; this enrichment reads
/// the `creator_id` metadata field and sets it so Stage 4 diversity enforcement
/// and Stage 4.5/Stage 5 notification capping can group by creator. Only
/// unresolved (`None`) candidates are touched, so it is safe to run more than
/// once. Shared by Stage 3 (`stage3_score`) and Stage 4.5 (`pipeline::execute`)
/// so the two passes cannot drift on the parse rule.
pub(super) fn backfill_creator_ids(
    candidates: &mut [crate::ranking::executor::ScoredCandidate],
    item_metadata: &std::collections::HashMap<u64, std::collections::HashMap<String, String>>,
) {
    if item_metadata.is_empty() {
        return;
    }
    for candidate in candidates.iter_mut() {
        if candidate.creator_id.is_none()
            && let Some(meta) = item_metadata.get(&candidate.entity_id.as_u64())
            && let Some(cid_str) = meta.get("creator_id")
            && let Ok(cid) = cid_str.parse::<u64>()
        {
            candidate.creator_id = Some(EntityId::new(cid));
        }
    }
}

/// Convert a 64-bit entity id to the 32-bit key space every suppression/
/// inclusion `RoaringBitmap` uses.
///
/// Returns `None` when the id does not fit in `u32`. Bitmaps only 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 leak a suppressed
/// item or wrongly drop a legitimate one. Mirrors the SEARCH pipeline's
/// `entity_as_u32`; callers decide the polarity (excluded ids overflow → keep,
/// included ids overflow → drop).
#[inline]
pub(crate) fn entity_as_u32(id: EntityId) -> Option<u32> {
    u32::try_from(id.as_u64()).ok()
}

/// Apply the FOR USER suppression + inclusion stage shared by the RETRIEVE and
/// SEARCH pipelines (Stage 2.5).
///
/// This is the single source of truth for "given a user, which candidates does
/// their state keep or drop": it removes seen/hidden items, items from blocked
/// creators, and hard negatives, then applies user-state *inclusion* filters
/// (`Saved`/`Liked`/`InProgress`/`SocialGraph`) extracted from `combined_filter`.
/// Both pipelines used to carry near-identical copies of this block; a divergence
/// between them silently meant one query surface suppressed an item the other
/// leaked. Extracting it here makes drift impossible.
///
/// All `u32` conversions go through [`entity_as_u32`]: a `> u32::MAX` id can never
/// be a bitmap member, so it is *retained* against an exclusion bitmap (truncating
/// it would alias onto a suppressed low id and drop the wrong item) and *dropped*
/// against an inclusion bitmap (it cannot satisfy the membership test).
pub(super) fn apply_user_context_suppression(
    candidates: &mut Vec<EntityId>,
    user_id: u64,
    user_state: Option<&UserStateIndex>,
    creator_items: Option<&CreatorItemsBitmap>,
    hard_negatives: Option<&HardNegIndex>,
    combined_filter: Option<&FilterExpr>,
) {
    if let Some(user_state) = user_state {
        // Exclude seen items.
        let seen = user_state.seen_bitmap(user_id);
        candidates.retain(|id| entity_as_u32(*id).is_none_or(|i| !seen.contains(i)));

        // Exclude hidden items.
        let hidden = user_state.hidden_items(user_id);
        candidates.retain(|id| entity_as_u32(*id).is_none_or(|i| !hidden.contains(i)));

        // Exclude items from blocked creators.
        if let Some(creator_items) = creator_items {
            let blocked_creators = user_state.blocked_creators(user_id);
            if !blocked_creators.is_empty() {
                let mut blocked_items = RoaringBitmap::new();
                for &cid in &blocked_creators {
                    if let Some(bm) = creator_items.get(cid) {
                        blocked_items |= &bm;
                    }
                }
                candidates
                    .retain(|id| entity_as_u32(*id).is_none_or(|i| !blocked_items.contains(i)));
            }
        }

        // User-state inclusion filters (Saved, Liked, InProgress, SocialGraph).
        // Unlike the exclusion filters above, these RETAIN only matching items.
        // Routed through the canonical [`user_filter::extract_user_state_filters`]
        // collector (which recurses into both `And` and `Or`, skipping `Not`) so
        // the filter scope and the trending scope cannot disagree.
        if let Some(filter_expr) = combined_filter {
            for uf in user_filter::extract_user_state_filters(filter_expr) {
                match uf {
                    FilterExpr::Saved(uid) => {
                        let saved = user_state.saved_bitmap(*uid);
                        candidates
                            .retain(|id| entity_as_u32(*id).is_some_and(|i| saved.contains(i)));
                    }
                    FilterExpr::Liked(uid) => {
                        let liked = user_state.liked_bitmap(*uid);
                        candidates
                            .retain(|id| entity_as_u32(*id).is_some_and(|i| liked.contains(i)));
                    }
                    FilterExpr::InProgress {
                        user_id: uid,
                        threshold,
                    } => {
                        candidates
                            .retain(|id| user_state.is_in_progress(*uid, id.as_u64(), *threshold));
                    }
                    FilterExpr::SocialGraph {
                        user_id: uid,
                        depth,
                    } => {
                        // Constrain to items from the user's social graph (followed
                        // creators at depth=1, community items at depth=2). Shared so
                        // neither surface silently ignores a `social_graph` filter.
                        post_filter::apply_social_graph_filter(
                            *uid,
                            *depth,
                            candidates,
                            user_state,
                            creator_items,
                        );
                    }
                    // Other variants are not user-state inclusion filters.
                    _ => {}
                }
            }
        }
    }

    // Exclude hard negatives.
    if let Some(hard_neg) = hard_negatives {
        let neg_bitmap = hard_neg.bitmap(user_id);
        if !neg_bitmap.is_empty() {
            candidates.retain(|id| entity_as_u32(*id).is_none_or(|i| !neg_bitmap.contains(i)));
        }
    }
}

/// Extract `SocialGraph { user_id, depth }` parameters from a filter expression.
///
/// Walks `And` AND `Or` lists (deliberately NOT `Not`) and returns the first
/// `SocialGraph` variant found. Returns `None` if no social graph filter is present.
///
/// This MUST share the exact traversal discipline of the Stage 2.5 filter
/// extractor [`user_filter::extract_user_state_filters`], which recurses into
/// both `And` and `Or` (skipping `Not`). If this resolver only walked `And`, an
/// `Or`-nested `SocialGraph` would fire the inclusion FILTER (Stage 2.5) but not
/// the trending-SCOPE resolution (`with_social_graph_users`) — the filtering and
/// scoring scopes would disagree about which social graph is active. `Not` is
/// skipped because a negated deferred filter has no positive scope and is
/// rejected up front by `reject_negated_deferred_filters`.
fn extract_social_graph_params(expr: &FilterExpr) -> Option<(u64, u8)> {
    match expr {
        FilterExpr::SocialGraph { user_id, depth } => Some((*user_id, *depth)),
        FilterExpr::And(exprs) | FilterExpr::Or(exprs) => {
            exprs.iter().find_map(extract_social_graph_params)
        }
        _ => None,
    }
}
