//! Integration tests for `mcptest matchers` and the matcher catalog
//! drift guarantees (WOR-1266).
//!
//! This crate depends on both `mcptest-core` (the catalog: names,
//! descriptions, examples) and `mcptest-config` (the schema-derived name
//! list the validator accepts), so the drift tests that pin the two lists
//! together live here.

use assert_cmd::Command;
use mcptest_config::loader::{load_and_validate, matcher_names, LoadOptions};
use mcptest_core::matchers::catalog::{EntryKind, CATALOG};

fn mcptest() -> Command {
    Command::cargo_bin("mcptest").expect("binary builds")
}

/// Wrap a catalog example (which starts at `expect:`) into a minimal,
/// otherwise-valid suite so it can be schema-validated as written.
fn suite_with_example(example: &str) -> String {
    let mut suite = String::from(
        "servers:\n  main:\n    command: [\"echo\", \"hi\"]\ntools:\n  - name: t\n    server: main\n    tool: echo\n",
    );
    for line in example.lines() {
        suite.push_str("    ");
        suite.push_str(line);
        suite.push('\n');
    }
    suite
}

#[test]
fn matchers_lists_every_catalog_name_with_description_and_example() {
    let assertion = mcptest().arg("matchers").assert().success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    for entry in CATALOG {
        assert!(
            stdout.contains(entry.name),
            "human output misses name {}",
            entry.name
        );
        assert!(
            stdout.contains(entry.description),
            "human output misses description for {}",
            entry.name
        );
        for line in entry.example.lines() {
            assert!(
                stdout.contains(line.trim()),
                "human output misses example line {line:?} for {}",
                entry.name
            );
        }
    }
}

#[test]
fn matchers_json_emits_an_array_of_name_description_example() {
    let assertion = mcptest().args(["matchers", "--json"]).assert().success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let doc: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
    let rows = doc.as_array().expect("a JSON array");
    assert_eq!(rows.len(), CATALOG.len());
    for (row, entry) in rows.iter().zip(CATALOG) {
        assert_eq!(row["name"], entry.name);
        assert_eq!(row["description"], entry.description);
        assert_eq!(row["example"], entry.example);
    }
}

/// Drift guard, schema side: the catalog's matcher names must equal the
/// names `schemas/v1.json` enumerates under `$defs/Matcher/properties`
/// (read through the validation layer's own parsed copy), so a matcher
/// added to either side without the other fails loudly.
#[test]
fn catalog_matcher_names_match_the_schema_enumeration() {
    let mut from_catalog: Vec<&str> = CATALOG
        .iter()
        .filter(|e| e.kind == EntryKind::Matcher)
        .map(|e| e.name)
        .collect();
    let mut from_schema: Vec<&str> = matcher_names().iter().map(String::as_str).collect();
    from_catalog.sort_unstable();
    from_schema.sort_unstable();
    assert_eq!(
        from_catalog, from_schema,
        "matcher catalog and $defs/Matcher/properties drifted"
    );
}

/// Drift guard, expectation side: the budget and header entries must equal
/// the `$defs/Expectation` properties minus `assertions` (the nested
/// assertion list) and `snapshot` (already cataloged as a matcher).
#[test]
fn catalog_expectation_names_match_the_expectation_block() {
    let schema_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../schemas/v1.json");
    let schema: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(schema_path).unwrap()).unwrap();
    let mut from_schema: Vec<&str> = schema
        .pointer("/$defs/Expectation/properties")
        .and_then(|v| v.as_object())
        .expect("Expectation properties")
        .keys()
        .map(String::as_str)
        .filter(|k| *k != "assertions" && *k != "snapshot")
        .collect();
    let mut from_catalog: Vec<&str> = CATALOG
        .iter()
        .filter(|e| e.kind == EntryKind::Expectation)
        .map(|e| e.name)
        .collect();
    from_catalog.sort_unstable();
    from_schema.sort_unstable();
    assert_eq!(
        from_catalog, from_schema,
        "expectation-level catalog entries and $defs/Expectation drifted"
    );
}

/// Drift guard, example side: every catalog example, pasted into a minimal
/// tool test, must pass the same schema validation `mcptest validate`
/// runs. A stale example fails here instead of in a user's terminal.
#[test]
fn every_catalog_example_validates_against_the_schema() {
    let dir = tempfile::tempdir().unwrap();
    for entry in CATALOG {
        let path = dir.path().join(format!("{}.yaml", entry.name));
        std::fs::write(&path, suite_with_example(entry.example)).unwrap();
        let result = load_and_validate(&path, LoadOptions::default());
        assert!(
            result.is_ok(),
            "example for `{}` fails validation: {:?}\nsuite:\n{}",
            entry.name,
            result.err(),
            suite_with_example(entry.example)
        );
    }
}

#[test]
fn validate_json_hints_the_nearest_matcher_for_a_misspelled_matcher_name() {
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(
        &cfg,
        "servers:\n  main:\n    command: [\"echo\", \"hi\"]\ntools:\n  - name: t\n    server: main\n    tool: echo\n    expect:\n      - target: result\n        matcher: { exxact: \"hi\" }\n",
    )
    .unwrap();
    let assertion = mcptest()
        .args([
            "--config",
            cfg.to_str().unwrap(),
            "validate",
            "--format",
            "json",
        ])
        .assert()
        .code(2);
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let doc: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
    let errors = doc["errors"].as_array().unwrap();
    let hinted = errors
        .iter()
        .find(|e| e["hint"].is_string())
        .unwrap_or_else(|| panic!("expected a hinted error, got: {errors:?}"));
    assert_eq!(
        hinted["hint"],
        format!(
            "unknown matcher `exxact` (did you mean `exact`?); see: {}",
            mcptest_core::error_anchors::UNKNOWN_MATCHER
        )
    );
}

#[test]
fn validate_json_hints_matcher_for_a_typoed_assertion_key() {
    let dir = tempfile::tempdir().unwrap();
    let cfg = dir.path().join("mcptest.yaml");
    std::fs::write(
        &cfg,
        "servers:\n  main:\n    command: [\"echo\", \"hi\"]\ntools:\n  - name: t\n    server: main\n    tool: echo\n    expect:\n      - target: result\n        matche: { exact: \"hi\" }\n",
    )
    .unwrap();
    let assertion = mcptest()
        .args([
            "--config",
            cfg.to_str().unwrap(),
            "validate",
            "--format",
            "json",
        ])
        .assert()
        .code(2);
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    let doc: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
    let errors = doc["errors"].as_array().unwrap();
    let hinted = errors
        .iter()
        .find(|e| e["hint"].is_string())
        .unwrap_or_else(|| panic!("expected a hinted error, got: {errors:?}"));
    assert_eq!(
        hinted["hint"],
        "unknown key `matche` (did you mean `matcher`?)"
    );
}
