//! JSONPath extraction and projection helpers.
//!
//! Why this exists: both `mcptest tools call` (the `--arg name=$.path`,
//! `--bind`, and `--select` flags) and `mcptest pipe` (the `${step.field}`
//! reference syntax) need to pull values out of a tool's JSON output without
//! the user reaching for `jq`. Centralizing the query logic keeps the two
//! surfaces consistent: a path that works in `--select` works in a pipeline
//! reference and vice versa.
//!
//! We wrap [`serde_json_path`] so callers get a single typed error and a
//! "extract one value" convenience that picks the natural shape (scalar when
//! the path matches one node, array when it matches many).

use serde_json::Value;
use serde_json_path::JsonPath;
use thiserror::Error;

/// Errors raised while compiling or evaluating a JSONPath expression.
///
/// Surfaced to the CLI boundary as `anyhow` context so a typo in a
/// `--arg` path or a `${...}` reference reports the offending expression
/// rather than a deep parser panic.
#[derive(Debug, Error)]
pub enum JsonPathError {
    /// The path string did not parse as a JSONPath expression.
    #[error("invalid JSONPath `{path}`: {source}")]
    Parse {
        /// The offending path, echoed back for the user.
        path: String,
        /// The underlying parser error.
        source: serde_json_path::ParseError,
    },
    /// The path compiled but matched no node in the input.
    #[error("JSONPath `{path}` matched nothing in the input")]
    NoMatch {
        /// The path that came up empty.
        path: String,
    },
}

/// Compile a JSONPath string, normalizing the bare-dollar shorthand.
///
/// Users write `$.id` or `$.users[0].name`; both are already valid
/// JSONPath. A naked `$` (the whole document) is also accepted. We keep
/// this in one place so the error message format is identical everywhere.
fn compile(path: &str) -> Result<JsonPath, JsonPathError> {
    JsonPath::parse(path).map_err(|source| JsonPathError::Parse {
        path: path.to_string(),
        source,
    })
}

/// Extract a single value for a `--arg name=$.path` or `${step.field}`
/// reference.
///
/// The result shape follows the match count so the common case reads
/// naturally: one matched node returns that node directly (a scalar stays
/// a scalar), and multiple matches return a JSON array. An empty match is
/// an error because a reference that resolves to nothing is almost always
/// a user mistake worth surfacing loudly.
pub fn extract_one(input: &Value, path: &str) -> Result<Value, JsonPathError> {
    let query = compile(path)?;
    let nodes = query.query(input);
    match nodes.len() {
        0 => Err(JsonPathError::NoMatch {
            path: path.to_string(),
        }),
        1 => Ok(nodes.first().cloned().expect("len checked to be 1")),
        _ => Ok(Value::Array(nodes.all().into_iter().cloned().collect())),
    }
}

/// Project a value with a JSONPath for `--select`.
///
/// Unlike [`extract_one`], a select that matches nothing returns an empty
/// array rather than an error: `--select $.items` over a response with no
/// items should print `[]`, mirroring `jq` semantics, not abort. A single
/// match returns the node directly so `--select $.count` prints the number.
pub fn project(input: &Value, path: &str) -> Result<Value, JsonPathError> {
    let query = compile(path)?;
    let nodes = query.query(input);
    match nodes.len() {
        1 => Ok(nodes.first().cloned().expect("len checked to be 1")),
        _ => Ok(Value::Array(nodes.all().into_iter().cloned().collect())),
    }
}

/// Whether a JSONPath matches at least one node, used by `when:` guards.
///
/// A conditional step runs when its guard path resolves to a truthy value.
/// This helper answers the "does anything match?" half; truthiness of the
/// matched value is decided by the caller in [`crate::pipe::reference`].
pub fn matches_any(input: &Value, path: &str) -> Result<bool, JsonPathError> {
    let query = compile(path)?;
    Ok(!query.query(input).is_empty())
}

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

    fn sample() -> Value {
        json!({
            "id": "abc",
            "users": [
                { "id": 1, "name": "alice" },
                { "id": 2, "name": "bob" }
            ],
            "count": 2,
            "items": []
        })
    }

    #[test]
    fn extract_one_returns_scalar_for_single_match() {
        let v = extract_one(&sample(), "$.id").unwrap();
        assert_eq!(v, json!("abc"));
    }

    #[test]
    fn extract_one_returns_array_for_multiple_matches() {
        let v = extract_one(&sample(), "$.users[*].name").unwrap();
        assert_eq!(v, json!(["alice", "bob"]));
    }

    #[test]
    fn extract_one_indexes_into_arrays() {
        let v = extract_one(&sample(), "$.users[0].id").unwrap();
        assert_eq!(v, json!(1));
    }

    #[test]
    fn extract_one_errors_on_no_match() {
        let err = extract_one(&sample(), "$.missing").unwrap_err();
        assert!(matches!(err, JsonPathError::NoMatch { .. }));
    }

    #[test]
    fn extract_one_errors_on_bad_syntax() {
        let err = extract_one(&sample(), "$[").unwrap_err();
        assert!(matches!(err, JsonPathError::Parse { .. }));
    }

    #[test]
    fn project_empty_match_yields_empty_array() {
        let v = project(&sample(), "$.items[*]").unwrap();
        assert_eq!(v, json!([]));
    }

    #[test]
    fn project_single_match_yields_scalar() {
        let v = project(&sample(), "$.count").unwrap();
        assert_eq!(v, json!(2));
    }

    #[test]
    fn matches_any_is_true_when_path_resolves() {
        assert!(matches_any(&sample(), "$.users[*]").unwrap());
    }

    #[test]
    fn matches_any_is_false_when_path_is_empty() {
        assert!(!matches_any(&sample(), "$.items[*]").unwrap());
    }
}
