//! `cel:` matcher: a CEL boolean predicate over the value.
//!
//! Why this exists: the built-in matchers cover the common shapes, but a
//! suite sometimes needs custom boolean logic over the response that no
//! single matcher expresses (for example "content is non-empty and not an
//! error"). promptfoo reaches for a `javascript`/`python` assertion here;
//! mcptest uses CEL, the same predicate language SBproxy uses, so a suite
//! author learns one expression dialect across the gateway and the tester.
//! CEL is deterministic and runs in-process with no model and no IO, unlike
//! the llm-judge matchers, so it also negates under `not:`.
//!
//! The value at `target` is bound into the context as the variable `value`,
//! so `cel: "value.content.size() > 0 && !value.isError"` asserts over the
//! resolved target. Omit `target` (or set it to "") to bind the whole
//! response envelope as `value`. The expression must return a boolean: a
//! parse error, an evaluation error, or a non-boolean result is a suite bug
//! (an evaluation error), not a failed assertion.

use std::collections::HashMap;
use std::sync::Arc;

use serde_json::Value;

use super::{AssertionDiff, AssertionResult, MatcherKind};
use crate::{Error, Result};

/// Evaluate a `cel:` predicate against `actual`.
///
/// `program` is the CEL source. `actual` is the value resolved from the
/// response at the assertion's `target`; it is bound into the context as the
/// variable `value`. Returns a passing result when the expression is `true`,
/// a single-diff failure (carrying the expression) when it is `false`, and an
/// [`Error::InvalidMatcherInput`] when the expression fails to compile, fails
/// to evaluate, or returns a non-boolean.
pub fn evaluate(program: &str, actual: &Value) -> Result<AssertionResult> {
    let compiled = cel::Program::compile(program)
        .map_err(|e| Error::InvalidMatcherInput(format!("cel: parse error in `{program}`: {e}")))?;
    let mut context = cel::Context::default();
    context.add_variable_from_value("value", json_to_cel(actual));
    let outcome = compiled.execute(&context).map_err(|e| {
        Error::InvalidMatcherInput(format!("cel: evaluation error in `{program}`: {e}"))
    })?;
    match outcome {
        cel::Value::Bool(true) => Ok(AssertionResult::passed(MatcherKind::Cel)),
        cel::Value::Bool(false) => Ok(AssertionResult::failed(
            MatcherKind::Cel,
            diff(
                actual,
                format!("cel expression `{program}` evaluated to false"),
            ),
        )),
        other => Err(Error::InvalidMatcherInput(format!(
            "cel: expression `{program}` must return a boolean, got {other:?}"
        ))),
    }
}

/// Convert a JSON value into a CEL value.
///
/// JSON has one number type; CEL distinguishes `int`, `uint`, and `double`.
/// We map any integer that fits in `i64` to CEL `int` so a bare numeric
/// literal in an expression (`value == 42`, which CEL types as `int`)
/// compares equal to a JSON number. Integers beyond `i64` fall back to
/// `uint`, and non-integers to `double`. We convert explicitly rather than
/// via serde so the integer mapping is predictable: serde routes positive
/// integers through `u64`, which would yield a `uint` that does not equal an
/// `int` literal.
fn json_to_cel(value: &Value) -> cel::Value {
    match value {
        Value::Null => cel::Value::Null,
        Value::Bool(b) => cel::Value::Bool(*b),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                cel::Value::Int(i)
            } else if let Some(u) = n.as_u64() {
                cel::Value::UInt(u)
            } else {
                cel::Value::Float(n.as_f64().unwrap_or(0.0))
            }
        }
        Value::String(s) => cel::Value::String(Arc::new(s.clone())),
        Value::Array(items) => cel::Value::List(Arc::new(items.iter().map(json_to_cel).collect())),
        Value::Object(fields) => {
            let mut map = HashMap::new();
            for (key, val) in fields {
                map.insert(cel::objects::Key::from(key.clone()), json_to_cel(val));
            }
            cel::Value::Map(cel::objects::Map { map: Arc::new(map) })
        }
    }
}

/// Build the single-diff failure shape shared by `cel` failures: the whole
/// value with a note naming the expression that did not hold.
fn diff(actual: &Value, note: String) -> AssertionDiff {
    AssertionDiff {
        path: String::new(),
        expected: None,
        actual: Some(actual.clone()),
        note: Some(note),
    }
}

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

    #[test]
    fn passes_when_predicate_is_true() {
        let actual = json!({"content": [{"text": "hi"}], "isError": false});
        let r = evaluate("value.content.size() > 0 && !value.isError", &actual).unwrap();
        assert!(r.pass);
        assert_eq!(r.kind, MatcherKind::Cel);
    }

    #[test]
    fn fails_when_predicate_is_false() {
        let actual = json!({"content": [], "isError": true});
        let r = evaluate("value.content.size() > 0 && !value.isError", &actual).unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs.len(), 1);
        let note = r.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("evaluated to false"));
    }

    #[test]
    fn binds_scalar_targets_too() {
        let r = evaluate("value == 42", &json!(42)).unwrap();
        assert!(r.pass);
        let r = evaluate("value == 42", &json!(7)).unwrap();
        assert!(!r.pass);
    }

    #[test]
    fn string_predicates_work() {
        let r = evaluate("value.startsWith(\"ok\")", &json!("ok: done")).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn parse_error_is_an_evaluation_error_not_a_failed_assertion() {
        let err = evaluate("value.. bogus", &json!({})).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)));
    }

    #[test]
    fn non_boolean_result_is_an_evaluation_error() {
        let err = evaluate("value.content", &json!({"content": [1, 2]})).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("must return a boolean")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }
}
