//! QA-owned matcher corpus. One integration-test surface per
//! built-in matcher: `exact`, `schema`, `regex`, `contains`, `snapshot`,
//! and the `llm-judge` block. Each section covers positive cases,
//! negative cases, and edge cases (empty input, deep nesting, unicode,
//! very long strings, numeric coercion, null vs missing) so the engine's
//! quality bar holds across the input space, not just the developer's
//! happy path.

use std::collections::BTreeMap;

use proptest::prelude::*;
use serde_json::{json, Value};

use mcptest_core::matchers::snapshot::Sanitizer;
use mcptest_core::matchers::{
    contains, exact, is_json, is_sql, is_xml, judge_criteria, levenshtein, llm_judge, named,
    regex as rx, schema, snapshot, tools_call,
};
use mcptest_core::matchers::{AssertionResult, MatcherKind};
use mcptest_core::Error;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn assert_passed(result: &AssertionResult, kind: MatcherKind) {
    assert!(result.pass, "expected pass; got diffs: {:?}", result.diffs);
    assert_eq!(result.kind, kind);
}

fn assert_failed(result: &AssertionResult, kind: MatcherKind) {
    assert!(!result.pass, "expected fail; got passing result");
    assert_eq!(result.kind, kind);
    assert!(
        !result.diffs.is_empty(),
        "failing result must carry at least one diff"
    );
}

fn unique_temp(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "mcptest-matcher-corpus-{}-{}",
        std::process::id(),
        name
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir.join(format!("{name}.snap.json"))
}

/// Seed a snapshot file the way the CLI does under `--update-snapshots`;
/// missing snapshots are no longer auto-created in the default mode.
fn record_snapshot(path: &std::path::Path, value: &Value) {
    let outcome = snapshot::evaluate_with_mode(
        path,
        value,
        &[],
        mcptest_core::matchers::snapshot::UpdateSnapshotsMode::Always,
    )
    .unwrap();
    assert!(outcome.result.pass);
}

// ---------------------------------------------------------------------------
// `exact` matcher (deep equality, normalized key order)
// ---------------------------------------------------------------------------

#[test]
fn exact_passes_on_identical_scalars() {
    for v in [
        json!(null),
        json!(true),
        json!(false),
        json!(0),
        json!(-1),
        json!(2.71_f64),
        json!(""),
        json!("hello"),
    ] {
        let r = exact::evaluate(&v, &v).unwrap();
        assert_passed(&r, MatcherKind::Exact);
    }
}

#[test]
fn exact_fails_when_scalar_types_differ() {
    // 1 (int) vs "1" (string) must not coerce.
    let r = exact::evaluate(&json!(1), &json!("1")).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert!(r.diffs[0]
        .note
        .as_deref()
        .unwrap_or("")
        .contains("not equal"));
}

#[test]
fn exact_treats_null_and_missing_separately() {
    let r = exact::evaluate(&json!({"a": null}), &json!({})).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert_eq!(r.diffs[0].path, "/a");
    assert!(r.diffs[0].actual.is_none());
}

#[test]
fn exact_treats_missing_and_null_in_actual_as_distinct() {
    let r = exact::evaluate(&json!({}), &json!({"a": null})).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert!(r.diffs[0]
        .note
        .as_deref()
        .unwrap()
        .contains("unexpected key"));
}

#[test]
fn exact_passes_on_empty_objects_and_arrays() {
    assert_passed(
        &exact::evaluate(&json!({}), &json!({})).unwrap(),
        MatcherKind::Exact,
    );
    assert_passed(
        &exact::evaluate(&json!([]), &json!([])).unwrap(),
        MatcherKind::Exact,
    );
}

#[test]
fn exact_handles_deeply_nested_objects() {
    let mut v = json!({"leaf": 1});
    for _ in 0..50 {
        v = json!({ "n": v });
    }
    assert_passed(&exact::evaluate(&v, &v).unwrap(), MatcherKind::Exact);
}

#[test]
fn exact_surfaces_deep_path_on_mismatch() {
    let expected = json!({"a": {"b": {"c": {"d": 1}}}});
    let actual = json!({"a": {"b": {"c": {"d": 2}}}});
    let r = exact::evaluate(&expected, &actual).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert_eq!(r.diffs[0].path, "/a/b/c/d");
}

#[test]
fn exact_unicode_keys_and_values_round_trip() {
    let v = json!({"\u{2603}": "snowman \u{1F600}", "\u{4e2d}\u{6587}": [1, 2, 3]});
    assert_passed(&exact::evaluate(&v, &v).unwrap(), MatcherKind::Exact);
}

#[test]
fn exact_long_string_match_is_correct() {
    let s = "a".repeat(50_000);
    let lhs = json!({ "blob": s.clone() });
    let rhs = json!({ "blob": s });
    assert_passed(&exact::evaluate(&lhs, &rhs).unwrap(), MatcherKind::Exact);
}

