//! Integration tests for `mcptest run --coverage` flags and the
//! `mcptest coverage` subcommand (WOR-1261).
//!
//! The first half pins the parser for `--coverage-threshold`, the
//! default-off behaviour of `--coverage`, and the `--coverage-threshold
//! requires --coverage` dependency so a future refactor cannot drop the
//! gate by accident. Per-run coverage recording is wired (WOR-1386); the
//! live recording and threshold gate are exercised end to end against a
//! mock server in cli_run_coverage.rs.
//!
//! The second half drives `mcptest coverage` end to end against a
//! `mcptest mock` catalog: a suite covering 3 of 6 declared tools yields
//! `--suggest` drafts for exactly the missing 3 (plus an uncovered-arg
//! draft and an error-path draft), the merged drafts run green against
//! the mock, and re-running coverage reports 100% tool coverage.

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

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

/// Fresh tempdir per run-path test so the bare default config resolution
/// never picks up the repo's checked-in mcptest.yml (WOR-1350).
fn empty_dir() -> tempfile::TempDir {
    tempfile::tempdir().expect("tempdir")
}

#[test]
fn coverage_flag_no_longer_prints_the_deprecated_unavailable_note() {
    // Per-run coverage is now wired (WOR-1386), so `--coverage` no longer
    // prints the old "not yet available, use `mcptest coverage`" note. The
    // real recording is exercised against a live mock in cli_run_coverage.rs;
    // here we only guard that the deprecated line is gone.
    let dir = empty_dir();
    mcptest()
        .current_dir(dir.path())
        .args(["run", "--coverage"])
        .assert()
        .success()
        .stdout(predicate::str::contains("per-run recording is not yet available").not());
}

#[test]
fn coverage_threshold_parses_without_a_suite_to_gate() {
    // `--coverage-threshold` parses and validates at the CLI layer. In a dir
    // with no config the run returns before any test dispatches, so no
    // coverage is recorded and the gate never evaluates: the run exits 0.
    // The gate firing on a real run (exit 6) is covered in cli_run_coverage.rs.
    let dir = empty_dir();
    mcptest()
        .current_dir(dir.path())
        .args([
            "run",
            "--coverage",
            "--coverage-threshold",
            "tools=80,args=60",
        ])
        .assert()
        .success();
}

