//! Filter expression AST.
//!
//! Defines the composable filter expression tree that the query parser
//! produces from `FILTER` clauses and that `FilterEvaluator` evaluates
//! against bitmap and range indexes.

/// Error constructing a filter expression from untyped inputs.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FilterError {
    /// An equality filter referenced a field with no typed index variant.
    ///
    /// The index-backed equality filters are `category` and `format`. Routing
    /// any other field to one of those (the previous best-effort behavior)
    /// silently produced wrong results, so unknown fields are now rejected.
    #[error("unknown equality filter field: {field:?} (supported: \"category\", \"format\")")]
    UnknownField {
        /// The field name that could not be mapped to a typed variant.
        field: String,
    },
}

/// A filter expression AST node.
///
/// Built by the query parser from the `FILTER` clause and evaluated by
/// `FilterEvaluator` against bitmap and range indexes.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterExpr {
    /// Exact equality on the category field.
    CategoryEq(String),
    /// Exact equality on the format field.
    FormatEq(String),
    /// Exact equality on creator (stored by creator ID as string).
    CreatorEq(u32),
    /// Tag match (multi-value field).
    Tag(String),
    /// Minimum duration in seconds.
    DurationMin(u32),
    /// Maximum duration in seconds.
    DurationMax(u32),
    /// Created after a timestamp (nanoseconds).
    CreatedAfter(u64),
    /// Created before a timestamp (nanoseconds).
    CreatedBefore(u64),
    /// AND: all sub-expressions must match.
    And(Vec<Self>),
    /// OR: at least one sub-expression must match.
    Or(Vec<Self>),
    /// NOT: the sub-expression must NOT match.
    Not(Box<Self>),
    // -- M3 User State Filters -----------------------------------------------
    // These variants are evaluated by the query executor's Stage 2.5 (user-context
    // filtering) which has access to UserStateIndex. At the FilterEvaluator level
    // they return the full universe. They will be constructed by the query parser
    // from FOR USER clauses in M4; currently they are only reachable programmatically.
    /// Only items the user has not seen. Requires FOR USER context.
    Unseen(u64),
    /// Only items not from blocked creators. Requires FOR USER context.
    Unblocked(u64),
    /// Only items the user has saved. Requires FOR USER context.
    Saved(u64),
    /// Only items the user has liked. Requires FOR USER context.
    Liked(u64),
    /// Only items the user has partially consumed (0 < completion < threshold).
    /// Requires FOR USER context.
    InProgress { user_id: u64, threshold: f64 },
    /// Social graph filter: items from creators the user follows (depth=1)
    /// or items engaged by users in the resolved social subgraph (depth=2).
    ///
    /// Requires FOR USER context and `CreatorItemsBitmap` in executor.
    /// At the `FilterEvaluator` level this returns the full universe;
    /// the actual resolution happens in executor Stage 2.5 where
    /// `UserStateIndex` and `CreatorItemsBitmap` are available.
    SocialGraph { user_id: u64, depth: u8 },

    // -- M6p3 Engagement Threshold Filters ────────────────────────────────
    /// Minimum engagement threshold: `AllTime` windowed count for the named
    /// signal must be >= `threshold`. Evaluated as a post-filter in Stage 2.3
    /// of the executor (needs ledger access).
    MinSignal { signal: String, threshold: f64 },
    /// Maximum engagement threshold: `AllTime` windowed count for the named
    /// signal must be <= `threshold`. Evaluated as a post-filter in Stage 2.3
    /// of the executor (needs ledger access).
    MaxSignal { signal: String, threshold: f64 },

    // -- M6p3 Geographic Post-Filter ──────────────────────────────────────
    /// Geographic proximity filter: Haversine distance from `(lat, lng)` must
    /// be <= `radius_km`. Evaluated against item "latitude"/"longitude" metadata
    /// as a post-filter in Stage 2.4 (not index-backed).
    NearLocation { lat: f64, lng: f64, radius_km: f64 },

    // -- M6p4 Collection Filter ──────────────────────────────────────────
    /// Only items in the specified collection. Evaluated as post-filter
    /// Stage 2.2 in the executor. Requires `CollectionIndex`.
    InCollection(crate::entities::CollectionId),
}

impl FilterExpr {
    /// Construct a social graph filter.
    ///
    /// - `depth=1`: items from creators the user follows.
    /// - `depth=2`: depth-1 items plus items engaged by users in the
    ///   resolved social subgraph (followers of the same creators).
    #[must_use]
    pub const fn social_graph(user_id: u64, depth: u8) -> Self {
        Self::SocialGraph { user_id, depth }
    }

    /// Construct a minimum signal threshold filter.
    ///
    /// Items with `AllTime` windowed count for `signal` below `threshold` are excluded.
    #[must_use]
    pub fn min_signal(signal: impl Into<String>, threshold: f64) -> Self {
        Self::MinSignal {
            signal: signal.into(),
            threshold,
        }
    }

