//! Integration tests for `mcptest propose` (WOR-1262): observe a live tool
//! call and print a proposed expect block. The target is `mcptest mock`
//! spawned through `--server-command`, so everything runs offline.

use assert_cmd::Command;
use predicates::prelude::*;

/// Absolute path to the built `mcptest` binary under test.
fn mcptest_bin() -> String {
    assert_cmd::cargo::cargo_bin("mcptest")
        .to_string_lossy()
        .into_owned()
}

/// Write a mock manifest with a stable read-only tool plus mutating and
/// destructive named siblings, and return the single-string command line
/// `--server-command` takes. Single quotes keep Windows backslashes literal
/// under the POSIX split.
fn mock_server_command(dir: &std::path::Path) -> String {
    let manifest = dir.join("propose-mock.yml");
    std::fs::write(
        &manifest,
        "mock_server:\n  name: propose-service\n  tools:\n    - name: get_status\n      description: Return status. Read-only.\n      input_schema: { type: object, additionalProperties: false, properties: {} }\n      response:\n        isError: false\n        content:\n          - type: text\n            text: \"status: degraded\"\n    - name: delete_records\n      description: Delete all records.\n      input_schema: { type: object, properties: { table: { type: string } }, required: [table] }\n      response:\n        content:\n          - type: text\n            text: \"deleted\"\n",
    )
    .unwrap();
    format!(
        "'{}' mock --tools-from '{}'",
        mcptest_bin(),
        manifest.display()
    )
}

#[test]
fn propose_prints_an_observation_derived_block_with_notes_on_stderr() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    let output = Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "get_status",
            "--server-command",
            &server_command,
        ])
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    // The block is on stdout: invariant, schema, stable exact, and the
    // long-form expect with a real, evaluated latency budget (WOR-1280).
    assert!(
        stdout.contains("- name: \"get_status: proposed assertions\""),
        "{stdout}"
    );
    assert!(stdout.contains("assertions:"), "{stdout}");
    assert!(stdout.contains("target: \"result.isError\""), "{stdout}");
    assert!(stdout.contains("schema:"), "{stdout}");
    assert!(stdout.contains("exact: \"status: degraded\""), "{stdout}");
    assert!(stdout.contains("max_duration_ms:"), "{stdout}");
    assert!(
        stdout.contains("# latency budget: 2x the slowest observed call"),
        "{stdout}"
    );
    // Notes are on stderr so a redirect captures a clean block.
    assert!(stderr.contains("calls made: 2"), "{stderr}");
    assert!(!stdout.contains("note:"), "{stdout}");
}

#[test]
fn proposed_block_round_trips_green_through_mcptest_run() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    let output = Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "get_status",
            "--server-command",
            &server_command,
        ])
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    let proposed = String::from_utf8(output.stdout).unwrap();

    // Paste the block under tools: in a suite that declares the target.
    let manifest = dir.path().join("propose-mock.yml");
    let suite = format!(
        "servers:\n  target:\n    command: ['{}', 'mock', '--tools-from', '{}']\ntools:\n{proposed}",
        mcptest_bin(),
        manifest.display()
    );
    let suite_path = dir.path().join("proposed.yml");
    std::fs::write(&suite_path, suite).unwrap();
    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["run", "--config", suite_path.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed, 0 failed"));
}

#[test]
fn single_call_skips_the_stability_probe_and_proposes_no_exact_values() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    let output = Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "get_status",
            "--server-command",
            &server_command,
            "--single-call",
        ])
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("calls made: 1"), "{stderr}");
    assert!(stderr.contains("single"), "{stderr}");
    // Only the isError invariant carries an exact matcher.
    assert!(!stdout.contains("exact: \"status: degraded\""), "{stdout}");
}

#[test]
fn destructive_tool_is_refused_without_the_execute_destructive_flag() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "delete_records",
            "--args",
            r#"{"table":"t"}"#,
            "--server-command",
            &server_command,
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--execute-destructive"));
}

