//! Helper functions extracted from the SEARCH executor to keep file size
//! under the 600-line limit (`CODING_GUIDELINES` §9).
//!
//! - `build_user_context` free function (was a private method on `SearchExecutor`)
//! - Creator metadata filter predicates
//!
//! User-state filter extraction lives in
//! [`crate::query::executor::user_filter::extract_user_state_filters`]; both the
//! RETRIEVE and SEARCH pipelines call that single collector so they cannot
//! drift on which user-state variants (Saved/Liked/InProgress/SocialGraph) are
//! recognized.

use std::collections::HashMap;

use crate::{
    entities::{CreatorItemsBitmap, InteractionLedger},
    ranking::executor::UserContext,
    schema::Timestamp,
    storage::indexes::filter::FilterExpr,
};

// ── UserContext builder ───────────────────────────────────────────────────────

/// Build a `UserContext` for personalized scoring.
pub(super) fn build_user_context(
    interaction_ledger: Option<&InteractionLedger>,
    creator_items: Option<&CreatorItemsBitmap>,
    user_id: u64,
    now: Timestamp,
) -> UserContext {
    let now_ns = now.as_nanos();

    let top_creators = interaction_ledger
        .map(|il| il.top_creators(user_id, 50, now_ns))
        .unwrap_or_default();

    let mut creator_interaction_boosts: HashMap<u32, f64> = HashMap::new();
    if let Some(creator_items) = creator_items {
        for (creator_id, weight) in &top_creators {
            if let Some(bitmap) = creator_items.get(*creator_id) {
                for item_id in &bitmap {
                    creator_interaction_boosts.insert(item_id, *weight);
                }
            }
        }
    }

    if !creator_interaction_boosts.is_empty() {
        let max_weight = creator_interaction_boosts
            .values()
            .copied()
            .fold(0.0_f64, f64::max);
        if max_weight > f64::EPSILON {
            for val in creator_interaction_boosts.values_mut() {
                *val /= max_weight;
            }
        }
    }

    UserContext {
        user_id,
        creator_interaction_boosts,
        // Populated by the SEARCH pipeline via `compute_preference_boosts` when a
        // preference vector and per-item embeddings are both available.
        preference_boosts: HashMap::new(),
    }
}

// ── Creator metadata filter helpers ──────────────────────────────────────────

/// Check if a filter expression contains metadata-based predicates
/// (`CategoryEq`, `FormatEq`) that need evaluating against entity metadata.
///
/// For creator searches, bitmap indexes are not populated with per-creator
/// metadata like `verified` or `language`, so any `CategoryEq`/`FormatEq` node
/// is evaluated against the entity store here.
///
/// Note: untrusted field equality (e.g. a caller-supplied `"language"` field)
/// must be built with [`FilterExpr::try_eq`], which returns
/// [`FilterError::UnknownField`] for fields other than `"category"`/`"format"`.
/// Unknown fields no longer silently collapse into `CategoryEq`; the infallible
/// [`FilterExpr::eq`] panics on an unknown field and is for compile-time-known
/// fields only.
///
/// [`FilterExpr::try_eq`]: crate::storage::indexes::filter::FilterExpr::try_eq
/// [`FilterExpr::eq`]: crate::storage::indexes::filter::FilterExpr::eq
/// [`FilterError::UnknownField`]: crate::storage::indexes::filter::FilterError::UnknownField
pub(super) fn has_creator_metadata_filter(expr: &FilterExpr) -> bool {
    match expr {
        FilterExpr::CategoryEq(_) | FilterExpr::FormatEq(_) => true,
        FilterExpr::And(children) | FilterExpr::Or(children) => {
            children.iter().any(has_creator_metadata_filter)
        }
        FilterExpr::Not(inner) => has_creator_metadata_filter(inner),
        _ => false,
    }
}

