//! `schema` matcher: validate a response against an inline JSON Schema.
//!
//! Why this matcher exists: MCP servers often return free-form objects whose
//! shape we want to pin without checking every value. JSON Schema lets the
//! user say "must have a `version` string and an `items` array" without
//! enumerating exact values. Each validation error becomes its own
//! [`AssertionDiff`] so the reporter can list them all at once.
//!
//! ## Draft and security
//!
//! The matcher targets JSON Schema 2020-12 (SEP-2106). `jsonschema` 0.30
//! auto-detects the draft from the `$schema` keyword and defaults to
//! 2020-12 when the keyword is absent, so 2020-12 features
//! (`prefixItems`, `unevaluatedProperties`, `if`/`then`/`else`,
//! `oneOf`/`anyOf`/`allOf`, `$defs` with internal `$ref`) work out of
//! the box.
//!
//! Two guards run on top of that:
//!
//! 1. The schema is walked before compile and refused if any `$ref`
//!    points outside the document (anything other than a `#`-rooted
//!    fragment). Without this the validator would attempt to fetch
//!    the URI; we keep validation transport-free.
//! 2. The nesting depth of the schema is capped to keep a pathological
//!    schema (deeply chained `allOf`/`$ref` cycles) from stalling the
//!    runner. The matching iteration itself is wrapped in a wall-clock
//!    budget for the same reason.

use std::time::{Duration, Instant};

use serde_json::Value;

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

/// Default cap on schema nesting depth. Real-world schemas almost never
/// reach a depth above 20; the cap is set generously so a hand-authored
/// suite is never the thing that trips it.
pub const DEFAULT_MAX_SCHEMA_DEPTH: usize = 64;

/// Default wall-clock budget for a single validation call. Picked to be
/// large enough that a realistic suite never trips it on the laptop the
/// CLI runs on, and small enough that a runaway iteration stops in well
/// under a `run --timeout-ms` default.
pub const DEFAULT_VALIDATION_BUDGET: Duration = Duration::from_secs(2);

/// Per-call security limits applied to the schema matcher.
///
/// The struct is `#[non_exhaustive]` so we can add a new limit (for
/// example, a maximum total error count) without breaking downstream
/// constructors. Callers should always start from [`Default::default`]
/// and tweak fields.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SchemaLimits {
    /// Maximum nested-schema depth allowed at compile.
    pub max_depth: usize,
    /// Wall-clock budget on validation iteration.
    pub validation_budget: Duration,
    /// Refuse `$ref` URIs that point outside the document. Always true
    /// today; carried as a field so a future enterprise build that
    /// pre-populates a fixture registry can opt in to internal refs.
    pub refuse_external_refs: bool,
}

impl Default for SchemaLimits {
    fn default() -> Self {
        Self {
            max_depth: DEFAULT_MAX_SCHEMA_DEPTH,
            validation_budget: DEFAULT_VALIDATION_BUDGET,
            refuse_external_refs: true,
        }
    }
}

/// Evaluate the `schema` matcher with default security limits.
///
/// Equivalent to [`evaluate_with_limits`] called with
/// [`SchemaLimits::default`]. The split exists so callers that need a
/// tighter budget (large CI suites) can opt in without rewriting every
/// call site.
pub fn evaluate(schema: &Value, actual: &Value) -> Result<AssertionResult> {
    evaluate_with_limits(schema, actual, &SchemaLimits::default())
}

/// Evaluate the `schema` matcher with explicit security limits.
///
/// Order of operations: pre-flight depth and external-`$ref` walk,
/// then compile, then iterate `validator.iter_errors` under a wall-clock
/// budget. Each validation error becomes its own [`AssertionDiff`].
pub fn evaluate_with_limits(
    schema: &Value,
    actual: &Value,
    limits: &SchemaLimits,
) -> Result<AssertionResult> {
    guard_schema(schema, limits)?;

    let validator =
        jsonschema::validator_for(schema).map_err(|e| Error::InvalidSchema(format!("{e}")))?;

    let deadline = Instant::now() + limits.validation_budget;
    let mut diffs: Vec<AssertionDiff> = Vec::new();
    for err in validator.iter_errors(actual) {
        if Instant::now() > deadline {
            return Err(Error::SchemaValidationTimedOut {
                budget_ms: budget_ms(limits.validation_budget),
            });
        }
        diffs.push(AssertionDiff {
            path: err.instance_path.to_string(),
            expected: None,
            actual: Some(err.instance.clone().into_owned()),
            note: Some(err.to_string()),
        });
    }
    if diffs.is_empty() {
        Ok(AssertionResult::passed(MatcherKind::Schema))
    } else {
        Ok(AssertionResult {
            kind: MatcherKind::Schema,
            pass: false,
            diffs,
            captures: Default::default(),
        })
    }
}