    /// Construct a maximum signal threshold filter.
    ///
    /// Items with `AllTime` windowed count for `signal` above `threshold` are excluded.
    #[must_use]
    pub fn max_signal(signal: impl Into<String>, threshold: f64) -> Self {
        Self::MaxSignal {
            signal: signal.into(),
            threshold,
        }
    }

    /// Construct a geographic proximity filter.
    ///
    /// Items beyond `radius_km` from `(lat, lng)` are excluded. Uses Haversine
    /// distance against item "latitude"/"longitude" metadata fields.
    #[must_use]
    pub const fn near_location(lat: f64, lng: f64, radius_km: f64) -> Self {
        Self::NearLocation {
            lat,
            lng,
            radius_km,
        }
    }

    /// Construct an in-collection filter.
    ///
    /// Only items that belong to the specified collection are retained.
    #[must_use]
    pub const fn in_collection(id: crate::entities::CollectionId) -> Self {
        Self::InCollection(id)
    }

    /// Maximum recursion depth `node_count` and `FilterEvaluator` will descend.
    ///
    /// A tree with at most [`MAX_FILTER_NODES`](Self::MAX_FILTER_NODES) nodes can
    /// be at most that deep (a degenerate `Not(Not(...))` chain), so bounding the
    /// descent at this value never rejects a tree the breadth guard would accept,
    /// while a pathologically deeper hand-built AST is rejected *before* it can
    /// recurse far enough to overflow the stack. Counting and evaluation both
    /// honor it so neither can be driven into unbounded recursion.
    pub const MAX_FILTER_DEPTH: usize = 256;

    /// Breadth guard: the maximum total node count a filter may carry.
    ///
    /// Mirrored by the query entrypoint (`Db::retrieve`), kept here so the
    /// node-count saturation sentinel and the caller's rejection threshold come
    /// from one definition.
    pub const MAX_FILTER_NODES: usize = 256;

    /// Returns the total number of nodes in this filter expression tree.
    ///
    /// Used to guard against deeply nested or oversized filter expressions
    /// that could cause excessive recursion or memory usage during evaluation.
    ///
    /// Counting is itself **depth-bounded**: a tree deeper than
    /// [`MAX_FILTER_DEPTH`](Self::MAX_FILTER_DEPTH) short-circuits to a count of
    /// `usize::MAX` rather than recursing the full depth first. This guarantees
    /// the breadth guard that consumes this count (`count > MAX_FILTER_NODES`)
    /// rejects a pathologically deep `Not(Not(...))` chain *before* the count
    /// itself can overflow the stack — closing the gap where the breadth guard
    /// depended on a full deep recursion to even compute the number.
    #[must_use]
    pub fn node_count(&self) -> usize {
        self.node_count_bounded(0)
    }

    /// Depth-bounded node counter. `depth` is the current recursion depth; once
    /// it exceeds [`MAX_FILTER_DEPTH`](Self::MAX_FILTER_DEPTH) we stop descending
    /// and return `usize::MAX` so the breadth guard trivially rejects the tree.
    /// Sums saturate so an over-wide tree also cannot wrap.
    fn node_count_bounded(&self, depth: usize) -> usize {
        if depth >= Self::MAX_FILTER_DEPTH {
            return usize::MAX;
        }
        match self {
            Self::And(children) | Self::Or(children) => children
                .iter()
                .map(|c| c.node_count_bounded(depth + 1))
                .fold(1usize, usize::saturating_add),
            Self::Not(inner) => 1usize.saturating_add(inner.node_count_bounded(depth + 1)),
            _ => 1,
        }
    }

    /// Construct an equality filter by field name.
    ///
    /// Routes known field names to their typed variants:
    /// - `"category"` -> `CategoryEq`
    /// - `"format"` -> `FormatEq`
    ///
    /// # Errors
    ///
    /// Returns [`FilterError::UnknownField`] for any other field name. The
    /// previous behavior silently routed unknown fields to `CategoryEq`, which
    /// produced wrong filter results (e.g. `eq("language", "en")` matched the
    /// category index instead of language). Until a generic field-equality
    /// variant lands (M3+), unknown fields are an error so callers cannot
    /// silently get the wrong set.
    pub fn try_eq(field: &str, value: &str) -> Result<Self, FilterError> {
        match field {
            "category" => Ok(Self::CategoryEq(value.to_string())),
            "format" => Ok(Self::FormatEq(value.to_string())),
            other => Err(FilterError::UnknownField {
                field: other.to_string(),
            }),
        }
    }

