//! Integration tests for `mcptest generate suite`.
//!
//! The command reads a server's declared tool catalog from a file and
//! synthesizes a starter suite: a valid-arg, edge, and schema-violation case
//! per tool, plus a multi-model matrix. These tests drive the compiled binary
//! end to end, confirm the emitted YAML validates against `schemas/v1.json`,
//! and check that `--update` appends new tests without clobbering existing
//! hand-authored ones.

use std::path::{Path, PathBuf};

use assert_cmd::Command;
use serde_json::Value;
use tempfile::tempdir;

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

fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root above crates/mcptest")
        .to_path_buf()
}

/// Validate a YAML suite string against the published v1 JSON Schema.
fn assert_validates(yaml: &str) {
    let schema_path = workspace_root().join("schemas").join("v1.json");
    let raw = std::fs::read_to_string(&schema_path).expect("read v1.json");
    let schema: Value = serde_json::from_str(&raw).expect("v1.json is valid JSON");
    let validator = jsonschema::validator_for(&schema).expect("schema compiles");
    let as_yaml: serde_yaml::Value = serde_yaml::from_str(yaml).expect("suite parses");
    let as_json: Value = serde_json::to_value(as_yaml).expect("YAML converts to JSON");
    let errors: Vec<String> = validator
        .iter_errors(&as_json)
        .map(|e| e.to_string())
        .collect();
    assert!(errors.is_empty(), "suite should validate, got: {errors:#?}");
}

fn fixture_catalog() -> &'static str {
    r#"{
        "tools": [
            {
                "name": "echo",
                "inputSchema": {
                    "type": "object",
                    "properties": { "msg": { "type": "string" } },
                    "required": ["msg"]
                }
            },
            {
                "name": "ping",
                "inputSchema": { "type": "object", "properties": {} }
            }
        ]
    }"#
}

#[test]
fn generate_suite_help_lists_flags() {
    let assertion = mcptest()
        .args(["generate", "suite", "--help"])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("--from-config"));
    assert!(stdout.contains("--output"));
    assert!(stdout.contains("--update"));
    assert!(stdout.contains("--models"));
}

#[test]
fn generate_suite_emits_valid_schema_compliant_suite() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, fixture_catalog()).unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();

    // The three case kinds appear for the echo tool.
    assert!(stdout.contains("echo: valid arguments"));
    assert!(stdout.contains("echo: boundary edge case"));
    assert!(stdout.contains("echo: rejects schema violation"));
    // Multi-model matrix placeholder is present.
    assert!(stdout.contains("model_compatibility:"));
    // And the whole document validates against the published schema.
    assert_validates(&stdout);
}

#[test]
fn generate_suite_writes_to_output_file() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, fixture_catalog()).unwrap();
    let out = dir.path().join("suite.yml");

    mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();

    let body = std::fs::read_to_string(&out).unwrap();
    assert!(body.contains("tool: echo"));
    assert_validates(&body);
}

#[test]
fn generate_suite_update_preserves_existing_tests() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, fixture_catalog()).unwrap();

    // A hand-authored suite that already covers echo with a custom body.
    let suite = dir.path().join("suite.yml");
    std::fs::write(
        &suite,
        "servers:\n  server:\n    command: [\"./server\"]\n\
         tools:\n  - name: \"echo: valid arguments\"\n    server: server\n    tool: echo\n    \
         args:\n      msg: \"keep me\"\n    expect:\n      - target: result\n        matcher:\n          contains: ok\n",
    )
    .unwrap();

    mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--update",
            suite.to_str().unwrap(),
        ])
        .assert()
        .success();

    let merged = std::fs::read_to_string(&suite).unwrap();
    // Hand-authored body survives.
    assert!(merged.contains("keep me"));
    // The matching test name is not duplicated.
    assert_eq!(
        merged.matches("echo: valid arguments").count(),
        1,
        "existing test name must not be duplicated"
    );
    // New ping tests were appended.
    assert!(merged.contains("ping: valid arguments"));
    assert_validates(&merged);
}

#[test]
fn generate_suite_no_edge_and_no_violation_emits_only_valid() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, fixture_catalog()).unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--no-edge",
            "--no-violation",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("echo: valid arguments"));
    assert!(!stdout.contains("boundary edge case"));
    assert!(!stdout.contains("rejects schema violation"));
    assert_validates(&stdout);
}

fn output_schema_catalog() -> &'static str {
    r#"{
        "tools": [
            {
                "name": "forecast",
                "inputSchema": {
                    "type": "object",
                    "properties": { "city": { "type": "string" } },
                    "required": ["city"]
                },
                "outputSchema": {
                    "type": "object",
                    "required": ["temp_c"],
                    "properties": { "temp_c": { "type": "number" } }
                }
            },
            {
                "name": "ping",
                "inputSchema": { "type": "object", "properties": {} }
            }
        ]
    }"#
}

