//! Shared result type for every matcher.
//!
//! A matcher returns an [`AssertionResult`]. Two reasons it is a struct
//! rather than a `Result<(), FailureReport>`:
//!
//! 1. A passing assertion still carries metadata reporters want
//!    (which matcher ran, how long it took, captured regex variables).
//! 2. The CLI prints failing assertions in a structured diff. Hiding the
//!    diff inside an `Err` variant pushes it through `?` and loses
//!    fidelity.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// The matcher that produced an [`AssertionResult`].
///
/// Used by reporters for grouping and for color-coding in the pretty
/// reporter. New variants are added at the end so downstream `match` arms
/// keep compiling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum MatcherKind {
    /// Deep-equality matcher (`exact:`).
    Exact,
    /// JSON Schema validator (`schema:`).
    Schema,
    /// Regex matcher (`regex:`).
    Regex,
    /// Subset / superset matcher (`contains:`).
    Contains,
    /// Record-and-diff snapshot matcher (`snapshot:`).
    Snapshot,
    /// LLM-as-judge matcher (`llm-judge:`). Stub in v1.0; full impl in W7.
    LlmJudge,
    /// LLM-as-jury matcher (`llm-jury:`). N judges with a
    /// quorum threshold.
    LlmJury,
    /// Duration budget (`max_duration_ms:`).
    Duration,
    /// Response token budget (`max_response_tokens:`).
    ResponseTokens,
    /// Response header assertions (`response_headers:` and
    /// `response_headers_absent:`).
    ResponseHeaders,
    /// Every-substring / every-element containment (`contains-all:`).
    ContainsAll,
    /// At-least-one-substring / element containment (`contains-any:`).
    ContainsAny,
    /// Case-insensitive substring containment (`icontains:`).
    IContains,
    /// String prefix check (`starts-with:`).
    StartsWith,
    /// JSON-parseability check, optionally with an inline schema
    /// (`is-json:`).
    IsJson,
    /// Well-formed MCP tool-call check (`is-valid-tools-call:`).
    IsValidToolsCall,
    /// Bounded edit-distance check (`levenshtein:`).
    Levenshtein,
    /// Well-formed XML check, optionally with an expected root element
    /// (`is-xml:`).
    IsXml,
    /// SQL-parseability check against the generic dialect (`is-sql:`).
    IsSql,
    /// Embedding cosine-similarity check against a reference string
    /// (`similar:`).
    Similar,
    /// Citation-grounding check: fetch cited sources and judge support
    /// (`citation:`).
    Citation,
    /// CEL boolean predicate over the value (`cel:`). Deterministic and
    /// in-process; the resolved target is bound as the variable `value`.
    Cel,
    /// Universal negation wrapper (`not:`). The inverted
    /// inner matcher's own kind is lost on purpose; reporters group every
    /// negated assertion under one heading.
    Not,
    /// `oneOf` composition wrapper. Carries a list of inner
    /// matchers and passes when exactly one passes. Mirrors JSON Schema
    /// 2020-12 `oneOf` at the matcher level so suites can compose any
    /// matchers, not just schemas.
    OneOf,
    /// `anyOf` composition wrapper. Passes when at least one
    /// inner matcher passes.
    AnyOf,
    /// `allOf` composition wrapper. Passes when every inner
    /// matcher passes.
    AllOf,
}

/// A structured diff produced by a failing assertion.
///
/// The pretty reporter renders this as a side-by-side patch; the JSON
/// reporter emits it as-is. Keeping it field-level (rather than a single
/// rendered string) lets enterprise tooling group similar failures.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssertionDiff {
    /// JSON Pointer (RFC 6901) into the response value where the mismatch
    /// occurred. Empty string means "the whole value." Example: `/result/0/name`.
    pub path: String,
    /// What the rule expected, rendered as JSON. `None` when the rule was
    /// "anything but X" (a negative expectation).
    pub expected: Option<serde_json::Value>,
    /// What the response actually contained. `None` when the response was
    /// missing the field entirely.
    pub actual: Option<serde_json::Value>,
    /// Free-form note from the matcher: a regex pattern that did not match,
    /// the schema validation error, etc. Used to give the user a one-line
    /// "why."
    pub note: Option<String>,
}

/// Outcome of running one matcher against one value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssertionResult {
    /// Which matcher produced this result.
    pub kind: MatcherKind,
    /// True when the response satisfied the expectation.
    pub pass: bool,
    /// Structured diffs for the parts of the value that failed. Empty when
    /// `pass = true`.
    pub diffs: Vec<AssertionDiff>,
    /// Variables captured from regex named groups, keyed by group name.
    /// The runner makes these available to subsequent test steps via
    /// `${capture.NAME}` interpolation. Sorted for determinism.
    pub captures: BTreeMap<String, String>,
}

impl AssertionResult {
    /// Convenience constructor for a clean pass. Most matchers call this
    /// when they have nothing interesting to report.
    pub fn passed(kind: MatcherKind) -> Self {
        Self {
            kind,
            pass: true,
            diffs: Vec::new(),
            captures: BTreeMap::new(),
        }
    }

    /// Convenience constructor for a single-diff failure.
    pub fn failed(kind: MatcherKind, diff: AssertionDiff) -> Self {
        Self {
            kind,
            pass: false,
            diffs: vec![diff],
            captures: BTreeMap::new(),
        }
    }

    /// Convenience constructor for a passing result that carries a note.
    ///
    /// Used by budget and header matchers that need to surface a "skipped"
    /// or "warning" annotation even though the assertion did not fail. The
    /// note is encoded as a single diff entry with no expected/actual so
    /// reporters can render it without a side-by-side patch.
    pub fn passed_with_note(kind: MatcherKind, note: impl Into<String>) -> Self {
        Self {
            kind,
            pass: true,
            diffs: vec![AssertionDiff {
                path: String::new(),
                expected: None,
                actual: None,
                note: Some(note.into()),
            }],
            captures: BTreeMap::new(),
        }
    }
}

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

    #[test]
    fn passed_constructor_has_no_diffs() {
        let r = AssertionResult::passed(MatcherKind::Exact);
        assert!(r.pass);
        assert!(r.diffs.is_empty());
        assert!(r.captures.is_empty());
    }

    #[test]
    fn passed_with_note_attaches_diagnostic() {
        let r = AssertionResult::passed_with_note(MatcherKind::Duration, "skipped: cache hit");
        assert!(r.pass);
        assert_eq!(r.diffs.len(), 1);
        assert_eq!(r.diffs[0].note.as_deref(), Some("skipped: cache hit"));
        assert!(r.diffs[0].expected.is_none());
        assert!(r.diffs[0].actual.is_none());
    }

    #[test]
    fn failed_constructor_carries_diff() {
        let diff = AssertionDiff {
            path: "/foo".into(),
            expected: Some(serde_json::json!("bar")),
            actual: Some(serde_json::json!("baz")),
            note: None,
        };
        let r = AssertionResult::failed(MatcherKind::Exact, diff.clone());
        assert!(!r.pass);
        assert_eq!(r.diffs, vec![diff]);
    }
}