    /// Construct an equality filter by field name for a statically known field.
    ///
    /// Convenience over [`try_eq`](Self::try_eq) for the two supported fields
    /// (`"category"`, `"format"`).
    ///
    /// # Panics
    ///
    /// Panics if `field` is not `"category"` or `"format"`. Use
    /// [`try_eq`](Self::try_eq) for caller-supplied (untrusted) field names so
    /// an unknown field surfaces as a typed error instead of a panic.
    #[must_use]
    pub fn eq(field: &str, value: &str) -> Self {
        // Callers of `eq` pass compile-time-known fields; an unknown field is a
        // programmer error, not runtime input, so an explicit panic with the
        // offending field is the right contract (no silent wrong default).
        Self::try_eq(field, value)
            .unwrap_or_else(|e| panic!("FilterExpr::eq with unsupported field: {e}"))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn node_count_leaf() {
        assert_eq!(FilterExpr::CategoryEq("music".into()).node_count(), 1);
    }

    #[test]
    fn try_eq_routes_known_fields() {
        assert_eq!(
            FilterExpr::try_eq("category", "jazz"),
            Ok(FilterExpr::CategoryEq("jazz".into()))
        );
        assert_eq!(
            FilterExpr::try_eq("format", "video"),
            Ok(FilterExpr::FormatEq("video".into()))
        );
    }

    #[test]
    fn try_eq_rejects_unknown_field() {
        let err = FilterExpr::try_eq("language", "en").expect_err("unknown field must error");
        assert_eq!(
            err,
            FilterError::UnknownField {
                field: "language".into()
            }
        );
        // Must NOT silently become a CategoryEq carrying the value.
        assert!(FilterExpr::try_eq("language", "en").is_err());
    }

    #[test]
    fn eq_routes_known_fields() {
        assert_eq!(
            FilterExpr::eq("category", "jazz"),
            FilterExpr::CategoryEq("jazz".into())
        );
        assert_eq!(
            FilterExpr::eq("format", "video"),
            FilterExpr::FormatEq("video".into())
        );
    }

    #[test]
    #[should_panic(expected = "unsupported field")]
    fn eq_panics_on_unknown_field() {
        // `eq` is for compile-time-known fields; an unknown one is a bug, not
        // a silent wrong default.
        let _ = FilterExpr::eq("language", "en");
    }

    #[test]
    fn node_count_and() {
        let filter = FilterExpr::And(vec![
            FilterExpr::CategoryEq("music".into()),
            FilterExpr::FormatEq("video".into()),
        ]);
        // 1 (And) + 2 (leaves) = 3
        assert_eq!(filter.node_count(), 3);
    }

    #[test]
    fn node_count_nested() {
        let filter = FilterExpr::Or(vec![
            FilterExpr::And(vec![
                FilterExpr::CategoryEq("a".into()),
                FilterExpr::FormatEq("b".into()),
            ]),
            FilterExpr::Not(Box::new(FilterExpr::Tag("c".into()))),
        ]);
        // Or(1) + And(1) + Cat(1) + Fmt(1) + Not(1) + Tag(1) = 6
        assert_eq!(filter.node_count(), 6);
    }

    #[test]
    fn node_count_deep_chain_is_depth_bounded() {
        // A `Not(Not(...))` chain far deeper than MAX_FILTER_DEPTH must be
        // counted WITHOUT recursing the full depth: node_count_bounded
        // short-circuits to usize::MAX past the depth cap, so the breadth guard
        // (`count > MAX_FILTER_NODES`) rejects it before a deep recursion can
        // overflow the stack. Build a chain a few thousand deep to prove the
        // bound holds well past the node cap.
        let mut expr = FilterExpr::CategoryEq("leaf".into());
        for _ in 0..5_000 {
            expr = FilterExpr::Not(Box::new(expr));
        }
        let count = expr.node_count();
        assert!(
            count > FilterExpr::MAX_FILTER_NODES,
            "deep chain must report a count exceeding the breadth cap, got {count}"
        );
        // Shallow-but-wide trees are unaffected: their depth never trips the cap,
        // so the exact count is preserved (see node_count_large_flat_and).
    }

    #[test]
    fn node_count_at_depth_limit_does_not_saturate_spuriously() {
        // A chain exactly at the boundary still counts truthfully: a Not chain of
        // depth just under MAX_FILTER_DEPTH returns its real (finite) node count,
        // proving the cap does not reject trees the breadth guard would accept.
        let mut expr = FilterExpr::CategoryEq("leaf".into());
        for _ in 0..(FilterExpr::MAX_FILTER_DEPTH - 2) {
            expr = FilterExpr::Not(Box::new(expr));
        }
        let count = expr.node_count();
        assert_ne!(count, usize::MAX, "below the depth cap must not saturate");
        assert_eq!(count, FilterExpr::MAX_FILTER_DEPTH - 1);
    }

    #[test]
    fn node_count_large_flat_and() {
        let children: Vec<FilterExpr> = (0..300)
            .map(|i| FilterExpr::CategoryEq(format!("cat_{i}")))
            .collect();
        let filter = FilterExpr::And(children);
        // 1 (And) + 300 (leaves) = 301
        assert_eq!(filter.node_count(), 301);
        assert!(
            filter.node_count() > 256,
            "this filter should exceed the 256-node limit"
        );
    }
}