#[test]
fn exact_numeric_int_and_float_with_same_value_compare_equal() {
    // serde_json treats 1 and 1.0 as distinct number variants; the
    // matcher should still report them as equal where serde does, and
    // unequal where it does not. This pins the current behavior.
    let lhs = json!(1);
    let rhs_int = json!(1);
    let rhs_float = json!(1.0);
    assert_passed(
        &exact::evaluate(&lhs, &rhs_int).unwrap(),
        MatcherKind::Exact,
    );
    // serde_json::Value compares 1 != 1.0 because the underlying number
    // representations differ. Documenting that here is the point of this
    // test: users should be aware that JSON integer and float literals
    // are not interchangeable under `exact`.
    let r = exact::evaluate(&lhs, &rhs_float).unwrap();
    assert_failed(&r, MatcherKind::Exact);
}

#[test]
fn exact_array_length_mismatch_is_reported() {
    let r = exact::evaluate(&json!([1, 2, 3]), &json!([1, 2])).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert!(r.diffs[0].note.as_deref().unwrap().contains("length"));
}

#[test]
fn exact_array_order_matters() {
    let r = exact::evaluate(&json!([1, 2, 3]), &json!([3, 2, 1])).unwrap();
    assert_failed(&r, MatcherKind::Exact);
}

#[test]
fn exact_extra_key_in_actual_is_reported() {
    let r = exact::evaluate(&json!({"a": 1}), &json!({"a": 1, "b": 2})).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert!(r.diffs[0]
        .note
        .as_deref()
        .unwrap()
        .contains("unexpected key"));
}

#[test]
fn exact_json_pointer_escapes_special_chars_in_keys() {
    let r = exact::evaluate(&json!({"a/b": 1, "c~d": 2}), &json!({"a/b": 9, "c~d": 2})).unwrap();
    assert_failed(&r, MatcherKind::Exact);
    assert_eq!(r.diffs[0].path, "/a~1b");
}

proptest! {
    /// Reflexive property: any value deep-equals itself.
    #[test]
    fn exact_is_reflexive(s in "[a-z]{0,20}", n in any::<i32>(), b in any::<bool>()) {
        let v = json!({"s": s, "n": n, "b": b});
        let r = exact::evaluate(&v, &v).unwrap();
        prop_assert!(r.pass);
    }
}

// ---------------------------------------------------------------------------
// `schema` matcher (JSON Schema validation)
// ---------------------------------------------------------------------------

#[test]
fn schema_valid_object_passes() {
    let schema = json!({
        "type": "object",
        "required": ["name"],
        "properties": {"name": {"type": "string"}}
    });
    let r = schema::evaluate(&schema, &json!({"name": "alice"})).unwrap();
    assert_passed(&r, MatcherKind::Schema);
}

#[test]
fn schema_required_field_missing_fails() {
    let schema = json!({"type": "object", "required": ["x"]});
    let r = schema::evaluate(&schema, &json!({})).unwrap();
    assert_failed(&r, MatcherKind::Schema);
}

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

#[test]
fn schema_empty_object_against_empty_schema_passes() {
    let r = schema::evaluate(&json!({}), &json!({})).unwrap();
    assert_passed(&r, MatcherKind::Schema);
}

#[test]
fn schema_minlength_and_maxlength_constraints() {
    let schema = json!({"type": "string", "minLength": 3, "maxLength": 5});
    assert_passed(
        &schema::evaluate(&schema, &json!("abc")).unwrap(),
        MatcherKind::Schema,
    );
    assert_failed(
        &schema::evaluate(&schema, &json!("ab")).unwrap(),
        MatcherKind::Schema,
    );
    assert_failed(
        &schema::evaluate(&schema, &json!("abcdef")).unwrap(),
        MatcherKind::Schema,
    );
}

#[test]
fn schema_array_items_validate_each_element() {
    let schema = json!({"type": "array", "items": {"type": "integer"}});
    assert_passed(
        &schema::evaluate(&schema, &json!([1, 2, 3])).unwrap(),
        MatcherKind::Schema,
    );
    let r = schema::evaluate(&schema, &json!([1, "two", 3])).unwrap();
    assert_failed(&r, MatcherKind::Schema);
}

#[test]
fn schema_enum_constraint_passes_and_fails() {
    let schema = json!({"enum": ["red", "green", "blue"]});
    assert_passed(
        &schema::evaluate(&schema, &json!("green")).unwrap(),
        MatcherKind::Schema,
    );
    assert_failed(
        &schema::evaluate(&schema, &json!("yellow")).unwrap(),
        MatcherKind::Schema,
    );
}

#[test]
fn schema_deeply_nested_schema_validates() {
    let schema = json!({
        "type": "object",
        "properties": {
            "outer": {
                "type": "object",
                "properties": {
                    "inner": {
                        "type": "array",
                        "items": {"type": "object", "required": ["leaf"]}
                    }
                }
            }
        }
    });
    let valid = json!({"outer": {"inner": [{"leaf": 1}]}});
    assert_passed(
        &schema::evaluate(&schema, &valid).unwrap(),
        MatcherKind::Schema,
    );
    let invalid = json!({"outer": {"inner": [{"leaf": 1}, {}]}});
    let r = schema::evaluate(&schema, &invalid).unwrap();
    assert_failed(&r, MatcherKind::Schema);
}

