//! Integration tests for the `tool_quality:` check block.
//!
//! Stands up a wiremock streamable-HTTP MCP endpoint that answers `initialize`
//! and `tools/list`, then runs a `tool_quality:` suite against it and asserts
//! the exit code and the reporter line, with and without an explicit
//! `expect:`.  adds the lint-finding targets (`critical_count`,
//! `warning_count`) to the same block.

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 reply(body: Value) -> impl Fn(&Request) -> ResponseTemplate {
    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")
    }
}

async fn mount(server: &MockServer, tools: Value) {
    Mock::given(method("POST"))
        .and(wiremock::matchers::body_partial_json(
            json!({ "method": "initialize" }),
        ))
        .respond_with(reply(json!({
            "protocolVersion": "2025-06-18",
            "serverInfo": { "name": "fake", "version": "0.1" },
            "capabilities": { "tools": {} }
        })))
        .mount(server)
        .await;
    Mock::given(method("POST"))
        .and(wiremock::matchers::body_partial_json(
            json!({ "method": "tools/list" }),
        ))
        .respond_with(reply(json!({ "tools": tools })))
        .mount(server)
        .await;
}

/// A well-documented two-tool catalog that clears the default floors.
fn good_tools() -> Value {
    json!([
        {
            "name": "search",
            "description": "Search the corpus and return the top matching documents with scores.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "query": { "type": "string", "description": "The search query string to match against documents." }
                }
            },
            "outputSchema": { "type": "object" },
            "annotations": {}
        },
        {
            "name": "fetch_document",
            "description": "Fetch one document by id and return its full text content.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": { "type": "string", "description": "The unique document identifier to fetch." }
                }
            },
            "outputSchema": { "type": "object" },
            "annotations": {}
        }
    ])
}

/// A catalog with one bare, undocumented tool to drag the min score down.
fn bad_tools() -> Value {
    json!([
        {
            "name": "x",
            "description": "",
            "inputSchema": { "type": "object", "properties": { "arg": {} } }
        }
    ])
}

/// A catalog whose one tool duplicates its name in the description (DESC-003
/// critical) but is otherwise documented, so its averaged scores clear the
/// default floors. Only the  critical_count gate catches it.
fn critical_but_high_scoring_tools() -> Value {
    // The name (and matching description) is 24 chars, past the DESC-001
    // 20-char floor, so description-present scores 1.0 and the tool's averaged
    // score stays high; the only failing rule is DESC-003 (name == description).
    json!([
        {
            "name": "summarize_the_documents",
            "description": "summarize_the_documents",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": { "type": "string", "description": "The document id to summarize and return." }
                }
            },
            "outputSchema": { "type": "object" },
            "annotations": {}
        }
    ])
}

fn write_suite(dir: &std::path::Path, url: &str, expect_block: &str) -> std::path::PathBuf {
    let cfg = dir.join("suite.yml");
    let body = format!(
        "servers:\n  remote:\n    url: \"{url}\"\ntool_quality:\n  - name: catalog quality\n    server: remote{expect_block}\n"
    );
    std::fs::write(&cfg, body).unwrap();
    cfg
}

#[tokio::test(flavor = "multi_thread")]
async fn tool_quality_defaults_pass_for_a_good_catalog() {
    let server = MockServer::start().await;
    mount(&server, good_tools()).await;
    let dir = tempdir().unwrap();
    let cfg = write_suite(dir.path(), &server.uri(), "");
    mcptest()
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}

#[tokio::test(flavor = "multi_thread")]
async fn tool_quality_defaults_fail_for_a_bare_catalog() {
    let server = MockServer::start().await;
    mount(&server, bad_tools()).await;
    let dir = tempdir().unwrap();
    let cfg = write_suite(dir.path(), &server.uri(), "");
    mcptest()
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .failure()
        // Default pretty prints the per-test failure detail inline on stdout.
        .stdout(predicate::str::contains("catalog quality"))
        .stdout(predicate::str::contains("min_score"));
}

#[tokio::test(flavor = "multi_thread")]
async fn tool_quality_default_critical_count_gate_catches_a_critical_finding() {
    // The single tool duplicates its name (DESC-003 critical) but scores high
    // enough on the TDQS heuristics that min/mean would pass. The default
    // critical_count gate (<= 0) is what fails the run.
    let server = MockServer::start().await;
    mount(&server, critical_but_high_scoring_tools()).await;
    let dir = tempdir().unwrap();
    let cfg = write_suite(dir.path(), &server.uri(), "");
    mcptest()
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .failure()
        // Default pretty prints the per-test failure detail inline on stdout.
        .stdout(predicate::str::contains("critical_count"));
}

#[tokio::test(flavor = "multi_thread")]
async fn explicit_expect_can_gate_one_tool_by_name() {
    let server = MockServer::start().await;
    mount(&server, good_tools()).await;
    let dir = tempdir().unwrap();
    let expect = "\n    expect:\n      - target: tool[\"search\"].score\n        matcher: { schema: { minimum: 0.6 } }";
    let cfg = write_suite(dir.path(), &server.uri(), expect);
    mcptest()
        .args(["run", "--config", cfg.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}
