//! v1.1.2 query DSL parser + AST for `docs::find` / `docs::find_one`.
//!
//! Sets the precedent v1.2's `dead_letters::list` will follow (see
//! `docs/v1.1.x-design-notes.md` §4 #13). When that lands we promote
//! this module to `picloud-shared` and rename to
//! `picloud_shared::query::{Filter, FieldPath, ComparisonOp}`; until
//! then keeping it private to manager-core avoids over-engineering.
//!
//! Parse stage is deliberately strict: any unrecognized `$xxx`
//! operator surfaces as `FilterParseError::UnsupportedOperator` with
//! a script-visible message naming the offending key + pointing at
//! v1.2. The error strings become part of the SDK contract once
//! scripts depend on them; pin them with snapshot tests in the test
//! module below before changing.
//!
//! ## DSL surface (v1.1.2 subset)
//!
//! ```rhai
//! // implicit equality (top-level)
//! users.find(#{ tier: "gold", status: "active" })
//!
//! // operator object on a field
//! users.find(#{ created_at: #{ "$gt": "2026-01-01T00:00:00Z" } })
//!
//! // dotted paths (max 5 segments)
//! users.find(#{ "user.email": "a@b" })
//!
//! // sort + limit as filter modifiers
//! users.find(#{ tier: "gold", "$sort": #{ created_at: -1 }, "$limit": 10 })
//! ```
//!
//! ## Out of scope (v1.2)
//!
//! `$or`, `$and`, `$not`, `$exists`, `$regex`, `$type`, `$size`,
//! `$all`, `$elemMatch`, multi-field sort, projection, aggregations.

use serde_json::Value;

/// Maximum nesting depth for dotted field paths. `"a.b.c.d.e"` is the
/// deepest path allowed (5 segments). Deeper paths reject at parse
/// time with `InvalidFilter` — prevents pathological JSONB navigation
/// chains from a script.
pub const MAX_FIELD_PATH_DEPTH: usize = 5;

/// Hard cap on `$limit` values — script-side limits are silently
/// clamped here so the Postgres query is always bounded. Mirrors the
/// `find` repo's own internal cap.
pub const MAX_FIND_LIMIT: u32 = 1_000;

/// Parsed `docs::find` filter.
#[derive(Debug, Clone, PartialEq)]
pub struct DocsFilter {
    pub conditions: Vec<FieldCondition>,
    pub sort: Option<Sort>,
    pub limit: Option<u32>,
}