#[test]
fn schema_unicode_string_pattern_validates() {
    let schema = json!({"type": "string", "minLength": 1});
    assert_passed(
        &schema::evaluate(&schema, &json!("\u{4e2d}\u{6587}")).unwrap(),
        MatcherKind::Schema,
    );
}

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

#[test]
fn schema_null_value_against_nullable_type() {
    let schema = json!({"type": ["string", "null"]});
    assert_passed(
        &schema::evaluate(&schema, &json!(null)).unwrap(),
        MatcherKind::Schema,
    );
    assert_passed(
        &schema::evaluate(&schema, &json!("ok")).unwrap(),
        MatcherKind::Schema,
    );
    assert_failed(
        &schema::evaluate(&schema, &json!(1)).unwrap(),
        MatcherKind::Schema,
    );
}

// ---------------------------------------------------------------------------
// `regex` matcher
// ---------------------------------------------------------------------------

#[test]
fn regex_matches_simple_string() {
    let r = rx::evaluate(r"^\d+$", &json!("12345")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
}

#[test]
fn regex_no_match_returns_failure_with_pattern_in_note() {
    let r = rx::evaluate(r"^\d+$", &json!("abc")).unwrap();
    assert_failed(&r, MatcherKind::Regex);
    assert!(r.diffs[0]
        .note
        .as_deref()
        .unwrap()
        .contains("did not match"));
}

#[test]
fn regex_named_captures_populate_map() {
    let r = rx::evaluate(r"^user-(?P<id>\d+)-(?P<role>\w+)$", &json!("user-42-admin")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
    assert_eq!(r.captures.get("id").map(String::as_str), Some("42"));
    assert_eq!(r.captures.get("role").map(String::as_str), Some("admin"));
}

#[test]
fn regex_empty_pattern_matches_empty_string() {
    let r = rx::evaluate(r"^$", &json!("")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
}

#[test]
fn regex_against_non_string_fails_with_typed_note() {
    for v in [
        json!(42),
        json!(true),
        json!(null),
        json!([]),
        json!({}),
        json!(2.71_f64),
    ] {
        let r = rx::evaluate(".*", &v).unwrap();
        assert_failed(&r, MatcherKind::Regex);
        let note = r.diffs[0].note.as_deref().unwrap_or("");
        assert!(
            note.contains("requires a string-typed actual value"),
            "missing note on {v:?}: {note}"
        );
    }
}

#[test]
fn regex_invalid_pattern_returns_compile_error() {
    let err = rx::evaluate("(", &json!("anything")).unwrap_err();
    assert!(matches!(err, Error::RegexCompile(_)));
}

#[test]
fn regex_unicode_pattern_matches_unicode_input() {
    let r = rx::evaluate(r"^\p{Han}+$", &json!("\u{4e2d}\u{6587}")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
}

#[test]
fn regex_long_input_string_matches_in_reasonable_time() {
    let big = "a".repeat(100_000);
    let r = rx::evaluate(r"^a+$", &json!(big)).unwrap();
    assert_passed(&r, MatcherKind::Regex);
}

#[test]
fn regex_anchors_are_respected() {
    let r = rx::evaluate(r"^foo$", &json!("foobar")).unwrap();
    assert_failed(&r, MatcherKind::Regex);
}

#[test]
fn regex_partial_match_without_anchors_passes() {
    let r = rx::evaluate(r"\d+", &json!("abc 42 xyz")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
}

#[test]
fn regex_capture_groups_with_no_matches_do_not_panic() {
    // Edge: pattern has named capture but the optional alternation does
    // not fire. The capture must be absent from the map, not present
    // with an empty value.
    let r = rx::evaluate(r"^foo(?P<rest>bar)?$", &json!("foo")).unwrap();
    assert_passed(&r, MatcherKind::Regex);
    assert!(!r.captures.contains_key("rest"));
}

proptest! {
    /// Property: a regex with `.` matches any non-empty single-character
    /// JSON string, and the matcher kind is always `Regex`.
    #[test]
    fn regex_dot_matches_any_single_char(c in "[a-zA-Z0-9]") {
        let r = rx::evaluate(".", &json!(c)).unwrap();
        prop_assert!(r.pass);
        prop_assert_eq!(r.kind, MatcherKind::Regex);
    }
}

// ---------------------------------------------------------------------------
// `contains` matcher
// ---------------------------------------------------------------------------

#[test]
fn contains_object_subset_passes() {
    let exp = json!({"name": "alice"});
    let act = json!({"name": "alice", "id": 1, "ts": "now"});
    assert_passed(
        &contains::evaluate(&exp, &act).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_missing_key_fails_with_path() {
    let r = contains::evaluate(&json!({"a": 1, "b": 2}), &json!({"a": 1})).unwrap();
    assert_failed(&r, MatcherKind::Contains);
    assert_eq!(r.diffs[0].path, "/b");
}

#[test]
fn contains_empty_object_always_contained() {
    assert_passed(
        &contains::evaluate(&json!({}), &json!({})).unwrap(),
        MatcherKind::Contains,
    );
    assert_passed(
        &contains::evaluate(&json!({}), &json!({"x": 1})).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_empty_array_always_contained() {
    assert_passed(
        &contains::evaluate(&json!([]), &json!([])).unwrap(),
        MatcherKind::Contains,
    );
    assert_passed(
        &contains::evaluate(&json!([]), &json!([1, 2, 3])).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_array_multiset_requires_duplicate_count() {
    // [1, 1, 2] must NOT be a subset of [1, 2, 3] because the second 1
    // is missing.
    let r = contains::evaluate(&json!([1, 1, 2]), &json!([1, 2, 3])).unwrap();
    assert_failed(&r, MatcherKind::Contains);
    assert!(r.diffs[0].note.as_deref().unwrap().contains("not found"));
}

#[test]
fn contains_array_multiset_passes_when_duplicates_available() {
    assert_passed(
        &contains::evaluate(&json!([1, 1, 2]), &json!([1, 1, 2, 3])).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_array_is_order_insensitive() {
    assert_passed(
        &contains::evaluate(&json!([1, 2, 3]), &json!([3, 2, 1])).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_nested_object_subset_passes() {
    let exp = json!({"outer": {"a": 1}});
    let act = json!({"outer": {"a": 1, "b": 2}, "extra": "ignored"});
    assert_passed(
        &contains::evaluate(&exp, &act).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_scalar_fallback_is_strict_equality() {
    let r = contains::evaluate(&json!("hello"), &json!("world")).unwrap();
    assert_failed(&r, MatcherKind::Contains);
}

#[test]
fn contains_null_value_is_not_a_wildcard() {
    let r = contains::evaluate(&json!({"a": null}), &json!({"a": 1})).unwrap();
    assert_failed(&r, MatcherKind::Contains);
}

#[test]
fn contains_unicode_keys_match() {
    let exp = json!({"\u{1F600}": "smile"});
    let act = json!({"\u{1F600}": "smile", "extra": "x"});
    assert_passed(
        &contains::evaluate(&exp, &act).unwrap(),
        MatcherKind::Contains,
    );
}

#[test]
fn contains_deeply_nested_subset_reports_full_path() {
    let exp = json!({"a": {"b": {"c": 1}}});
    let act = json!({"a": {"b": {"c": 2}}});
    let r = contains::evaluate(&exp, &act).unwrap();
    assert_failed(&r, MatcherKind::Contains);
    assert_eq!(r.diffs[0].path, "/a/b/c");
}

#[test]
fn contains_long_array_matches_efficiently() {
    let big_actual: Vec<Value> = (0..10_000).map(|i| json!(i)).collect();
    let needle = json!([9999, 5000, 0]);
    let actual = json!(big_actual);
    assert_passed(
        &contains::evaluate(&needle, &actual).unwrap(),
        MatcherKind::Contains,
    );
}

// ---------------------------------------------------------------------------
// `snapshot` matcher
// ---------------------------------------------------------------------------

#[test]
fn snapshot_missing_file_fails_without_update_mode() {
    let path = unique_temp("first_run");
    let _ = std::fs::remove_file(&path);
    let v = json!({"a": 1});
    let r = snapshot::evaluate(&path, &v, &[]).unwrap();
    assert_failed(&r, MatcherKind::Snapshot);
    assert!(!path.exists(), "missing snapshot must not be auto-created");
    let note = r.diffs[0].note.as_deref().unwrap();
    assert!(note.contains("snapshot missing at"), "note: {note}");
    assert!(
        note.contains("run with --update-snapshots to create it"),
        "note: {note}"
    );
}

#[test]
fn snapshot_unchanged_value_passes_on_second_run() {
    let path = unique_temp("second_run_unchanged");
    let _ = std::fs::remove_file(&path);
    let v = json!({"a": 1});
    record_snapshot(&path, &v);
    let r = snapshot::evaluate(&path, &v, &[]).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_drift_fails_with_diff() {
    let path = unique_temp("drift");
    let _ = std::fs::remove_file(&path);
    record_snapshot(&path, &json!({"a": 1}));
    let r = snapshot::evaluate(&path, &json!({"a": 2}), &[]).unwrap();
    assert_failed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_ignore_fields_sanitizer_scrubs_volatile_paths() {
    let path = unique_temp("scrub");
    let _ = std::fs::remove_file(&path);
    record_snapshot(&path, &json!({"id": "abc", "ts": 1}));
    let sanitizers = vec![Sanitizer::IgnoreFields {
        paths: vec!["/ts".into()],
    }];
    let r = snapshot::evaluate(&path, &json!({"id": "abc", "ts": 9}), &sanitizers).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_unknown_ignore_path_is_a_noop() {
    let path = unique_temp("unknown_path");
    let _ = std::fs::remove_file(&path);
    record_snapshot(&path, &json!({"a": 1}));
    let sanitizers = vec![Sanitizer::IgnoreFields {
        paths: vec!["/does/not/exist".into()],
    }];
    let r = snapshot::evaluate(&path, &json!({"a": 1}), &sanitizers).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_unicode_round_trip_is_lossless() {
    let path = unique_temp("unicode");
    let _ = std::fs::remove_file(&path);
    let v = json!({"\u{4e2d}\u{6587}": "\u{1F600}"});
    record_snapshot(&path, &v);
    let r = snapshot::evaluate(&path, &v, &[]).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_with_special_pointer_chars_in_keys() {
    let path = unique_temp("special_keys");
    let _ = std::fs::remove_file(&path);
    let v = json!({"a/b": 1, "c~d": 2});
    record_snapshot(&path, &v);
    let sanitizers = vec![Sanitizer::IgnoreFields {
        paths: vec!["/a~1b".into()],
    }];
    let drifted = json!({"a/b": 999, "c~d": 2});
    let r = snapshot::evaluate(&path, &drifted, &sanitizers).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_empty_value_is_recorded_and_compared() {
    let path = unique_temp("empty_obj");
    let _ = std::fs::remove_file(&path);
    record_snapshot(&path, &json!({}));
    let r = snapshot::evaluate(&path, &json!({}), &[]).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_array_drift_reports_failure() {
    let path = unique_temp("array_drift");
    let _ = std::fs::remove_file(&path);
    record_snapshot(&path, &json!([1, 2, 3]));
    let r = snapshot::evaluate(&path, &json!([1, 2, 4]), &[]).unwrap();
    assert_failed(&r, MatcherKind::Snapshot);
}

#[test]
fn snapshot_corrupted_file_returns_io_or_json_error() {
    let path = unique_temp("corrupted");
    std::fs::write(&path, "not valid json").unwrap();
    let err = snapshot::evaluate(&path, &json!({}), &[]).unwrap_err();
    // serde_json reports this as a JSON error variant of Error.
    assert!(matches!(err, Error::Json(_) | Error::Io(_)));
}

#[test]
fn snapshot_sanitizer_yaml_roundtrip() {
    let original = Sanitizer::IgnoreFields {
        paths: vec!["/x".into(), "/y/z".into()],
    };
    let yaml = serde_yaml::to_string(&original).unwrap();
    let parsed: Sanitizer = serde_yaml::from_str(&yaml).unwrap();
    assert_eq!(parsed, original);
}

#[test]
fn snapshot_long_string_value_round_trips() {
    let path = unique_temp("long_string");
    let _ = std::fs::remove_file(&path);
    let s = "a".repeat(10_000);
    let v = json!({"blob": s});
    record_snapshot(&path, &v);
    let r = snapshot::evaluate(&path, &v, &[]).unwrap();
    assert_passed(&r, MatcherKind::Snapshot);
}

// ---------------------------------------------------------------------------
//  deterministic vocabulary: contains-all/any, icontains, starts-with,
// is-json, is-valid-tools-call, levenshtein. (The `not` wrapper is exercised
// in the executor's unit tests since it lives on the dispatch path.)
// ---------------------------------------------------------------------------

#[test]
fn contains_all_passes_for_strings_and_arrays() {
    assert_passed(
        &named::contains_all(&json!(["foo", "bar"]), &json!("a foo and a bar")).unwrap(),
        MatcherKind::ContainsAll,
    );
    assert_passed(
        &named::contains_all(&json!([1, 2]), &json!([3, 2, 1])).unwrap(),
        MatcherKind::ContainsAll,
    );
}

#[test]
fn contains_all_fails_when_a_needle_is_absent() {
    assert_failed(
        &named::contains_all(&json!(["foo", "zzz"]), &json!("foo only")).unwrap(),
        MatcherKind::ContainsAll,
    );
}

#[test]
fn contains_all_rejects_non_array_argument() {
    let err = named::contains_all(&json!("nope"), &json!("x")).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
}

#[test]
fn contains_any_passes_with_at_least_one_match() {
    assert_passed(
        &named::contains_any(&json!(["xx", "bar"]), &json!("a bar here")).unwrap(),
        MatcherKind::ContainsAny,
    );
}

#[test]
fn contains_any_fails_on_empty_needle_list() {
    assert_failed(
        &named::contains_any(&json!([]), &json!("anything")).unwrap(),
        MatcherKind::ContainsAny,
    );
}

#[test]
fn icontains_is_case_insensitive_and_unicode_aware() {
    assert_passed(
        &named::icontains(&json!("WORLD"), &json!("hello world")).unwrap(),
        MatcherKind::IContains,
    );
    assert_failed(
        &named::icontains(&json!("zzz"), &json!("hello world")).unwrap(),
        MatcherKind::IContains,
    );
}

#[test]
fn icontains_rejects_non_string_target() {
    let err = named::icontains(&json!("a"), &json!(42)).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
}

#[test]
fn starts_with_respects_prefix_position() {
    assert_passed(
        &named::starts_with(&json!("hello"), &json!("hello world")).unwrap(),
        MatcherKind::StartsWith,
    );
    assert_failed(
        &named::starts_with(&json!("world"), &json!("hello world")).unwrap(),
        MatcherKind::StartsWith,
    );
}

#[test]
fn is_json_passes_on_valid_and_fails_on_malformed() {
    assert_passed(
        &is_json::evaluate(&Value::Null, &json!("{\"a\": 1}")).unwrap(),
        MatcherKind::IsJson,
    );
    assert_failed(
        &is_json::evaluate(&Value::Null, &json!("{ broken")).unwrap(),
        MatcherKind::IsJson,
    );
}

#[test]
fn is_json_with_inline_schema_validates_parsed_document() {
    let arg = json!({"schema": {"type": "object", "required": ["name"]}});
    assert_passed(
        &is_json::evaluate(&arg, &json!("{\"name\": \"alice\"}")).unwrap(),
        MatcherKind::IsJson,
    );
    assert_failed(
        &is_json::evaluate(&arg, &json!("{\"other\": 1}")).unwrap(),
        MatcherKind::IsJson,
    );
}

#[test]
fn is_valid_tools_call_checks_shape_and_optional_schema() {
    let call = json!({"name": "search", "arguments": {"q": "rust"}});
    assert_passed(
        &tools_call::evaluate(&Value::Null, &call).unwrap(),
        MatcherKind::IsValidToolsCall,
    );
    assert_failed(
        &tools_call::evaluate(&Value::Null, &json!({"arguments": {}})).unwrap(),
        MatcherKind::IsValidToolsCall,
    );
    let arg = json!({"schema": {"type": "object", "required": ["q"]}});
    assert_failed(
        &tools_call::evaluate(&arg, &json!({"name": "x", "arguments": {"y": 1}})).unwrap(),
        MatcherKind::IsValidToolsCall,
    );
}

#[test]
fn levenshtein_passes_within_and_fails_beyond_threshold() {
    let within = json!({"value": "sitting", "max": 3});
    assert_passed(
        &levenshtein::evaluate(&within, &json!("kitten")).unwrap(),
        MatcherKind::Levenshtein,
    );
    let tight = json!({"value": "sitting", "max": 1});
    assert_failed(
        &levenshtein::evaluate(&tight, &json!("kitten")).unwrap(),
        MatcherKind::Levenshtein,
    );
}

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

// `is-xml` well-formedness, optional root assertion.

#[test]
fn is_xml_passes_on_well_formed_and_fails_on_malformed() {
    assert_passed(
        &is_xml::evaluate(&Value::Null, &json!("<note><to>a</to></note>")).unwrap(),
        MatcherKind::IsXml,
    );
    assert_failed(
        &is_xml::evaluate(&Value::Null, &json!("<note><to>a</note>")).unwrap(),
        MatcherKind::IsXml,
    );
}

#[test]
fn is_xml_root_assertion_matches_and_mismatches() {
    let arg = json!({"root": "note"});
    assert_passed(
        &is_xml::evaluate(&arg, &json!("<note/>")).unwrap(),
        MatcherKind::IsXml,
    );
    assert_failed(
        &is_xml::evaluate(&json!({"root": "memo"}), &json!("<note/>")).unwrap(),
        MatcherKind::IsXml,
    );
}

#[test]
fn is_xml_rejects_malformed_argument() {
    let err = is_xml::evaluate(&json!({"root": 5}), &json!("<a/>")).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
}

// `is-sql` parseability against the generic dialect.

#[test]
fn is_sql_passes_on_valid_and_fails_on_garbage() {
    assert_passed(
        &is_sql::evaluate(&Value::Null, &json!("SELECT a FROM t WHERE a > 1")).unwrap(),
        MatcherKind::IsSql,
    );
    assert_failed(
        &is_sql::evaluate(&Value::Null, &json!("not sql ;;;")).unwrap(),
        MatcherKind::IsSql,
    );
}

#[test]
fn is_sql_rejects_non_null_argument() {
    let err = is_sql::evaluate(&json!({"dialect": "pg"}), &json!("SELECT 1")).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
}

// The llm-judge matcher used to live here as a NotYetAvailable stub.
//  replaced the stub with a real evaluator that requires an
// LlmProvider. Behavioral tests for parsing, threshold logic, and
// malformed-reply handling live alongside the implementation in
// `src/matchers/llm_judge.rs` (and `src/matchers/llm_jury.rs`).
// The corpus pins only the provider-free parse surface of the block,
// including the WOR-1299 self-consistency `samples` knob.

#[test]
fn llm_judge_block_without_samples_parses_with_a_single_sample() {
    let block = json!({"rubric": "Is this on topic?"});
    let (cfg, samples) = llm_judge::parse_config_with_samples(&block).unwrap();
    assert_eq!(cfg.rubric, "Is this on topic?");
    assert!((cfg.threshold - 0.7).abs() < 1e-9);
    assert_eq!(samples, 1);
}

#[test]
fn llm_judge_samples_accepts_every_odd_count_through_nine() {
    for n in [1u32, 3, 5, 7, 9] {
        let block = json!({"rubric": "x", "samples": n});
        let (_, samples) = llm_judge::parse_config_with_samples(&block).unwrap();
        assert_eq!(samples, n);
    }
}

#[test]
fn llm_judge_samples_rejects_even_zero_oversized_and_non_integer_counts() {
    for bad in [
        json!(0),
        json!(2),
        json!(10),
        json!(99),
        json!(-1),
        json!(1.5),
        json!("3"),
        json!(null),
    ] {
        let block = json!({"rubric": "x", "samples": bad});
        let err = llm_judge::parse_config_with_samples(&block).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)), "{bad}");
        assert!(
            err.to_string().contains("`samples`"),
            "error must name the field: {err}"
        );
    }
}

#[test]
fn llm_judge_strict_parse_still_rejects_a_samples_key() {
    // Until the executor threads the sampled path, the strict parser must
    // reject `samples:` loudly rather than silently grading one sample.
    let block = json!({"rubric": "x", "samples": 3});
    let err = llm_judge::parse_config(&block).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
    assert!(err.to_string().contains("samples"), "{err}");
}

// WOR-1300: binary criteria decomposition on the llm-judge block. The corpus
// pins the provider-free parse surface (criteria list, combine fold rule,
// the threshold interaction) going through the same JSON shape the suite
// parser hands the executor. Provider-backed behavior lives next to the
// implementation in `src/matchers/llm_judge.rs` and `judge_criteria.rs`.

#[test]
fn llm_judge_criteria_block_parses_with_default_all_combine() {
    let block = json!({"rubric": "Grade it.", "criteria": [
        {"name": "tone", "require": "Is it polite?"},
        {"name": "accuracy", "require": "Is it correct?"},
    ]});
    let parsed = llm_judge::parse_block(&block).unwrap();
    let spec = parsed.criteria.expect("criteria spec parsed");
    assert_eq!(spec.criteria.len(), 2);
    assert_eq!(spec.criteria[0].name, "tone");
    assert_eq!(spec.combine, judge_criteria::Combine::All);
    assert_eq!(spec.threshold, None);
    assert_eq!(parsed.samples, 1);
}

#[test]
fn llm_judge_criteria_compose_with_combine_fraction_and_samples() {
    let block = json!({
        "rubric": "Grade it.",
        "samples": 3,
        "combine": {"fraction": 0.5},
        "criteria": [
            {"name": "tone", "require": "Is it polite?"},
            {"name": "accuracy", "require": "Is it correct?"},
        ],
    });
    let parsed = llm_judge::parse_block(&block).unwrap();
    assert_eq!(parsed.samples, 3);
    let spec = parsed.criteria.expect("criteria spec parsed");
    assert_eq!(spec.combine, judge_criteria::Combine::Fraction(0.5));
}

#[test]
fn llm_judge_criteria_reject_empty_lists_oversized_lists_and_duplicates() {
    let empty = json!({"rubric": "x", "criteria": []});
    let err = llm_judge::parse_block(&empty).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
    assert!(err.to_string().contains("between 1 and 8"), "{err}");

    let nine: Vec<_> = (0..9)
        .map(|i| json!({"name": format!("c{i}"), "require": "q"}))
        .collect();
    let oversized = json!({"rubric": "x", "criteria": nine});
    assert!(llm_judge::parse_block(&oversized).is_err());

    let duplicated = json!({"rubric": "x", "criteria": [
        {"name": "tone", "require": "a"},
        {"name": "tone", "require": "b"},
    ]});
    let err = llm_judge::parse_block(&duplicated).unwrap_err();
    assert!(err.to_string().contains("duplicate"), "{err}");
}

#[test]
fn llm_judge_combine_fraction_bounds_are_enforced_at_parse() {
    for bad in [json!(0.0), json!(-0.1), json!(1.5)] {
        let block = json!({
            "rubric": "x",
            "combine": {"fraction": bad},
            "criteria": [{"name": "a", "require": "q"}],
        });
        let err = llm_judge::parse_block(&block).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)), "{bad}");
        assert!(err.to_string().contains("(0, 1]"), "{bad}: {err}");
    }
}

#[test]
fn llm_judge_threshold_with_combine_all_is_rejected_at_parse() {
    let block = json!({
        "rubric": "x",
        "threshold": 0.7,
        "criteria": [{"name": "a", "require": "q"}],
    });
    let err = llm_judge::parse_block(&block).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
    let msg = err.to_string();
    assert!(msg.contains("`threshold`"), "{msg}");
    assert!(msg.contains("\"all\""), "{msg}");
}

#[test]
fn llm_judge_combine_without_criteria_is_rejected_at_parse() {
    let block = json!({"rubric": "x", "combine": "all"});
    let err = llm_judge::parse_block(&block).unwrap_err();
    assert!(err.to_string().contains("`combine`"), "{err}");
}

#[test]
fn llm_judge_strict_parse_still_rejects_criteria_keys() {
    // Callers that build JudgeConfig directly (jury, named-graded sugar) do
    // not support criteria, so the strict parser must reject the keys loudly.
    let block = json!({"rubric": "x", "criteria": [{"name": "a", "require": "q"}]});
    let err = llm_judge::parse_config(&block).unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
    assert!(err.to_string().contains("criteria"), "{err}");
}

// The llm-jury matcher's provider-backed behavior is tested next to the
// implementation in `src/matchers/llm_jury.rs`. The corpus pins the YAML
// shape of the WOR-1296 `bias:` block: parse defaults, the three knobs, and
// the load-time rejections, going through serde_yaml exactly like a user's
// suite file does.

fn jury_block_from_yaml(yaml: &str) -> Result<mcptest_core::matchers::llm_jury::JuryConfig, Error> {
    let parsed: serde_yaml::Value = serde_yaml::from_str(yaml).expect("corpus YAML parses");
    let as_json = serde_json::to_value(parsed).expect("YAML converts to JSON");
    mcptest_core::matchers::llm_jury::parse_config(&as_json)
}

#[test]
fn llm_jury_yaml_without_bias_block_parses_with_mitigations_off() {
    let cfg = jury_block_from_yaml(
        r#"
rubric: Is the answer helpful?
jurors:
  - model: claude-sonnet-4-5
  - model: gpt-5
"#,
    )
    .unwrap();
    assert!(cfg.bias.is_none());
}

#[test]
fn llm_jury_yaml_bias_block_parses_all_three_mitigations() {
    let cfg = jury_block_from_yaml(
        r#"
rubric: Is the answer helpful?
jurors:
  - model: claude-sonnet-4-5
  - model: gpt-5
bias:
  chain_of_thought: true
  verbosity_guard: 2.5
  anchors:
    - candidate: a known good answer
      expected: 0.9
    - candidate: { text: structured anchor }
      expected: 0.2
"#,
    )
    .unwrap();
    let bias = cfg.bias.expect("bias block parses");
    assert!(bias.chain_of_thought);
    assert!((bias.verbosity_guard.unwrap() - 2.5).abs() < 1e-9);
    assert_eq!(bias.anchors.len(), 2);
    assert!(bias.anchors[1].candidate.is_object());
}

#[test]
fn llm_jury_yaml_empty_bias_block_defaults_every_mitigation_off() {
    let cfg = jury_block_from_yaml(
        r#"
rubric: x
jurors:
  - model: claude-sonnet-4-5
bias: {}
"#,
    )
    .unwrap();
    let bias = cfg.bias.expect("empty bias block parses");
    assert!(!bias.chain_of_thought);
    assert!(bias.verbosity_guard.is_none());
    assert!(bias.anchors.is_empty());
}

#[test]
fn llm_jury_yaml_rejects_verbosity_guard_at_or_below_one() {
    for bad in ["1.0", "0.5"] {
        let err = jury_block_from_yaml(&format!(
            "rubric: x\njurors:\n  - model: m\nbias:\n  verbosity_guard: {bad}\n"
        ))
        .unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)), "{bad}");
        assert!(err.to_string().contains("verbosity_guard"), "{err}");
    }
}

#[test]
fn llm_jury_yaml_rejects_anchor_expected_outside_unit_interval() {
    for bad in ["1.5", "-0.1"] {
        let err = jury_block_from_yaml(&format!(
            "rubric: x\njurors:\n  - model: m\nbias:\n  anchors:\n    - candidate: x\n      expected: {bad}\n"
        ))
        .unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)), "{bad}");
        assert!(err.to_string().contains("anchor expected score"), "{err}");
    }
}

#[test]
fn llm_jury_yaml_rejects_unknown_bias_keys() {
    let err =
        jury_block_from_yaml("rubric: x\njurors:\n  - model: m\nbias:\n  position_swap: true\n")
            .unwrap_err();
    assert!(matches!(err, Error::InvalidMatcherInput(_)));
    assert!(err.to_string().contains("position_swap"), "{err}");
}

// ---------------------------------------------------------------------------
// `AssertionResult` constructors and shape
// ---------------------------------------------------------------------------

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

#[test]
fn assertion_result_captures_are_btreemap_sorted() {
    let mut captures: BTreeMap<String, String> = BTreeMap::new();
    captures.insert("b".into(), "1".into());
    captures.insert("a".into(), "2".into());
    let keys: Vec<&String> = captures.keys().collect();
    assert_eq!(keys, vec![&"a".to_string(), &"b".to_string()]);
}