#[test]
fn generate_suite_emits_output_schema_conformance_test() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, output_schema_catalog()).unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("forecast: response conforms to declared output schema"),
        "missing conformance test: {stdout}"
    );
    assert!(stdout.contains("target: result.structuredContent"));
    assert_validates(&stdout);
}

#[test]
fn generate_suite_flag_suppresses_output_schema_tests() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, output_schema_catalog()).unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--no-output-schema-tests",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(
        !stdout.contains("conforms to declared output schema"),
        "flag should suppress the conformance test: {stdout}"
    );
    assert!(!stdout.contains("declares no output schema"));
    assert_validates(&stdout);
}

#[test]
fn generate_suite_notes_tools_without_output_schema() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, output_schema_catalog()).unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("tool `ping` declares no output schema, consider adding one"),
        "missing nudge comment: {stdout}"
    );
}

#[test]
fn generate_suite_skips_contradictory_tool_with_a_comment() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(
        &catalog,
        r#"{
            "tools": [{
                "name": "broken",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "s": { "type": "string", "minLength": 9, "maxLength": 2 }
                    },
                    "required": ["s"]
                }
            }]
        }"#,
    )
    .unwrap();

    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("# Skipped \"broken: valid arguments\""));
    assert!(stdout.contains("minLength 9 is greater than maxLength 2"));
    assert!(!stdout.contains("- name: \"broken: valid arguments\""));
    assert_validates(&stdout);
}

#[test]
fn generate_suite_missing_tools_exits_one() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("bad.json");
    std::fs::write(&catalog, r#"{"servers":{}}"#).unwrap();

    mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
        ])
        .assert()
        .code(1);
}

fn probe_catalog() -> &'static str {
    r#"{
        "tools": [
            {
                "name": "search",
                "inputSchema": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "query": { "type": "string", "minLength": 2, "maxLength": 8 },
                        "limit": { "type": "integer", "minimum": 1, "maximum": 50 },
                        "note": { "type": "string" }
                    },
                    "required": ["query", "limit"]
                }
            },
            {
                "name": "delete_item",
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "integer", "minimum": 1, "maximum": 9 } },
                    "required": ["id"]
                }
            }
        ]
    }"#
}

fn run_probe_suite(catalog: &Path) -> String {
    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--probe",
        ])
        .assert()
        .success();
    String::from_utf8(assertion.get_output().stdout.clone()).unwrap()
}

#[test]
fn generate_suite_probe_flag_emits_boundary_and_negative_probe_cases() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, probe_catalog()).unwrap();
    let stdout = run_probe_suite(&catalog);

    // The probe header documents the seed and cap.
    assert!(stdout
        .contains("probe tier: schema-derived boundary and negative cases (seed 1260, cap 12)"));
    // Boundary probes sit on declared bounds, violations sit just outside.
    assert!(stdout.contains("search: probe query at minLength"));
    assert!(stdout.contains("search: probe limit at declared maximum"));
    assert!(stdout.contains("search: probe rejects limit below declared minimum"));
    assert!(stdout.contains("search: probe rejects query longer than maxLength"));
    // additionalProperties: false yields the extra-property rejection probe.
    assert!(stdout.contains("search: probe rejects unexpected extra property"));
    // The schema is silent about the empty optional string: no-crash verdict.
    assert!(stdout.contains("search: probe empty note (schema is silent)"));
    assert!(stdout.contains("anyOf:"));
    assert_validates(&stdout);
}

#[test]
fn generate_suite_probe_marks_destructive_tools_for_review() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, probe_catalog()).unwrap();
    let stdout = run_probe_suite(&catalog);
    assert!(
        stdout.contains("# review before first run"),
        "destructive tool probes must carry the review marker: {stdout}"
    );
}

#[test]
fn generate_suite_probe_output_is_byte_identical_across_invocations() {
    let dir = tempdir().unwrap();
    let catalog = dir.path().join("catalog.json");
    std::fs::write(&catalog, probe_catalog()).unwrap();
    let first = run_probe_suite(&catalog);
    let second = run_probe_suite(&catalog);
    assert_eq!(first, second, "probe output must be deterministic");
}

#[test]
fn generate_suite_without_probe_matches_the_prechange_fixture() {
    // The fixture was captured from the binary before the probe tier landed.
    // Probe-off output must stay byte-identical so existing baselines and
    // `--check` flows do not drift.
    let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/generate");
    let catalog = fixtures.join("probe-off-catalog.json");
    let expected = std::fs::read_to_string(fixtures.join("probe-off-suite.yml")).unwrap();
    let assertion = mcptest()
        .args([
            "generate",
            "suite",
            "--from-config",
            catalog.to_str().unwrap(),
            "--models",
            "pin-model-a,pin-model-b",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert_eq!(
        stdout, expected,
        "probe-off suite output drifted from the pre-probe fixture"
    );
}