#[test]
fn destructive_tool_with_the_flag_is_observed_with_exactly_one_call() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    let output = Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "delete_records",
            "--args",
            r#"{"table":"t"}"#,
            "--server-command",
            &server_command,
            "--execute-destructive",
        ])
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("calls made: 1"), "{stderr}");
    assert!(stderr.contains("destructive"), "{stderr}");
}

#[test]
fn unknown_tool_fails_with_the_available_catalog() {
    let dir = tempfile::tempdir().unwrap();
    let server_command = mock_server_command(dir.path());
    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "no_such_tool",
            "--server-command",
            &server_command,
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("get_status"));
}

#[test]
fn url_and_server_command_are_mutually_exclusive_and_one_is_required() {
    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "t",
            "--url",
            "http://localhost:1/mcp",
            "--server-command",
            "./srv",
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("cannot be used with"));
    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["propose", "--tool", "t"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("required"));
}

/// A stdio MCP server whose tools/call response carries a per-call counter,
/// so the two-call stability probe sees a changing leaf. Builtins only.
#[cfg(unix)]
fn counting_script(dir: &std::path::Path) -> std::path::PathBuf {
    use std::os::unix::fs::PermissionsExt;
    let script = dir.join("counting-server.sh");
    std::fs::write(
        &script,
        r##"#!/bin/sh
n=0
while IFS= read -r line; do
  case "$line" in
    *'"id":'*) tmp=${line#*'"id":'}; id=${tmp%%[!0-9]*} ;;
    *) id= ;;
  esac
  case "$line" in
    *'"method":"initialize"'*)
      printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-03-26","serverInfo":{"name":"counting","version":"0.0.1"},"capabilities":{"tools":{}}}}\n' "$id" ;;
    *'"method":"notifications/initialized"'*) : ;;
    *'"method":"tools/list"'*)
      printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"get_ticket","description":"Returns a ticket. Read-only.","inputSchema":{"type":"object","properties":{}}}]}}\n' "$id" ;;
    *'"method":"tools/call"'*)
      n=$((n+1))
      printf '{"jsonrpc":"2.0","id":%s,"result":{"isError":false,"content":[{"type":"text","text":"ticket ready"}],"structuredContent":{"status":"ok","serial":%s}}}\n' "$id" "$n" ;;
    *'"method":'*)
      [ -n "$id" ] && printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"nope"}}\n' "$id" ;;
    *) : ;;
  esac
done
"##,
    )
    .unwrap();
    let mut perms = std::fs::metadata(&script).unwrap().permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(&script, perms).unwrap();
    script
}

#[cfg(unix)]
#[test]
fn changing_leaf_is_excluded_and_the_proposal_passes_a_later_run() {
    let dir = tempfile::tempdir().unwrap();
    let script = counting_script(dir.path());
    let server_command = format!("'{}'", script.display());
    let output = Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "propose",
            "--tool",
            "get_ticket",
            "--server-command",
            &server_command,
        ])
        .output()
        .unwrap();
    assert!(output.status.success(), "{output:?}");
    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("excluded volatile leaf: result.structuredContent.serial"),
        "{stderr}"
    );
    // Stable leaves are pinned; the counter (observed 1, then 2) is not.
    assert!(stdout.contains("exact: \"ticket ready\""), "{stdout}");
    assert!(!stdout.contains("exact: 1"), "{stdout}");
    assert!(!stdout.contains("exact: 2"), "{stdout}");

    // A later run observes fresh serials; the proposal must still pass.
    let suite = format!(
        "servers:\n  target:\n    command: ['{}']\ntools:\n{stdout}",
        script.display()
    );
    let suite_path = dir.path().join("proposed.yml");
    std::fs::write(&suite_path, suite).unwrap();
    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["run", "--config", suite_path.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed, 0 failed"));
}
