//! Integration tests for `mcptest generate stubs`.
//!
//! The command introspects a server's tool catalog and writes one YAML test
//! stub per declared tool. Tests stand up a wiremock-backed streamable HTTP
//! endpoint so the round-trip through the protocol stack is exercised end to
//! end without a real MCP server.

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

use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::{json, Value};
use tempfile::tempdir;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

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 generated stub against the published v1 JSON Schema. Each stub
/// is a standalone document, so it must carry everything the schema requires
/// at the file level (a `servers:` block plus the `tools:` list).
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("stub 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(), "stub should validate, got: {errors:#?}");
}

async fn mount_jsonrpc(server: &MockServer, responses: Vec<(&'static str, Value)>) {
    for (method_name, payload) in responses {
        let body = payload.clone();
        Mock::given(method("POST"))
            .and(wiremock::matchers::body_partial_json(
                json!({ "method": method_name }),
            ))
            .respond_with(move |req: &Request| {
                let parsed: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
                let id = parsed.get("id").cloned().unwrap_or(Value::Null);
                let envelope = json!({
                    "jsonrpc": "2.0",
                    "id": id,
                    "result": body.clone(),
                });
                ResponseTemplate::new(200)
                    .set_body_string(serde_json::to_string(&envelope).unwrap())
                    .insert_header("content-type", "application/json")
            })
            .mount(server)
            .await;
    }
}

fn handshake_and_tools(tools: Value) -> Vec<(&'static str, Value)> {
    vec![
        (
            "initialize",
            json!({
                "protocolVersion": "2025-06-18",
                "serverInfo": { "name": "fake", "version": "0.1" },
                "capabilities": {}
            }),
        ),
        ("tools/list", json!({ "tools": tools })),
    ]
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_help_lists_flags() {
    let assertion = mcptest()
        .args(["generate", "stubs", "--help"])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(stdout.contains("--url"));
    assert!(stdout.contains("--server-command"));
    assert!(stdout.contains("--output"));
    assert!(stdout.contains("--overwrite"));
    assert!(stdout.contains("--check"));
}

/// Absolute path to the built `mcptest` binary, for stdio targets that spawn
/// `mcptest mock` as the server under introspection.
fn mcptest_bin() -> String {
    assert_cmd::cargo::cargo_bin("mcptest")
        .to_string_lossy()
        .into_owned()
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_over_stdio_matches_the_url_path_for_the_same_catalog() {
    let tools = json!([
        {
            "name": "search",
            "inputSchema": {
                "type": "object",
                "properties": { "query": { "type": "string" } },
                "required": ["query"]
            }
        },
        { "name": "ping", "inputSchema": { "type": "object", "properties": {} } }
    ]);
    let dir = tempdir().unwrap();

    // URL path: a wiremock streamable HTTP endpoint serving the catalog.
    let server = MockServer::start().await;
    mount_jsonrpc(&server, handshake_and_tools(tools.clone())).await;
    let url_out = dir.path().join("url-stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            url_out.to_str().unwrap(),
        ])
        .assert()
        .success();

    // Stdio path: `mcptest mock --tools-from <snapshot>` serving the same
    // catalog over stdin/stdout.
    let snapshot = dir.path().join("tools.json");
    std::fs::write(
        &snapshot,
        serde_json::to_string(&json!({ "tools": tools })).unwrap(),
    )
    .unwrap();
    let command = format!(
        "{} mock --tools-from {}",
        shell_words::quote(&mcptest_bin()),
        shell_words::quote(snapshot.to_str().unwrap()),
    );
    let stdio_out = dir.path().join("stdio-stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--server-command",
            &command,
            "--output",
            stdio_out.to_str().unwrap(),
        ])
        .assert()
        .success();

    // The synthesis layer is shared and the catalogs are identical, so the
    // stubs must be identical too, file for file.
    for tool in ["search", "ping"] {
        let from_url = std::fs::read_to_string(url_out.join(format!("{tool}.yml")))
            .unwrap_or_else(|e| panic!("read URL stub {tool}.yml: {e}"));
        let from_stdio = std::fs::read_to_string(stdio_out.join(format!("{tool}.yml")))
            .unwrap_or_else(|e| panic!("read stdio stub {tool}.yml: {e}"));
        assert_eq!(from_url, from_stdio, "stdio and URL stubs drifted: {tool}");
        assert_validates(&from_stdio);
    }
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_without_a_target_is_a_usage_error() {
    mcptest()
        .args(["generate", "stubs"])
        .assert()
        .code(2)
        .stderr(
            predicate::str::contains("--url").and(predicate::str::contains("--server-command")),
        );
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_url_and_server_command_are_mutually_exclusive() {
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            "http://127.0.0.1:0",
            "--server-command",
            "./srv",
        ])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("--server-command"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_bearer_token_env_is_url_only() {
    mcptest()
        .args([
            "generate",
            "stubs",
            "--server-command",
            "./srv",
            "--bearer-token-env",
            "TOKEN",
        ])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("--bearer-token-env"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_writes_one_yaml_per_tool() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "search",
                "inputSchema": {
                    "type": "object",
                    "properties": { "query": { "type": "string" } },
                    "required": ["query"]
                }
            },
            { "name": "ping", "inputSchema": { "type": "object", "properties": {} } }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("tools");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    assert!(out.join("search.yml").exists(), "search.yml missing");
    assert!(out.join("ping.yml").exists(), "ping.yml missing");
    let search_body = std::fs::read_to_string(out.join("search.yml")).unwrap();
    assert!(search_body.contains("search: happy path"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_schema_aware_uses_string_placeholder() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "say",
                "inputSchema": {
                    "type": "object",
                    "properties": { "text": { "type": "string" } },
                    "required": ["text"]
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("say.yml")).unwrap();
    // The stub generator renders a string placeholder for required string
    // args, exercising the schema-aware code path.
    assert!(
        body.contains("text: \"sample\""),
        "expected sample placeholder, got: {body}"
    );
    // And it also adds a missing-required-arg test plus a type-mismatch test.
    assert!(body.contains("rejects missing required arg"));
    assert!(body.contains("rejects type-mismatched text"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_synthesizes_format_aware_args() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "fetch",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "url": { "type": "string", "format": "uri" },
                        "limit": { "type": "integer", "minimum": 5 }
                    },
                    "required": ["url", "limit"]
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("fetch.yml")).unwrap();
    assert!(
        body.contains("url: \"https://example.com/resource\""),
        "uri format should synthesize a plausible URL, got: {body}"
    );
    assert!(
        body.contains("limit: 5"),
        "numeric minimum should win over the legacy placeholder, got: {body}"
    );
    assert_validates(&body);
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_emits_output_schema_conformance_test() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "forecast",
                "inputSchema": {
                    "type": "object",
                    "properties": { "city": { "type": "string" } },
                    "required": ["city"]
                },
                "outputSchema": {
                    "type": "object",
                    "required": ["temp_c"],
                    "properties": { "temp_c": { "type": "number" } }
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("forecast.yml")).unwrap();
    assert!(
        body.contains("forecast: response conforms to declared output schema"),
        "missing conformance test: {body}"
    );
    assert!(body.contains("target: result.structuredContent"));
    assert_validates(&body);
}

#[test]
fn generate_stubs_against_the_mock_emits_output_schema_conformance_test() {
    // WOR-1276: the mock now advertises `output_schema` from its manifest,
    // so the stdio leg can drive output-schema conformance end to end.
    let dir = tempdir().unwrap();
    let manifest = dir.path().join("stats-mock.yml");
    std::fs::write(
        &manifest,
        r#"mock_server:
  name: stats-service
  tools:
    - name: note_stats
      description: "Aggregate counts over the note store."
      input_schema: { type: object, properties: {} }
      output_schema:
        type: object
        required: ["total"]
        properties:
          total: { type: integer }
"#,
    )
    .unwrap();
    let command = format!(
        "{} mock --tools-from {}",
        shell_words::quote(&mcptest_bin()),
        shell_words::quote(manifest.to_str().unwrap()),
    );
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--server-command",
            &command,
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("note_stats.yml")).unwrap();
    assert!(
        body.contains("note_stats: response conforms to declared output schema"),
        "missing conformance test: {body}"
    );
    assert!(body.contains("target: result.structuredContent"));
    assert_validates(&body);
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_flag_suppresses_output_schema_tests() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "forecast",
                "inputSchema": { "type": "object" },
                "outputSchema": { "type": "object" }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--no-output-schema-tests",
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("forecast.yml")).unwrap();
    assert!(
        !body.contains("conforms to declared output schema"),
        "flag should suppress the conformance test: {body}"
    );
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_notes_tools_without_output_schema() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            { "name": "ping", "inputSchema": { "type": "object" } }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("ping.yml")).unwrap();
    assert!(
        body.contains("declares no output schema, consider adding one"),
        "missing nudge comment: {body}"
    );
    assert_validates(&body);
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_additive_mode_skips_existing_files() {
    let server = MockServer::start().await;
    mount_jsonrpc(&server, handshake_and_tools(json!([{ "name": "alpha" }]))).await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    std::fs::create_dir_all(&out).unwrap();
    std::fs::write(out.join("alpha.yml"), "DO NOT OVERWRITE\n").unwrap();
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("alpha.yml")).unwrap();
    assert_eq!(body, "DO NOT OVERWRITE\n");
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_overwrite_mode_replaces_existing_files() {
    let server = MockServer::start().await;
    mount_jsonrpc(&server, handshake_and_tools(json!([{ "name": "alpha" }]))).await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    std::fs::create_dir_all(&out).unwrap();
    std::fs::write(out.join("alpha.yml"), "stale\n").unwrap();
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--overwrite",
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("alpha.yml")).unwrap();
    assert_ne!(body, "stale\n");
    assert!(body.contains("alpha"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_check_mode_exits_six_on_drift() {
    let server = MockServer::start().await;
    mount_jsonrpc(&server, handshake_and_tools(json!([{ "name": "alpha" }]))).await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    std::fs::create_dir_all(&out).unwrap();
    std::fs::write(out.join("alpha.yml"), "drifted body\n").unwrap();
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--check",
        ])
        .assert()
        .code(6)
        .stderr(predicate::str::contains("drifted").or(predicate::str::contains("missing")));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_check_and_overwrite_are_mutually_exclusive() {
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            "http://127.0.0.1:0",
            "--check",
            "--overwrite",
        ])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("mutually exclusive"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_emit_schema_valid_tooltest_yaml() {
    // Every generated stub must validate against schemas/v1.json.
    // The catalog mixes a tool with required typed args (so the happy-path,
    // missing-required, and type-mismatch cases all fire) with an arg-less
    // tool (so the empty-args branch is covered too).
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "search",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "query": { "type": "string" },
                        "limit": { "type": "integer" }
                    },
                    "required": ["query", "limit"]
                }
            },
            { "name": "ping", "inputSchema": { "type": "object", "properties": {} } }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("tools");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--server-name",
            "fs",
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();

    for tool in ["search", "ping"] {
        let body = std::fs::read_to_string(out.join(format!("{tool}.yml")))
            .unwrap_or_else(|e| panic!("read {tool}.yml: {e}"));
        // The custom server name threads through to both the servers block
        // and each test's server field.
        assert!(body.contains("server: fs"), "{tool}.yml: {body}");
        assert_validates(&body);
    }
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_probe_flag_emits_probes_and_check_passes_after_generation() {
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "search",
                "inputSchema": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "query": { "type": "string", "minLength": 2, "maxLength": 8 },
                        "limit": { "type": "integer", "minimum": 1, "maximum": 50 }
                    },
                    "required": ["query", "limit"]
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--probe",
        ])
        .assert()
        .success();
    let body = std::fs::read_to_string(out.join("search.yml")).unwrap();
    assert!(
        body.contains("probe tier: schema-derived boundary and negative cases (seed 1260, cap 12)")
    );
    assert!(body.contains("search: probe query at maxLength"));
    assert!(body.contains("search: probe rejects limit above declared maximum"));
    assert!(body.contains("search: probe rejects unexpected extra property"));
    assert_validates(&body);

    // Probe output is deterministic, so --check over the just-written files
    // must report no drift.
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--probe",
            "--check",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("No drift detected"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_marks_destructive_named_tool_with_review_marker() {
    // WOR-1285: plain `generate stubs` must put the shared review marker
    // above the tests of destructive-classified tools, the same way
    // scaffold, suite rendering, and the probe tier already do.
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "delete_note",
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "string" } },
                    "required": ["id"]
                }
            },
            {
                "name": "get_note",
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "string" } },
                    "required": ["id"]
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();

    let destructive = std::fs::read_to_string(out.join("delete_note.yml")).unwrap();
    assert!(
        destructive.contains("# review before first run"),
        "destructive-named tool stub must carry the marker: {destructive}"
    );
    assert!(
        destructive.contains("developer's responsibility"),
        "marker must be the full shared block: {destructive}"
    );
    // The marker sits above the first test entry.
    let marker_pos = destructive.find("# review before first run").unwrap();
    let first_test_pos = destructive.find("- name:").unwrap();
    assert!(marker_pos < first_test_pos, "marker must precede the tests");
    assert_validates(&destructive);

    let read_only = std::fs::read_to_string(out.join("get_note.yml")).unwrap();
    assert!(
        !read_only.contains("# review before first run"),
        "read-only tool stub must not carry the marker: {read_only}"
    );
    assert_validates(&read_only);

    // The marker must not break --check determinism: re-running in check
    // mode right after generation reports no drift.
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
            "--check",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("No drift detected"));
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_marks_destructive_hint_annotated_tool_with_review_marker() {
    // Annotation-classified destructive tools (a harmless-looking name with
    // `destructiveHint: true`) get the same marker as destructive names.
    let server = MockServer::start().await;
    mount_jsonrpc(
        &server,
        handshake_and_tools(json!([
            {
                "name": "apply_change",
                "annotations": { "destructiveHint": true },
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "string" } },
                    "required": ["id"]
                }
            }
        ])),
    )
    .await;

    let dir = tempdir().unwrap();
    let out = dir.path().join("stubs");
    mcptest()
        .args([
            "generate",
            "stubs",
            "--url",
            &server.uri(),
            "--output",
            out.to_str().unwrap(),
        ])
        .assert()
        .success();

    let body = std::fs::read_to_string(out.join("apply_change.yml")).unwrap();
    assert!(
        body.contains("# review before first run"),
        "destructiveHint-annotated tool stub must carry the marker: {body}"
    );
    assert_validates(&body);
}

#[tokio::test(flavor = "current_thread")]
async fn generate_stubs_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 checked-in stubs and
    // `--check` baselines 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-stubs.yml")).unwrap();
    let command = format!(
        "{} mock --tools-from {}",
        shell_words::quote(&mcptest_bin()),
        shell_words::quote(catalog.to_str().unwrap()),
    );
    let assertion = mcptest()
        .args([
            "generate",
            "stubs",
            "--server-command",
            &command,
            "--stdout",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert_eq!(
        stdout, expected,
        "probe-off stub output drifted from the pre-probe fixture"
    );
}
