//! The expected-trace vocabulary: argument comparators, expected calls,
//! the five trajectory match modes, and the [`validate_trace`] dispatch.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::engine;
use crate::matchers::{contains, schema, AssertionDiff};
use crate::Result;

/// How an expected call's arguments must relate to the recorded
/// arguments object.
///
/// Each variant delegates to an existing matcher so the semantics match
/// what suite authors already know from the YAML `contains:` and
/// `schema:` matchers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedArgs {
    /// Arguments may be anything (including absent). Use when only the
    /// function name matters at this position.
    Any,
    /// Skip argument checking for this call entirely, the same as `Any`
    /// for matching but named to read as a deliberate "do not look at the
    /// args here" rather than "args can be anything." Use when a tool's
    /// arguments are noisy or nondeterministic and only the call's
    /// presence matters.
    Ignore,
    /// Recorded args must deep-equal this value. Field order is
    /// normalized by `serde_json` so key ordering does not matter.
    Exact(Value),
    /// Recorded args must be a superset of this value, with object-subset
    /// and multiset-array semantics. Delegates to
    /// `crate::matchers::contains`, so extra keys in the recorded args are
    /// allowed.
    Subset(Value),
    /// Recorded args must validate against this JSON Schema document.
    /// Delegates to `crate::matchers::schema`.
    Schema(Value),
}

impl ExpectedArgs {
    /// Compare `actual` recorded arguments against this expectation.
    ///
    /// Returns `Ok(None)` on a match. Returns `Ok(Some(diffs))` with one
    /// or more diffs when the shape does not satisfy the expectation.
    /// Returns `Err` only on a structural problem with the expectation
    /// itself (a malformed JSON Schema), mirroring the matcher contract.
    fn check(&self, actual: &Value) -> Result<Option<Vec<AssertionDiff>>> {
        match self {
            ExpectedArgs::Any | ExpectedArgs::Ignore => Ok(None),
            ExpectedArgs::Exact(expected) => {
                if expected == actual {
                    Ok(None)
                } else {
                    Ok(Some(vec![AssertionDiff {
                        path: String::new(),
                        expected: Some(expected.clone()),
                        actual: Some(actual.clone()),
                        note: Some("args are not deep-equal to expected".into()),
                    }]))
                }
            }
            ExpectedArgs::Subset(expected) => {
                let result = contains::evaluate(expected, actual)?;
                if result.pass {
                    Ok(None)
                } else {
                    Ok(Some(result.diffs))
                }
            }
            ExpectedArgs::Schema(doc) => {
                let result = schema::evaluate(doc, actual)?;
                if result.pass {
                    Ok(None)
                } else {
                    Ok(Some(result.diffs))
                }
            }
        }
    }
}

/// One expected tool call in a trace: a function name plus an argument
/// shape.
///
/// This is the "AST node" the validator matches against a recorded call.
/// The name must be exactly equal to the recorded `tool_calls[i].name`;
/// the args satisfy the chosen [`ExpectedArgs`] shape.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExpectedCall {
    /// Function (tool) name the recorded call must carry, without the
    /// `<server>__` wire prefix, matching the trace's
    /// `tool_calls[i].name`.
    pub name: String,
    /// Required shape of the recorded call's `args` object.
    pub args: ExpectedArgs,
}

impl ExpectedCall {
    /// Build an expected call that only pins the function name and
    /// accepts any arguments.
    pub fn by_name(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            args: ExpectedArgs::Any,
        }
    }

    /// True when `actual` (one entry from the trace's `tool_calls` array)
    /// matches this expected call on both name and argument shape.
    ///
    /// Returns the per-call diffs when it does not match. A name mismatch
    /// short-circuits with a single diff and does not check the args. A
    /// malformed JSON Schema in [`ExpectedArgs::Schema`] surfaces as an
    /// `Err`, never as a silent pass.
    pub(super) fn match_call(&self, actual: &Value) -> Result<Option<Vec<AssertionDiff>>> {
        let actual_name = actual.get("name").and_then(Value::as_str);
        if actual_name != Some(self.name.as_str()) {
            return Ok(Some(vec![AssertionDiff {
                path: "/name".into(),
                expected: Some(Value::String(self.name.clone())),
                actual: actual.get("name").cloned(),
                note: Some(format!(
                    "expected tool name `{}`, recorded call has `{}`",
                    self.name,
                    actual_name.unwrap_or("<none>")
                )),
            }]));
        }
        // `args` is free-form JSON on the trace; treat a missing key as an
        // empty object so an expected `Any` or empty-subset still matches.
        let actual_args = actual.get("args").cloned().unwrap_or(Value::Null);
        self.args.check(&actual_args)
    }
}