#[test]
fn coverage_threshold_rejects_invalid_syntax() {
    mcptest()
        .args(["run", "--coverage", "--coverage-threshold", "zebras=99"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("unknown threshold dimension"));
}

#[test]
fn coverage_threshold_rejects_out_of_range_value() {
    mcptest()
        .args(["run", "--coverage", "--coverage-threshold", "tools=150"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("threshold"));
}

#[test]
fn coverage_threshold_rejects_non_numeric_value() {
    mcptest()
        .args(["run", "--coverage", "--coverage-threshold", "tools=high"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("not a number"));
}

#[test]
fn coverage_threshold_without_coverage_exits_two() {
    let dir = empty_dir();
    mcptest()
        .current_dir(dir.path())
        .args(["run", "--coverage-threshold", "tools=80"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("requires --coverage"));
}

#[test]
fn coverage_flag_defaults_to_off() {
    let dir = empty_dir();
    let assertion = mcptest()
        .current_dir(dir.path())
        .arg("run")
        .assert()
        .success();
    let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
    assert!(
        !stdout.contains("coverage:"),
        "the coverage summary line must only render when --coverage is set"
    );
}

#[test]
fn coverage_accepts_all_four_dimensions() {
    let dir = empty_dir();
    mcptest()
        .current_dir(dir.path())
        .args([
            "run",
            "--coverage",
            "--coverage-threshold",
            "tools=80,args=60,error_paths=50,transports=100",
        ])
        .assert()
        .success();
}

#[test]
fn coverage_threshold_never_exits_six_against_stub_run() {
    // The old stub folded an empty tracker into a fabricated 100%
    // report and ran the gate against it, which could exit 6 on nothing.
    // With no real coverage recorded, even a maxed-out threshold must not
    // produce the exit-6 quality-gate failure.
    let dir = empty_dir();
    mcptest()
        .current_dir(dir.path())
        .args([
            "run",
            "--coverage",
            "--coverage-threshold",
            "tools=100,args=100,error_paths=100,transports=100",
        ])
        .assert()
        .code(predicate::ne(6));
}

// ---------------------------------------------------------------------
// `mcptest coverage` against a `mcptest mock` catalog (WOR-1261).
// ---------------------------------------------------------------------

/// 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 six-tool mock manifest: five no-arg tools plus `search`, which
/// declares a required `q` and an optional `limit`.
fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
    let manifest = dir.join("manifest.yml");
    let mut body = String::from("mock_server:\n  name: cov-service\n  tools:\n");
    for tool in ["alpha", "bravo", "charlie", "delta", "echo"] {
        body.push_str(&format!(
            "    - name: {tool}\n      description: {tool}. Read-only.\n      input_schema: {{ type: object, properties: {{}} }}\n"
        ));
    }
    body.push_str(
        "    - name: search\n      description: Search. Read-only.\n      input_schema:\n        type: object\n        properties:\n          q: { type: string }\n          limit: { type: integer, minimum: 1 }\n        required: [q]\n",
    );
    std::fs::write(&manifest, body).unwrap();
    manifest
}

/// Write a suite that covers 3 of the 6 mock tools (alpha, bravo, and
/// search, the latter without its optional `limit`), and declares one
/// error fixture no test triggers.
fn write_suite(dir: &std::path::Path, manifest: &std::path::Path) -> std::path::PathBuf {
    let bin = mcptest_bin();
    let mut body = format!(
        "servers:\n  cov:\n    command: ['{bin}', 'mock', '--tools-from', '{}']\n",
        manifest.display()
    );
    body.push_str(
        "fixtures:\n  errors:\n    - name: rate_limited\n      code: -32000\n      message: rate limit exceeded\n      applies_to: any\n",
    );
    body.push_str("tools:\n");
    for tool in ["alpha", "bravo"] {
        body.push_str(&format!(
            "  - name: {tool} responds\n    server: cov\n    tool: {tool}\n    args: {{}}\n    expect:\n      - target: result\n        matcher: {{ schema: {{ type: object }} }}\n"
        ));
    }
    body.push_str(
        "  - name: search with query\n    server: cov\n    tool: search\n    args: { q: hello }\n    expect:\n      - target: result\n        matcher: { schema: { type: object } }\n",
    );
    let suite = dir.join("suite.yml");
    std::fs::write(&suite, body).unwrap();
    suite
}

/// Run `mcptest coverage --format json [--suggest]` and parse the report.
fn coverage_json(
    dir: &std::path::Path,
    suite: &std::path::Path,
    manifest: &std::path::Path,
    suggest: bool,
) -> serde_json::Value {
    let mut cmd = mcptest();
    cmd.current_dir(dir).args([
        "coverage",
        suite.to_str().unwrap(),
        "--tools-from",
        manifest.to_str().unwrap(),
        "--format",
        "json",
    ]);
    if suggest {
        cmd.arg("--suggest");
    }
    let assert = cmd.assert().success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    serde_json::from_str(&stdout).expect("coverage --format json emits one JSON object")
}

#[test]
fn coverage_table_reports_uncovered_tools_against_the_mock_catalog() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    mcptest()
        .current_dir(dir.path())
        .args([
            "coverage",
            suite.to_str().unwrap(),
            "--tools-from",
            manifest.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("charlie"))
        .stdout(predicate::str::contains("50% tool coverage"))
        .stdout(predicate::str::contains(
            "error path not triggered: rate_limited",
        ));
}

#[test]
fn coverage_without_a_catalog_exits_two_with_a_pointer_to_tools_from() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    mcptest()
        .current_dir(dir.path())
        .args(["coverage", suite.to_str().unwrap()])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("--tools-from"));
}

#[test]
fn coverage_threshold_gate_exits_six_when_tool_coverage_is_below_it() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    mcptest()
        .current_dir(dir.path())
        .args([
            "coverage",
            suite.to_str().unwrap(),
            "--tools-from",
            manifest.to_str().unwrap(),
            "--threshold",
            "80",
        ])
        .assert()
        .code(6);
}