impl DocsFilter {
    /// Empty filter — matches every document in the collection.
    #[must_use]
    pub const fn empty() -> Self {
        Self {
            conditions: Vec::new(),
            sort: None,
            limit: None,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct FieldCondition {
    pub path: FieldPath,
    pub op: ComparisonOp,
    pub value: Value,
}

/// Validated dotted path. Construct only via `FieldPath::parse` so the
/// segment invariants (non-empty, no `..`, no `$` prefix, depth ≤ 5)
/// are guaranteed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldPath {
    segments: Vec<String>,
}

impl FieldPath {
    /// Parse a dotted path from a JSON object key.
    pub fn parse(raw: &str) -> Result<Self, FilterParseError> {
        if raw.is_empty() {
            return Err(FilterParseError::InvalidFilter(
                "docs::find: field path must not be empty".into(),
            ));
        }
        let segments: Vec<&str> = raw.split('.').collect();
        if segments.len() > MAX_FIELD_PATH_DEPTH {
            return Err(FilterParseError::InvalidFilter(format!(
                "docs::find: field path '{raw}' exceeds max depth {MAX_FIELD_PATH_DEPTH}"
            )));
        }
        for seg in &segments {
            if seg.is_empty() {
                return Err(FilterParseError::InvalidFilter(format!(
                    "docs::find: field path '{raw}' has an empty segment (leading/trailing dot or '..')"
                )));
            }
            if seg.starts_with('$') {
                return Err(FilterParseError::InvalidFilter(format!(
                    "docs::find: field path segment '{seg}' must not start with '$'"
                )));
            }
        }
        Ok(Self {
            segments: segments.into_iter().map(ToString::to_string).collect(),
        })
    }

    /// Path segments in order. The Postgres impl binds each as a
    /// separate text parameter to `jsonb_extract_path_text`, so no
    /// segment ever appears in the SQL string verbatim.
    #[must_use]
    pub fn segments(&self) -> &[String] {
        &self.segments
    }

    /// Display form for error messages — joined back with `.`.
    #[must_use]
    pub fn as_str(&self) -> String {
        self.segments.join(".")
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComparisonOp {
    /// Implicit equality at top level OR explicit `$eq`. Maps to
    /// `jsonb_extract_path_text(...) = $M`.
    Eq,
    /// `$ne` — uses Postgres `IS DISTINCT FROM` so JSON nulls and
    /// missing paths are correctly included (`<>` returns NULL on
    /// either operand being NULL, which would silently exclude rows
    /// the user expects to see).
    Ne,
    /// `$gt` / `$gte` / `$lt` / `$lte` — text-lex comparison per the
    /// brief's contract. Known limitation: lex breaks across
    /// digit-count boundaries (`'10' < '9'` is TRUE). Documented in
    /// CHANGELOG; v1.2 advanced query will add numeric-aware
    /// operators.
    Gt,
    Gte,
    Lt,
    Lte,
    /// `$in` — `= ANY($M::text[])` where the value list is bound as
    /// a TEXT[].
    In,
}

impl ComparisonOp {
    /// Decode an operator key like `"$gt"`. Returns `None` for any
    /// non-`$` key; returns `Some(Err(...))` for `$`-prefixed keys
    /// not in the v1.1.2 allowlist (caller surfaces the
    /// UnsupportedOperator error).
    fn from_dollar_key(key: &str) -> Option<Result<Self, FilterParseError>> {
        if !key.starts_with('$') {
            return None;
        }
        Some(match key {
            "$eq" => Ok(Self::Eq),
            "$ne" => Ok(Self::Ne),
            "$gt" => Ok(Self::Gt),
            "$gte" => Ok(Self::Gte),
            "$lt" => Ok(Self::Lt),
            "$lte" => Ok(Self::Lte),
            "$in" => Ok(Self::In),
            other => Err(FilterParseError::UnsupportedOperator(format!(
                "docs::find: operator '{other}' is not supported in v1.1.2; planned for v1.2 advanced query"
            ))),
        })
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Sort {
    pub path: FieldPath,
    pub direction: SortDir,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
    Asc,
    Desc,
}

#[derive(Debug, thiserror::Error)]
pub enum FilterParseError {
    /// Bad path syntax, malformed operator value, multi-field sort,
    /// etc. The string is the script-visible message.
    #[error("{0}")]
    InvalidFilter(String),

    /// Filter used an operator not in the v1.1.2 allowlist. The
    /// string includes the offending operator + v1.2 pointer.
    #[error("{0}")]
    UnsupportedOperator(String),
}

/// Parse a `serde_json::Value` filter into `DocsFilter`. The bridge
/// converts the script's Rhai map into a `Value` via
/// `executor-core::sdk::bridge::dynamic_to_json` and passes it through
/// `DocsService::find`; the service calls this parser before touching
/// the repo.
pub fn parse_filter(filter: &Value) -> Result<DocsFilter, FilterParseError> {
    let obj = filter.as_object().ok_or_else(|| {
        FilterParseError::InvalidFilter("docs::find: filter must be a map/object".into())
    })?;

    let mut out = DocsFilter::empty();

    for (key, value) in obj {
        if let Some(stripped) = key.strip_prefix('$') {
            // Top-level modifier — `$sort` / `$limit`. Any other
            // dollar-key at top level is unsupported.
            match stripped {
                "sort" => out.sort = Some(parse_sort(value)?),
                "limit" => out.limit = Some(parse_limit(value)?),
                other => {
                    return Err(FilterParseError::UnsupportedOperator(format!(
                        "docs::find: top-level modifier '${other}' is not supported in v1.1.2; planned for v1.2 advanced query"
                    )));
                }
            }
            continue;
        }

        // Field path → either implicit equality OR operator-object.
        let path = FieldPath::parse(key)?;
        match value {
            Value::Object(inner) if is_operator_object(inner) => {
                for (op_key, op_val) in inner {
                    let Some(op_res) = ComparisonOp::from_dollar_key(op_key) else {
                        // This shouldn't trigger — is_operator_object
                        // already guarantees every key is $-prefixed.
                        return Err(FilterParseError::InvalidFilter(format!(
                            "docs::find: operator object for '{}' has non-$ key '{op_key}'",
                            path.as_str()
                        )));
                    };
                    let op = op_res?;
                    validate_op_value(op, op_val, &path)?;
                    out.conditions.push(FieldCondition {
                        path: path.clone(),
                        op,
                        value: op_val.clone(),
                    });
                }
            }
            // Any non-object value is implicit equality.
            // (Object values with non-$ keys are user data, not an
            // operator object — reject so the user doesn't accidentally
            // match against a literal `{ name: "Alice" }` shape that
            // would never compare meaningfully under JSONB text.)
            Value::Object(_) => {
                return Err(FilterParseError::InvalidFilter(format!(
                    "docs::find: value for '{}' must be a scalar (implicit equality) or an operator map (keys starting with '$')",
                    path.as_str()
                )));
            }
            _ => {
                out.conditions.push(FieldCondition {
                    path,
                    op: ComparisonOp::Eq,
                    value: value.clone(),
                });
            }
        }
    }

    Ok(out)
}

/// True when every key in the map starts with `$`. Mixed-shape maps
/// (some `$key`, some user-data key) are rejected to avoid silent
/// surprise — the user almost certainly meant an operator object.
fn is_operator_object(map: &serde_json::Map<String, Value>) -> bool {
    !map.is_empty() && map.keys().all(|k| k.starts_with('$'))
}

fn validate_op_value(
    op: ComparisonOp,
    value: &Value,
    path: &FieldPath,
) -> Result<(), FilterParseError> {
    match op {
        ComparisonOp::In => {
            if !value.is_array() {
                return Err(FilterParseError::InvalidFilter(format!(
                    "docs::find: '$in' on '{}' requires an array value",
                    path.as_str()
                )));
            }
        }
        _ => {
            // For the scalar-comparison ops, the value must be a JSON
            // scalar (no arrays / no nested objects). JSON null is
            // allowed — `$ne` against null is a valid query.
            if value.is_array() || value.is_object() {
                return Err(FilterParseError::InvalidFilter(format!(
                    "docs::find: '{op_name}' on '{path}' requires a scalar value",
                    op_name = op_name(op),
                    path = path.as_str()
                )));
            }
        }
    }
    Ok(())
}

const fn op_name(op: ComparisonOp) -> &'static str {
    match op {
        ComparisonOp::Eq => "$eq",
        ComparisonOp::Ne => "$ne",
        ComparisonOp::Gt => "$gt",
        ComparisonOp::Gte => "$gte",
        ComparisonOp::Lt => "$lt",
        ComparisonOp::Lte => "$lte",
        ComparisonOp::In => "$in",
    }
}

fn parse_sort(value: &Value) -> Result<Sort, FilterParseError> {
    let map = value.as_object().ok_or_else(|| {
        FilterParseError::InvalidFilter("docs::find: '$sort' must be a map".into())
    })?;
    if map.is_empty() {
        return Err(FilterParseError::InvalidFilter(
            "docs::find: '$sort' must name at least one field".into(),
        ));
    }
    if map.len() > 1 {
        return Err(FilterParseError::InvalidFilter(
            "docs::find: multi-field '$sort' is not supported in v1.1.2; planned for v1.2 advanced query"
                .into(),
        ));
    }
    let (field, dir_val) = map.iter().next().unwrap();
    let path = FieldPath::parse(field)?;
    let direction = match dir_val.as_i64() {
        Some(1) => SortDir::Asc,
        Some(-1) => SortDir::Desc,
        _ => {
            return Err(FilterParseError::InvalidFilter(format!(
                "docs::find: '$sort' direction for '{field}' must be 1 (ascending) or -1 (descending)"
            )));
        }
    };
    Ok(Sort { path, direction })
}

fn parse_limit(value: &Value) -> Result<u32, FilterParseError> {
    let n = value.as_i64().ok_or_else(|| {
        FilterParseError::InvalidFilter("docs::find: '$limit' must be an integer".into())
    })?;
    if n < 0 {
        return Err(FilterParseError::InvalidFilter(
            "docs::find: '$limit' must be non-negative".into(),
        ));
    }
    Ok(u32::try_from(n)
        .unwrap_or(MAX_FIND_LIMIT)
        .min(MAX_FIND_LIMIT))
}

// ----------------------------------------------------------------------------
// Tests — error messages are part of the SDK contract once scripts
// depend on them; the snapshot-style asserts pin the exact strings.
// ----------------------------------------------------------------------------

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

    fn parse(v: Value) -> Result<DocsFilter, FilterParseError> {
        parse_filter(&v)
    }

    #[test]
    fn empty_object_has_no_conditions() {
        let f = parse(json!({})).unwrap();
        assert!(f.conditions.is_empty());
        assert!(f.sort.is_none());
        assert!(f.limit.is_none());
    }

    #[test]
    fn single_equality_top_level() {
        let f = parse(json!({ "tier": "gold" })).unwrap();
        assert_eq!(f.conditions.len(), 1);
        assert_eq!(f.conditions[0].path.segments(), &["tier".to_string()]);
        assert_eq!(f.conditions[0].op, ComparisonOp::Eq);
        assert_eq!(f.conditions[0].value, json!("gold"));
    }

    #[test]
    fn multi_field_equality_is_conjunctive() {
        let f = parse(json!({ "tier": "gold", "status": "active" })).unwrap();
        assert_eq!(f.conditions.len(), 2);
    }

    #[test]
    fn nested_dotted_path() {
        let f = parse(json!({ "user.email": "a@b" })).unwrap();
        let cond = &f.conditions[0];
        assert_eq!(
            cond.path.segments(),
            &["user".to_string(), "email".to_string()]
        );
    }

    #[test]
    fn depth_limit_rejects_six_segments() {
        let err = parse(json!({ "a.b.c.d.e.f": "x" })).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("exceeds max depth"), "msg: {msg}");
        assert!(msg.contains('5'), "msg: {msg}");
    }

    #[test]
    fn double_dot_rejected() {
        let err = parse(json!({ "a..b": "x" })).unwrap_err();
        assert!(err.to_string().contains("empty segment"));
    }

    #[test]
    fn leading_dot_rejected() {
        let err = parse(json!({ ".a": "x" })).unwrap_err();
        assert!(err.to_string().contains("empty segment"));
    }

    #[test]
    fn trailing_dot_rejected() {
        let err = parse(json!({ "a.": "x" })).unwrap_err();
        assert!(err.to_string().contains("empty segment"));
    }

    #[test]
    fn dollar_prefix_in_path_segment_rejected() {
        // (The top-level $foo would route to operator dispatch; this
        // tests deeper segments which should never start with $.)
        let err = parse(json!({ "x.$inner": "v" })).unwrap_err();
        assert!(err.to_string().contains("must not start with '$'"));
    }

    #[test]
    fn each_supported_operator_parses() {
        for (key, expected_op) in [
            ("$eq", ComparisonOp::Eq),
            ("$ne", ComparisonOp::Ne),
            ("$gt", ComparisonOp::Gt),
            ("$gte", ComparisonOp::Gte),
            ("$lt", ComparisonOp::Lt),
            ("$lte", ComparisonOp::Lte),
        ] {
            let v = json!({ "field": { key: "v" } });
            let f = parse(v).unwrap();
            assert_eq!(f.conditions[0].op, expected_op, "key {key}");
        }
        // $in needs an array.
        let f = parse(json!({ "tier": { "$in": ["gold", "platinum"] } })).unwrap();
        assert_eq!(f.conditions[0].op, ComparisonOp::In);
    }

    #[test]
    fn dollar_in_with_non_array_value_rejected() {
        let err = parse(json!({ "tier": { "$in": "gold" } })).unwrap_err();
        assert!(err.to_string().contains("'$in'"));
        assert!(err.to_string().contains("array"));
    }

    #[test]
    fn scalar_op_with_object_value_rejected() {
        let err = parse(json!({ "tier": { "$gt": { "nested": true } } })).unwrap_err();
        assert!(err.to_string().contains("'$gt'"));
        assert!(err.to_string().contains("scalar"));
    }

    /// Snapshot: the v1.2-deferred operator error string is part of
    /// the SDK contract. Don't change it without a major-version bump.
    #[test]
    fn unsupported_operator_message_pins_v1_2_pointer() {
        let err = parse(json!({ "name": { "$regex": "^A" } })).unwrap_err();
        assert_eq!(
            err.to_string(),
            "docs::find: operator '$regex' is not supported in v1.1.2; planned for v1.2 advanced query"
        );
    }

    #[test]
    fn unsupported_top_level_modifier_rejected() {
        let err = parse(json!({ "$or": [{ "x": 1 }] })).unwrap_err();
        assert!(err.to_string().contains("'$or'"));
        assert!(err.to_string().contains("v1.2"));
    }

    /// Snapshot: depth-limit error string. Pinned per the SDK contract.
    #[test]
    fn depth_limit_message_pinned() {
        let err = parse(json!({ "a.b.c.d.e.f": 1 })).unwrap_err();
        assert_eq!(
            err.to_string(),
            "docs::find: field path 'a.b.c.d.e.f' exceeds max depth 5"
        );
    }

    #[test]
    fn mixed_shape_operator_object_rejected() {
        // Object value where some keys are $-prefixed and some aren't
        // — treated as user data + invalid (the user almost certainly
        // meant an operator object).
        let err = parse(json!({ "x": { "$gt": 1, "other": 2 } })).unwrap_err();
        assert!(err
            .to_string()
            .contains("scalar (implicit equality) or an operator map"));
    }

    #[test]
    fn sort_asc_and_desc_parse() {
        let f = parse(json!({ "$sort": { "created_at": 1 } })).unwrap();
        let sort = f.sort.unwrap();
        assert_eq!(sort.direction, SortDir::Asc);
        assert_eq!(sort.path.segments(), &["created_at".to_string()]);

        let f = parse(json!({ "$sort": { "created_at": -1 } })).unwrap();
        assert_eq!(f.sort.unwrap().direction, SortDir::Desc);
    }

    #[test]
    fn sort_with_bad_direction_rejected() {
        let err = parse(json!({ "$sort": { "x": 2 } })).unwrap_err();
        assert!(err.to_string().contains("1 (ascending)"));
    }

    /// Snapshot: multi-field sort error string. Pinned.
    #[test]
    fn multi_field_sort_rejected_with_v1_2_pointer() {
        let err = parse(json!({ "$sort": { "a": 1, "b": -1 } })).unwrap_err();
        assert_eq!(
            err.to_string(),
            "docs::find: multi-field '$sort' is not supported in v1.1.2; planned for v1.2 advanced query"
        );
    }

    #[test]
    fn limit_accepts_non_negative_integer() {
        let f = parse(json!({ "$limit": 50 })).unwrap();
        assert_eq!(f.limit, Some(50));
    }

    #[test]
    fn limit_clamps_to_max() {
        let f = parse(json!({ "$limit": 10_000 })).unwrap();
        assert_eq!(f.limit, Some(MAX_FIND_LIMIT));
    }

    #[test]
    fn limit_rejects_negative() {
        let err = parse(json!({ "$limit": -1 })).unwrap_err();
        assert!(err.to_string().contains("non-negative"));
    }

    #[test]
    fn limit_rejects_non_integer() {
        let err = parse(json!({ "$limit": "twenty" })).unwrap_err();
        assert!(err.to_string().contains("integer"));
    }

    #[test]
    fn non_object_filter_rejected() {
        let err = parse(json!("not a map")).unwrap_err();
        assert!(err.to_string().contains("filter must be a map/object"));
    }

    #[test]
    fn dollar_eq_value_can_be_null() {
        // $ne against null is a valid query (returns docs where field
        // exists and is not null OR is missing) — so null must be an
        // accepted scalar.
        let f = parse(json!({ "deleted_at": { "$ne": null } })).unwrap();
        assert_eq!(f.conditions[0].op, ComparisonOp::Ne);
        assert_eq!(f.conditions[0].value, Value::Null);
    }

    #[test]
    fn implicit_equality_with_array_value_accepts() {
        // `{ "tags": ["a", "b"] }` is implicit equality against the
        // literal array shape. The Postgres query will compare the
        // text encoding under JSONB; this is valid v1.1.2.
        let f = parse(json!({ "tags": ["a", "b"] })).unwrap();
        assert_eq!(f.conditions[0].op, ComparisonOp::Eq);
    }
}