/// Pre-flight check: depth and external-`$ref`. Splitting this out keeps
/// [`evaluate_with_limits`] readable and lets the guard be tested in
/// isolation without a real validator.
fn guard_schema(schema: &Value, limits: &SchemaLimits) -> Result<()> {
    let mut depth = 0usize;
    walk(schema, 1, &mut depth, limits)?;
    if depth > limits.max_depth {
        return Err(Error::SchemaTooDeep {
            depth,
            max: limits.max_depth,
        });
    }
    Ok(())
}

/// Recursive walk that records the maximum observed depth and rejects
/// external `$ref` strings. We do this rather than scanning serialized
/// text so a single `$ref` inside an array or a deeply nested object is
/// still caught.
fn walk(node: &Value, level: usize, max: &mut usize, limits: &SchemaLimits) -> Result<()> {
    if level > *max {
        *max = level;
    }
    // Refuse early once the depth cap is crossed so a malicious schema
    // does not have to be walked all the way down before we bail.
    if level > limits.max_depth {
        return Err(Error::SchemaTooDeep {
            depth: level,
            max: limits.max_depth,
        });
    }
    match node {
        Value::Object(map) => {
            for (key, value) in map {
                if limits.refuse_external_refs && key == "$ref" {
                    if let Some(uri) = value.as_str() {
                        if is_external_ref(uri) {
                            return Err(Error::SchemaExternalRef {
                                uri: uri.to_string(),
                            });
                        }
                    }
                }
                walk(value, level + 1, max, limits)?;
            }
        }
        Value::Array(items) => {
            for item in items {
                walk(item, level + 1, max, limits)?;
            }
        }
        _ => {}
    }
    Ok(())
}

/// A `$ref` is external when it is not a same-document fragment.
/// Same-document refs start with `#` (the document root) or are entirely
/// empty (equivalent to `#`). Anything else (an absolute URI like
/// `https://...`, a relative path like `./other.json`, or a `file://`
/// URL) is refused.
fn is_external_ref(uri: &str) -> bool {
    !uri.is_empty() && !uri.starts_with('#')
}