/// Whether and how the expected calls must line up against the recorded
/// calls.
///
/// The five modes form a named trajectory-assertion vocabulary. Read
/// against the same set of reference (expected) calls:
///
/// - [`MatchMode::Strict`]: exact one-for-one positional match. Lengths
///   must be equal, every position must match, no extra and no missing
///   calls, and order matters. The tightest mode; the contract is the full
///   call plan. (Alias of the original `ExactSequence`.)
/// - [`MatchMode::ExactSequence`]: the historical name for
///   [`MatchMode::Strict`], kept for compatibility. Identical semantics.
/// - [`MatchMode::Subsequence`]: every reference call must appear, in the
///   reference order, somewhere among the recorded calls, with other
///   recorded calls allowed between them. Order-preserving but tolerant of
///   interleaved extras.
/// - [`MatchMode::Unordered`]: every reference call must appear (matched
///   one-to-one against distinct recorded calls), in any order. Order does
///   not matter and extra recorded calls are allowed.
/// - [`MatchMode::Subset`]: the recorded calls must be a subset of the
///   reference calls, i.e. no call the reference did not allow. This is the
///   over-calling / wasted-call detector: it fails when the trace made a
///   call beyond the reference set.
/// - [`MatchMode::Superset`]: at least the reference calls must be present
///   (each matched against a distinct recorded call), in any order; extra
///   recorded calls are allowed. The complement of `Subset`.
///
/// How they relate: `Strict` is the intersection of order and count
/// constraints. Dropping the "no extras" constraint but keeping order
/// gives `Subsequence`. Dropping order entirely but requiring every
/// reference call gives `Superset` (extras allowed) or, when extras are
/// also forbidden, `Unordered` collapses toward `Strict` modulo order.
/// `Subset` inverts the containment direction: where `Superset` asks
/// "are all reference calls present?", `Subset` asks "are all recorded
/// calls allowed?".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchMode {
    /// Exact one-for-one positional match: lengths must be equal and every
    /// position must match, in order, with no extras. Alias of
    /// [`MatchMode::ExactSequence`].
    Strict,
    /// One-for-one positional match: lengths must be equal and every
    /// position must match. The historical name for [`MatchMode::Strict`].
    ExactSequence,
    /// In-order containment: expected calls must appear in order, extra
    /// recorded calls between them are allowed.
    Subsequence,
    /// Every reference call must appear, matched one-to-one against
    /// distinct recorded calls, in any order; extras allowed.
    Unordered,
    /// Every recorded call must be allowed by the reference set (the trace
    /// is a subset of the reference). Fails on any over-call.
    Subset,
    /// At least the reference calls must be present, in any order; extras
    /// allowed. The complement of [`MatchMode::Subset`].
    Superset,
}

/// An ordered list of expected calls plus the mode used to line them up
/// against a recorded trace.
///
/// This is the full "expected AST" for a trace: a `Vec<ExpectedCall>`
/// read under one [`MatchMode`]. An empty expected list is trivially
/// satisfied by any trace under every order-or-superset mode; under
/// [`MatchMode::Subset`] an empty reference means "no calls allowed," so a
/// non-empty trace fails (see [`validate_trace`]).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExpectedTrace {
    /// The expected (reference) calls. Order matters only for the ordered
    /// modes (`Strict`/`ExactSequence` and `Subsequence`).
    pub calls: Vec<ExpectedCall>,
    /// How to line the expected calls up against the recorded calls.
    pub mode: MatchMode,
}

impl ExpectedTrace {
    /// Build a strict (exact one-for-one, ordered) expectation.
    pub fn strict(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::Strict,
        }
    }

    /// Build an exact-sequence expectation. Identical to
    /// [`ExpectedTrace::strict`]; kept for callers written against the
    /// original  name.
    pub fn exact(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::ExactSequence,
        }
    }

    /// Build a subsequence (in-order-contains) expectation.
    pub fn subsequence(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::Subsequence,
        }
    }

    /// Build an unordered expectation: every reference call present, any
    /// order, extras allowed.
    pub fn unordered(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::Unordered,
        }
    }

    /// Build a subset expectation: every recorded call must be allowed by
    /// the reference set (the over-calling detector).
    pub fn subset(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::Subset,
        }
    }

    /// Build a superset expectation: at least the reference calls present,
    /// any order, extras allowed.
    pub fn superset(calls: Vec<ExpectedCall>) -> Self {
        Self {
            calls,
            mode: MatchMode::Superset,
        }
    }
}