/// Evaluate a filter expression against entity metadata key-value map.
///
/// Returns `true` if the entity passes the filter. Non-metadata filter
/// variants (duration, range, user-state) pass through -- they are handled
/// by earlier stages.
pub(super) fn evaluate_metadata_filter(expr: &FilterExpr, meta: &HashMap<String, String>) -> bool {
    match expr {
        // CategoryEq and FormatEq are the only typed equality variants today.
        // Caller-supplied field equality must be constructed via
        // `FilterExpr::try_eq`, which errors with `FilterError::UnknownField`
        // for anything other than "category"/"format" (it no longer silently
        // collapses an unknown field like "language" into `CategoryEq`).
        //
        // Each variant matches against its OWN field discriminant
        // ("category"/"format" — the canonical metadata keys the write path
        // stores under and the bitmap indexes are built from), NOT against ANY
        // metadata value (W29). The prior `meta.values().any(...)` cross-matched
        // fields, e.g. `CategoryEq("video")` wrongly admitted a creator whose
        // *format* (not category) was "video".
        FilterExpr::CategoryEq(value) => meta.get("category").is_some_and(|v| v == value),
        FilterExpr::FormatEq(value) => meta.get("format").is_some_and(|v| v == value),
        FilterExpr::And(children) => children.iter().all(|c| evaluate_metadata_filter(c, meta)),
        FilterExpr::Or(children) => children.iter().any(|c| evaluate_metadata_filter(c, meta)),
        FilterExpr::Not(inner) => !evaluate_metadata_filter(inner, meta),
        // Non-metadata filters pass through (already handled by bitmap/range evaluator).
        _ => true,
    }
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::collections::HashMap;

    use super::{FilterExpr, evaluate_metadata_filter};

    fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
        pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
            .collect()
    }

    /// W29 regression: `CategoryEq`/`FormatEq` must match the value against their
    /// OWN field, not against ANY metadata field. The cross-field case is the
    /// load-bearing one — `CategoryEq("video")` must NOT match a creator whose
    /// *format* (not category) is "video".
    #[test]
    fn metadata_filter_matches_specific_field_not_any() {
        let m = meta(&[("category", "jazz"), ("format", "video")]);

        // Correct field, correct value → match.
        assert!(evaluate_metadata_filter(
            &FilterExpr::CategoryEq("jazz".into()),
            &m
        ));
        assert!(evaluate_metadata_filter(
            &FilterExpr::FormatEq("video".into()),
            &m
        ));

        // Right value but WRONG field → must NOT match (the W29 bug).
        assert!(
            !evaluate_metadata_filter(&FilterExpr::CategoryEq("video".into()), &m),
            "CategoryEq must not match the value against the `format` field"
        );
        assert!(
            !evaluate_metadata_filter(&FilterExpr::FormatEq("jazz".into()), &m),
            "FormatEq must not match the value against the `category` field"
        );
    }

    /// A missing field never matches (no panic, no false positive).
    #[test]
    fn metadata_filter_missing_field_does_not_match() {
        let m = meta(&[("format", "video")]);
        assert!(!evaluate_metadata_filter(
            &FilterExpr::CategoryEq("jazz".into()),
            &m
        ));
    }

    /// AND/OR/NOT composition still threads through the per-field match.
    #[test]
    fn metadata_filter_composes_with_boolean_nodes() {
        let m = meta(&[("category", "jazz"), ("format", "audio")]);

        // category=jazz AND format=audio → both true.
        assert!(evaluate_metadata_filter(
            &FilterExpr::And(vec![
                FilterExpr::CategoryEq("jazz".into()),
                FilterExpr::FormatEq("audio".into()),
            ]),
            &m
        ));
        // category=jazz AND format=video → second is false (cross-field guarded).
        assert!(!evaluate_metadata_filter(
            &FilterExpr::And(vec![
                FilterExpr::CategoryEq("jazz".into()),
                FilterExpr::FormatEq("video".into()),
            ]),
            &m
        ));
        // NOT(category=rock) → true (no field cross-match).
        assert!(evaluate_metadata_filter(
            &FilterExpr::Not(Box::new(FilterExpr::CategoryEq("rock".into()))),
            &m
        ));
    }
}