/// Convert a [`Duration`] budget to milliseconds for error reporting,
/// saturating at `u64::MAX`. Centralized so the error path stays
/// readable.
fn budget_ms(budget: Duration) -> u64 {
    u64::try_from(budget.as_millis()).unwrap_or(u64::MAX)
}

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

    #[test]
    fn valid_instance_passes() {
        let schema = json!({"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}});
        let actual = json!({"name": "alice"});
        let r = evaluate(&schema, &actual).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn type_mismatch_produces_diff_with_pointer_path() {
        let schema = json!({"type": "object", "properties": {"age": {"type": "integer"}}});
        let actual = json!({"age": "thirty"});
        let r = evaluate(&schema, &actual).unwrap();
        assert!(!r.pass);
        let diff = r
            .diffs
            .iter()
            .find(|d| d.path == "/age")
            .expect("path /age");
        assert!(
            diff.note
                .as_deref()
                .unwrap_or("")
                .to_lowercase()
                .contains("string")
                || diff
                    .note
                    .as_deref()
                    .unwrap_or("")
                    .to_lowercase()
                    .contains("integer"),
            "note was: {:?}",
            diff.note
        );
    }

    #[test]
    fn multiple_errors_all_reported() {
        let schema = json!({
            "type": "object",
            "required": ["a", "b"],
            "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}
        });
        let actual = json!({"a": "no", "b": "still no"});
        let r = evaluate(&schema, &actual).unwrap();
        assert!(!r.pass);
        assert!(
            r.diffs.len() >= 2,
            "expected >=2 diffs, got {}",
            r.diffs.len()
        );
    }

    #[test]
    fn invalid_schema_returns_error_not_assertion_failure() {
        let bad_schema = json!({"type": 42});
        let err = evaluate(&bad_schema, &json!(null)).unwrap_err();
        assert!(matches!(err, Error::InvalidSchema(_)));
    }

    // === 2020-12 vocabulary acceptance ===
    // These tests pin the behavior of `jsonschema` 0.30 so a future
    // upgrade that broke any of them would fail loud rather than break
    // a suite quietly.

    #[test]
    fn validates_2020_12_prefix_items() {
        let schema = json!({
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "type": "array",
            "prefixItems": [{"type": "string"}, {"type": "integer"}]
        });
        assert!(evaluate(&schema, &json!(["a", 1])).unwrap().pass);
        assert!(!evaluate(&schema, &json!([1, "a"])).unwrap().pass);
    }

    #[test]
    fn validates_2020_12_unevaluated_properties_closed() {
        let schema = json!({
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "type": "object",
            "properties": {"a": {}},
            "unevaluatedProperties": false
        });
        assert!(evaluate(&schema, &json!({"a": 1})).unwrap().pass);
        assert!(
            !evaluate(&schema, &json!({"a": 1, "extra": 2}))
                .unwrap()
                .pass
        );
    }

    #[test]
    fn validates_if_then_else_conditional() {
        let schema = json!({
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "if": {"properties": {"kind": {"const": "a"}}},
            "then": {"required": ["only_for_a"]}
        });
        assert!(
            evaluate(&schema, &json!({"kind": "a", "only_for_a": 1}))
                .unwrap()
                .pass
        );
        assert!(!evaluate(&schema, &json!({"kind": "a"})).unwrap().pass);
        // The else branch does not trigger when `if` matches.
        assert!(evaluate(&schema, &json!({"kind": "b"})).unwrap().pass);
    }

    #[test]
    fn validates_internal_ref_with_defs() {
        let schema = json!({
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "$defs": {"name": {"type": "string", "minLength": 1}},
            "type": "object",
            "properties": {"name": {"$ref": "#/$defs/name"}}
        });
        assert!(evaluate(&schema, &json!({"name": "ok"})).unwrap().pass);
        assert!(!evaluate(&schema, &json!({"name": ""})).unwrap().pass);
    }

    #[test]
    fn default_draft_supports_2020_12_features_without_schema_keyword() {
        // When `$schema` is missing, jsonschema 0.30 defaults to 2020-12,
        // so `prefixItems` is still enforced. Pin that here.
        let schema = json!({"type": "array", "prefixItems": [{"type": "string"}]});
        assert!(!evaluate(&schema, &json!([1])).unwrap().pass);
    }

    // === Security: external $ref refused at compile ===

    #[test]
    fn refuses_external_ref_with_https_scheme_before_any_network_call() {
        let schema = json!({
            "$ref": "https://example.com/never.json"
        });
        let err = evaluate(&schema, &json!({})).unwrap_err();
        match err {
            Error::SchemaExternalRef { uri } => assert_eq!(uri, "https://example.com/never.json"),
            other => panic!("expected SchemaExternalRef, got {other:?}"),
        }
    }

    #[test]
    fn refuses_external_ref_nested_under_property() {
        let schema = json!({
            "type": "object",
            "properties": {
                "child": {"$ref": "file:///etc/passwd"}
            }
        });
        let err = evaluate(&schema, &json!({})).unwrap_err();
        assert!(matches!(err, Error::SchemaExternalRef { .. }));
    }

    #[test]
    fn refuses_external_ref_nested_inside_one_of_branch() {
        let schema = json!({
            "oneOf": [
                {"type": "string"},
                {"$ref": "https://attacker.example/cap.json"}
            ]
        });
        let err = evaluate(&schema, &json!("hi")).unwrap_err();
        assert!(matches!(err, Error::SchemaExternalRef { .. }));
    }

    #[test]
    fn accepts_internal_fragment_ref() {
        let schema = json!({
            "$defs": {"int": {"type": "integer"}},
            "$ref": "#/$defs/int"
        });
        assert!(evaluate(&schema, &json!(7)).unwrap().pass);
        assert!(!evaluate(&schema, &json!("nope")).unwrap().pass);
    }

    // === Security: depth cap ===

    #[test]
    fn refuses_schema_deeper_than_the_cap() {
        // Build a schema nested deeper than a small custom cap. We use a
        // small cap (8) instead of the default (64) so the test stays fast
        // and explicit.
        let limits = SchemaLimits {
            max_depth: 8,
            ..SchemaLimits::default()
        };
        let mut schema = json!({"type": "string"});
        for _ in 0..20 {
            schema = json!({"type": "object", "properties": {"x": schema}});
        }
        let err = evaluate_with_limits(&schema, &json!({}), &limits).unwrap_err();
        match err {
            Error::SchemaTooDeep { depth, max } => {
                assert!(depth > max, "depth={depth} max={max}");
                assert_eq!(max, 8);
            }
            other => panic!("expected SchemaTooDeep, got {other:?}"),
        }
    }

    #[test]
    fn accepts_schema_under_the_depth_cap() {
        // A schema well under the cap compiles fine. Each `{"type":
        // "object", "properties": {"x": INNER}}` wrap adds two JSON-tree
        // levels (the outer object and the `properties` map), so a cap
        // of 16 admits about seven such wraps. The default production
        // cap (64) admits roughly 32 wraps which is far more than any
        // real schema needs.
        let limits = SchemaLimits {
            max_depth: 16,
            ..SchemaLimits::default()
        };
        let mut schema = json!({"type": "string"});
        for _ in 0..6 {
            schema = json!({"type": "object", "properties": {"x": schema}});
        }
        let r = evaluate_with_limits(&schema, &json!({}), &limits).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }
}