/// One position that failed to match during trace validation.
///
/// `expected_index` is the position in [`ExpectedTrace::calls`].
/// `actual_index` is the position in the recorded `tool_calls` array the
/// validator was looking at, or `None` when the trace ran out of calls
/// before the expected call could be matched. The `diffs` are the
/// per-call diffs from the underlying matcher, so reporters render them
/// the same way they render any other [`AssertionDiff`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TraceMismatch {
    /// Index into [`ExpectedTrace::calls`] of the expected call that did
    /// not match, or the count of reference calls when the failure is
    /// about a recorded call with no reference (an over-call).
    pub expected_index: usize,
    /// Index into the recorded `tool_calls` array, or `None` when the
    /// trace had no call left to compare against.
    pub actual_index: Option<usize>,
    /// Per-call diffs explaining the mismatch. Empty only when the
    /// failure was structural (trace ended early, missing call, or
    /// over-call), in which case `note` carries the explanation.
    pub diffs: Vec<AssertionDiff>,
    /// Human-readable one-line reason, for the failure summary.
    pub note: String,
}

/// Outcome of validating a recorded trace against an [`ExpectedTrace`].
///
/// `passed` is the headline boolean the CLI gates on; `mismatches`
/// carries the per-position detail for the reporter. A pass always has an
/// empty `mismatches` list.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TraceValidation {
    /// True when every expected call matched under the chosen mode.
    pub passed: bool,
    /// Per-position mismatches, in expected-call order. Empty on a pass.
    pub mismatches: Vec<TraceMismatch>,
}

impl TraceValidation {
    /// A clean pass with no mismatches.
    pub(super) fn pass() -> Self {
        Self {
            passed: true,
            mismatches: Vec::new(),
        }
    }
}

/// Validate a recorded trace's tool calls against an [`ExpectedTrace`].
///
/// `actual_tool_calls` is the slice of recorded calls, each a
/// `serde_json::Value` object with at least `name` and `args` keys (the
/// shape produced by `ConversationTrace::to_envelope` in the agent
/// crate). Pull this slice out of a trace or cassette envelope with
/// [`tool_calls_from_envelope`].
///
/// AST-style: each [`ExpectedCall`] must structurally match a recorded
/// call (name equal, args satisfy the [`ExpectedArgs`]) under the
/// [`MatchMode`] chosen on the [`ExpectedTrace`]. The function never
/// executes a model and never performs I/O.
///
/// Returns `Err` only on a structural problem with an expectation (a
/// malformed JSON Schema). A normal shape mismatch is a successful
/// validation that returns `passed = false`, mirroring the matcher
/// contract where a failed assertion is not an error.
pub fn validate_trace(
    actual_tool_calls: &[Value],
    expected: &ExpectedTrace,
) -> Result<TraceValidation> {
    // An empty reference is trivially satisfied for every mode except
    // Subset, where "no reference calls" means "no calls allowed," so a
    // non-empty trace is an over-call.
    if expected.calls.is_empty() && !matches!(expected.mode, MatchMode::Subset) {
        return Ok(TraceValidation::pass());
    }
    match expected.mode {
        MatchMode::Strict | MatchMode::ExactSequence => {
            engine::validate_exact_sequence(actual_tool_calls, &expected.calls)
        }
        MatchMode::Subsequence => engine::validate_subsequence(actual_tool_calls, &expected.calls),
        MatchMode::Unordered | MatchMode::Superset => {
            engine::validate_all_present(actual_tool_calls, &expected.calls)
        }
        MatchMode::Subset => engine::validate_subset(actual_tool_calls, &expected.calls),
    }
}