#[test]
fn suggest_emits_drafts_for_exactly_the_uncovered_surface() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    let report = coverage_json(dir.path(), &suite, &manifest, true);

    assert_eq!(report["tool_coverage_pct"], 50.0);
    let items = report["suggestions"]["items"].as_array().unwrap();

    // Exactly the three uncovered tools get a happy-path draft.
    let tool_drafts: Vec<&str> = items
        .iter()
        .filter(|i| i["kind"] == "tool")
        .map(|i| i["tool"].as_str().unwrap())
        .collect();
    assert_eq!(tool_drafts, vec!["charlie", "delta", "echo"]);

    // The uncovered optional argument gets its own draft.
    let arg_drafts: Vec<(&str, &str)> = items
        .iter()
        .filter(|i| i["kind"] == "arg")
        .map(|i| (i["tool"].as_str().unwrap(), i["arg"].as_str().unwrap()))
        .collect();
    assert_eq!(arg_drafts, vec![("search", "limit")]);

    // The untriggered error fixture gets a rejection draft.
    let error_drafts: Vec<&str> = items
        .iter()
        .filter(|i| i["kind"] == "error_path")
        .map(|i| i["yaml"].as_str().unwrap())
        .collect();
    assert_eq!(error_drafts.len(), 1);
    assert!(error_drafts[0].contains("inject_error: rate_limited"));

    // Every draft carries the marker comment on its first line.
    let yaml = report["suggestions"]["yaml"].as_str().unwrap();
    for item in items {
        assert!(item["yaml"]
            .as_str()
            .unwrap()
            .trim_start()
            .starts_with("# suggested by mcptest coverage"));
    }
    assert!(yaml.starts_with("tools:\n"));
}

#[test]
fn suggest_pretty_output_prints_the_draft_block_after_the_table() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    let assert = mcptest()
        .current_dir(dir.path())
        .args([
            "coverage",
            suite.to_str().unwrap(),
            "--tools-from",
            manifest.to_str().unwrap(),
            "--suggest",
        ])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let table_at = stdout.find("TOOL").expect("the coverage table");
    let drafts_at = stdout
        .find("# suggested by mcptest coverage")
        .expect("the draft block");
    assert!(
        table_at < drafts_at,
        "drafts must print after the table:\n{stdout}"
    );
}

#[test]
fn merging_suggestions_lifts_tool_coverage_to_one_hundred_percent() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    let report = coverage_json(dir.path(), &suite, &manifest, true);
    let drafts_yaml = report["suggestions"]["yaml"].as_str().unwrap();

    // Merge: append the suggested entries under the suite's tools: key,
    // exactly the additive merge an operator (or agent) would perform.
    let mut merged: serde_yaml::Value =
        serde_yaml::from_str(&std::fs::read_to_string(&suite).unwrap()).unwrap();
    let drafts: serde_yaml::Value = serde_yaml::from_str(drafts_yaml).unwrap();
    let mut tests = merged["tools"].as_sequence().cloned().unwrap();
    let existing_names: Vec<String> = tests
        .iter()
        .map(|t| t["name"].as_str().unwrap().to_string())
        .collect();
    for draft in drafts["tools"].as_sequence().unwrap() {
        // Mergeability contract: no draft name collides with a
        // hand-authored test.
        let name = draft["name"].as_str().unwrap();
        assert!(
            !existing_names.contains(&name.to_string()),
            "draft name {name:?} collides with an existing test"
        );
        tests.push(draft.clone());
    }
    merged["tools"] = serde_yaml::Value::Sequence(tests);
    let merged_path = dir.path().join("merged.yml");
    std::fs::write(&merged_path, serde_yaml::to_string(&merged).unwrap()).unwrap();

    // The merged suite runs green against the mock: the drafts are
    // ready-to-merge tests, not pseudo-YAML.
    mcptest()
        .current_dir(dir.path())
        .args(["run", merged_path.to_str().unwrap(), "--reporter", "quiet"])
        .assert()
        .success();

    // And re-running coverage reports the closed gaps.
    let after = coverage_json(dir.path(), &merged_path, &manifest, false);
    assert_eq!(after["tool_coverage_pct"], 100.0);
    assert_eq!(after["error_path_coverage_pct"], 100.0);
}

#[test]
fn suggested_names_never_collide_with_existing_test_names() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_manifest(dir.path());
    let suite = write_suite(dir.path(), &manifest);
    // A hand-authored test already owns the name a charlie draft would
    // get, even though it never calls charlie.
    let mut body = std::fs::read_to_string(&suite).unwrap();
    body.push_str(
        "  - name: \"charlie: valid arguments\"\n    server: cov\n    tool: alpha\n    args: {}\n    expect:\n      - target: result\n        matcher: { schema: { type: object } }\n",
    );
    std::fs::write(&suite, body).unwrap();

    let report = coverage_json(dir.path(), &suite, &manifest, true);
    let charlie = report["suggestions"]["items"]
        .as_array()
        .unwrap()
        .iter()
        .find(|i| i["kind"] == "tool" && i["tool"] == "charlie")
        .expect("a draft for the uncovered charlie tool");
    assert!(charlie["yaml"]
        .as_str()
        .unwrap()
        .contains("charlie: valid arguments (suggested)"));
}