/// Pull the `tool_calls` array out of a trace or cassette envelope.
///
/// A trace envelope (from `ConversationTrace::to_envelope`) has
/// `tool_calls` at the root. A cassette envelope nests it under
/// `trace.tool_calls`. This helper checks the cassette nesting first,
/// then the root, and returns an empty slice's worth of an owned `Vec`
/// when neither is present so callers do not have to special-case a trace
/// with no calls. The values are cloned so the caller owns them
/// independently of the envelope.
pub fn tool_calls_from_envelope(envelope: &Value) -> Vec<Value> {
    let array = envelope
        .get("trace")
        .and_then(|t| t.get("tool_calls"))
        .or_else(|| envelope.get("tool_calls"))
        .and_then(Value::as_array);
    match array {
        Some(calls) => calls.clone(),
        None => Vec::new(),
    }
}

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

    fn call(name: &str, args: Value) -> Value {
        json!({"name": name, "server": "s", "args": args})
    }

    #[test]
    fn strict_match_passes() {
        let actual = vec![
            call("search", json!({"q": "rust"})),
            call("open", json!({"id": 7})),
        ];
        let expected = ExpectedTrace::strict(vec![
            ExpectedCall {
                name: "search".into(),
                args: ExpectedArgs::Subset(json!({"q": "rust"})),
            },
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(result.passed, "mismatches: {:?}", result.mismatches);
    }

    #[test]
    fn exact_alias_matches_strict_semantics() {
        let actual = vec![call("search", json!({"q": "rust"}))];
        let strict = ExpectedTrace::strict(vec![ExpectedCall::by_name("search")]);
        let exact = ExpectedTrace::exact(vec![ExpectedCall::by_name("search")]);
        let s = validate_trace(&actual, &strict).expect("ok");
        let e = validate_trace(&actual, &exact).expect("ok");
        assert!(s.passed);
        assert!(e.passed);
    }

    #[test]
    fn wrong_name_at_position_fails_with_name_diff() {
        let actual = vec![call("delete", json!({}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall::by_name("create")]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        assert_eq!(result.mismatches.len(), 1);
        let m = &result.mismatches[0];
        assert_eq!(m.expected_index, 0);
        assert_eq!(m.actual_index, Some(0));
        assert_eq!(m.diffs[0].path, "/name");
        assert_eq!(m.diffs[0].expected, Some(json!("create")));
        assert_eq!(m.diffs[0].actual, Some(json!("delete")));
    }

    #[test]
    fn wrong_arg_shape_fails_via_subset_semantics() {
        // Reuses contains: a missing required key is the failure.
        let actual = vec![call("search", json!({"q": "rust"}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "search".into(),
            args: ExpectedArgs::Subset(json!({"q": "rust", "limit": 10})),
        }]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        let m = &result.mismatches[0];
        assert_eq!(m.expected_index, 0);
        assert!(m
            .diffs
            .iter()
            .any(|d| d.note.as_deref().unwrap_or("").contains("missing key")));
    }

    #[test]
    fn wrong_arg_shape_fails_via_schema_semantics() {
        // Reuses schema: a type mismatch on a property is the failure.
        let actual = vec![call("open", json!({"id": "seven"}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "open".into(),
            args: ExpectedArgs::Schema(json!({
                "type": "object",
                "properties": {"id": {"type": "integer"}}
            })),
        }]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        let m = &result.mismatches[0];
        assert_eq!(m.diffs[0].path, "/id");
    }

    #[test]
    fn exact_match_args_requires_deep_equality() {
        let actual = vec![call("open", json!({"id": 7, "extra": true}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "open".into(),
            args: ExpectedArgs::Exact(json!({"id": 7})),
        }]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        // Exact does not tolerate the extra key the way Subset would.
        assert!(!result.passed);
        assert!(result.mismatches[0].diffs[0]
            .note
            .as_deref()
            .unwrap_or("")
            .contains("deep-equal"));
    }

    #[test]
    fn ignore_args_skips_argument_checking() {
        // A call whose args would fail every concrete comparator still
        // passes under Ignore, because Ignore looks only at the name.
        let actual = vec![call("noisy", json!({"nonce": "ab12", "ts": 99}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "noisy".into(),
            args: ExpectedArgs::Ignore,
        }]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(result.passed, "mismatches: {:?}", result.mismatches);
    }

    #[test]
    fn ignore_args_still_enforces_the_name() {
        let actual = vec![call("wrong", json!({}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "right".into(),
            args: ExpectedArgs::Ignore,
        }]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        assert_eq!(result.mismatches[0].diffs[0].path, "/name");
    }

    #[test]
    fn strict_fails_when_trace_has_extra_trailing_calls() {
        let actual = vec![call("search", json!({})), call("open", json!({}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall::by_name("search")]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        assert!(result
            .mismatches
            .iter()
            .any(|m| m.note.contains("extra call")));
    }

    #[test]
    fn strict_fails_on_reorder() {
        // Same two calls, swapped order: strict is position-sensitive.
        let actual = vec![call("open", json!({})), call("search", json!({}))];
        let expected = ExpectedTrace::strict(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        // Both positions mismatch on name.
        assert_eq!(result.mismatches.len(), 2);
        assert!(result.mismatches.iter().all(|m| m.diffs[0].path == "/name"));
    }

    #[test]
    fn strict_fails_when_trace_ends_early() {
        let actual = vec![call("search", json!({}))];
        let expected = ExpectedTrace::strict(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        let m = &result.mismatches[0];
        assert_eq!(m.expected_index, 1);
        assert_eq!(m.actual_index, None);
        assert!(m.note.contains("trace ended"));
    }

    #[test]
    fn subsequence_allows_extra_interleaved_calls() {
        let actual = vec![
            call("login", json!({})),
            call("search", json!({"q": "rust"})),
            call("log_event", json!({})),
            call("open", json!({"id": 7})),
        ];
        let expected = ExpectedTrace::subsequence(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(result.passed, "mismatches: {:?}", result.mismatches);
    }

    #[test]
    fn subsequence_fails_when_order_is_wrong() {
        // open comes before search, so the in-order search-then-open
        // subsequence is not satisfied.
        let actual = vec![call("open", json!({})), call("search", json!({}))];
        let expected = ExpectedTrace::subsequence(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        let m = &result.mismatches[0];
        // search is found at index 1, open is never found after it.
        assert_eq!(m.expected_index, 1);
        assert!(m.note.contains("not found in order"));
    }

    #[test]
    fn unordered_passes_regardless_of_order() {
        let forward = vec![call("search", json!({})), call("open", json!({}))];
        let reversed = vec![call("open", json!({})), call("search", json!({}))];
        let expected = ExpectedTrace::unordered(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        assert!(validate_trace(&forward, &expected).expect("ok").passed);
        assert!(validate_trace(&reversed, &expected).expect("ok").passed);
    }

    #[test]
    fn unordered_tolerates_extra_calls() {
        let actual = vec![
            call("noise", json!({})),
            call("open", json!({})),
            call("search", json!({})),
        ];
        let expected = ExpectedTrace::unordered(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        assert!(validate_trace(&actual, &expected).expect("ok").passed);
    }

    #[test]
    fn unordered_fails_when_a_reference_call_is_missing() {
        let actual = vec![call("search", json!({}))];
        let expected = ExpectedTrace::unordered(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("ok");
        assert!(!result.passed);
        assert_eq!(result.mismatches.len(), 1);
        assert_eq!(result.mismatches[0].expected_index, 1);
        assert!(result.mismatches[0].note.contains("not found anywhere"));
    }

    #[test]
    fn superset_passes_with_extras() {
        let actual = vec![
            call("search", json!({})),
            call("open", json!({})),
            call("extra", json!({})),
        ];
        let expected = ExpectedTrace::superset(vec![
            ExpectedCall::by_name("open"),
            ExpectedCall::by_name("search"),
        ]);
        // Any order, extras tolerated.
        assert!(validate_trace(&actual, &expected).expect("ok").passed);
    }

    #[test]
    fn superset_fails_when_a_reference_call_is_missing() {
        let actual = vec![call("search", json!({})), call("extra", json!({}))];
        let expected = ExpectedTrace::superset(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("ok");
        assert!(!result.passed);
        assert!(result.mismatches.iter().any(|m| m.note.contains("open")));
    }

    #[test]
    fn subset_passes_when_trace_calls_fewer_than_reference() {
        // Only one of the two allowed calls was made: still a subset.
        let actual = vec![call("search", json!({}))];
        let expected = ExpectedTrace::subset(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        assert!(validate_trace(&actual, &expected).expect("ok").passed);
    }

    #[test]
    fn subset_fails_on_an_extra_over_call() {
        let actual = vec![
            call("search", json!({})),
            call("delete_everything", json!({})),
        ];
        let expected = ExpectedTrace::subset(vec![
            ExpectedCall::by_name("search"),
            ExpectedCall::by_name("open"),
        ]);
        let result = validate_trace(&actual, &expected).expect("ok");
        assert!(!result.passed);
        let m = &result.mismatches[0];
        assert_eq!(m.actual_index, Some(1));
        assert!(m.note.contains("over-call"));
    }

    #[test]
    fn subset_with_empty_reference_rejects_any_call() {
        // No reference calls means no calls are allowed.
        let actual = vec![call("anything", json!({}))];
        let result = validate_trace(&actual, &ExpectedTrace::subset(vec![])).expect("ok");
        assert!(!result.passed);
        // An empty trace under empty subset is fine: nothing to over-call.
        let empty: Vec<Value> = Vec::new();
        assert!(
            validate_trace(&empty, &ExpectedTrace::subset(vec![]))
                .expect("ok")
                .passed
        );
    }

    #[test]
    fn empty_expected_is_trivially_satisfied_for_order_and_superset_modes() {
        let actual = vec![call("anything", json!({"x": 1}))];
        assert!(
            validate_trace(&actual, &ExpectedTrace::strict(vec![]))
                .expect("ok")
                .passed
        );
        assert!(
            validate_trace(&actual, &ExpectedTrace::exact(vec![]))
                .expect("ok")
                .passed
        );
        assert!(
            validate_trace(&actual, &ExpectedTrace::subsequence(vec![]))
                .expect("ok")
                .passed
        );
        assert!(
            validate_trace(&actual, &ExpectedTrace::unordered(vec![]))
                .expect("ok")
                .passed
        );
        assert!(
            validate_trace(&actual, &ExpectedTrace::superset(vec![]))
                .expect("ok")
                .passed
        );
    }

    #[test]
    fn malformed_schema_surfaces_as_error_not_silent_pass() {
        let actual = vec![call("open", json!({"id": 7}))];
        let expected = ExpectedTrace::strict(vec![ExpectedCall {
            name: "open".into(),
            // `type` must be a string or array, 42 is neither.
            args: ExpectedArgs::Schema(json!({"type": 42})),
        }]);
        let err = validate_trace(&actual, &expected).expect_err("malformed schema");
        assert!(matches!(err, crate::Error::InvalidSchema(_)));
    }

    #[test]
    fn malformed_schema_surfaces_in_unordered_scan() {
        // The unordered scan must also propagate a schema error rather
        // than treating the probe failure as a non-match.
        let actual = vec![call("open", json!({"id": 7}))];
        let expected = ExpectedTrace::unordered(vec![ExpectedCall {
            name: "open".into(),
            args: ExpectedArgs::Schema(json!({"type": 42})),
        }]);
        let err = validate_trace(&actual, &expected).expect_err("malformed schema");
        assert!(matches!(err, crate::Error::InvalidSchema(_)));
    }

    #[test]
    fn tool_calls_pulled_from_root_trace_envelope() {
        let envelope = json!({
            "tool_calls": [call("search", json!({"q": "rust"}))],
            "final_response": "done"
        });
        let calls = tool_calls_from_envelope(&envelope);
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0]["name"], json!("search"));
    }

    #[test]
    fn tool_calls_pulled_from_nested_cassette_envelope() {
        let envelope = json!({
            "schema_version": "v1",
            "trace": {
                "tool_calls": [call("open", json!({"id": 1}))],
                "final_response": ""
            }
        });
        let calls = tool_calls_from_envelope(&envelope);
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0]["name"], json!("open"));
    }

    #[test]
    fn missing_tool_calls_yields_empty_slice() {
        let envelope = json!({"final_response": "no calls"});
        assert!(tool_calls_from_envelope(&envelope).is_empty());
    }

    #[test]
    fn call_without_name_key_fails_name_check() {
        let actual = vec![json!({"args": {}})];
        let expected = ExpectedTrace::strict(vec![ExpectedCall::by_name("search")]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(!result.passed);
        assert_eq!(result.mismatches[0].diffs[0].actual, None);
    }

    #[test]
    fn missing_args_treated_as_null_for_any_match() {
        // A call object with no args key still matches an Any expectation.
        let actual = vec![json!({"name": "ping"})];
        let expected = ExpectedTrace::strict(vec![ExpectedCall::by_name("ping")]);
        let result = validate_trace(&actual, &expected).expect("no schema error");
        assert!(result.passed, "mismatches: {:?}", result.mismatches);
    }
}
