//! QA-owned corpus for `mcptest doctor --url` connectivity probes.
//!
//! `mcptest doctor` walks a layered pipeline (DNS, TCP, TLS, auth, MCP
//! `initialize`) and emits a layer-tagged report. Each scenario below
//! pins exactly one failure mode at one layer so a regression that
//! reorders rows or strips the suggestion line shows up in `cargo test`
//! before it ships.
//!
//! ## Scope
//!
//! - Connection-down (no listener on the target port) lands as a TCP
//!   (or in race conditions an MCP-INIT) failure with a suggestion line.
//! - A 5xx response from the upstream lands on [`Layer::McpInitialize`]
//!   with the retry-and-inspect-logs hint.
//! - A response carrying an explicit `unauthorized` / `forbidden` token
//!   in the JSON-RPC error body routes to [`Layer::Authentication`].
//! - An invalid URL fails the DNS row before any socket work happens.
//! - The pipeline short-circuits: rows after the first failure are not
//!   appended.
//! - A happy initialize response makes every layer green.
//!
//! ## What we do not test here
//!
//! - TLS handshake failures: reproducing a TLS error without a real TLS
//!   peer is brittle; the unit tests in `mcptest-core::network`
//!   classify TLS-flavored strings directly.
//! - The full `mcptest doctor` binary path (no `--url`): covered by
//!   `tests/cli_doctor.rs`.
//! - A bare 401 HTTP status with no body: the streamable HTTP transport
//!   silently drops non-2xx-non-5xx statuses with empty or non-JSON
//!   bodies and waits for a response, so the doctor only ever sees a
//!   request timeout. To exercise the auth path here we surface the auth
//!   signal inside a JSON-RPC error payload (`HTTP 401 Unauthorized`
//!   embedded in the error message), which is what the protocol layer
//!   feeds the doctor's classifier.

use std::time::Duration;

use mcptest_cli::commands::doctor::{probe_url, run_token_catalog_check, DoctorAuth};
use mcptest_core::network::Layer;
use serde_json::{json, Value};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

/// Build a JSON-RPC response envelope that echoes the request's `id`.
///
/// The protocol Client matches responses to requests by `id`, so the
/// mock must echo the incoming id rather than hardcode a number. The
/// `payload` argument supplies either a `result` or an `error` object.
fn jsonrpc_envelope(req: &Request, payload: Value) -> ResponseTemplate {
    let parsed: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
    let id = parsed.get("id").cloned().unwrap_or(Value::Null);
    let mut envelope = json!({
        "jsonrpc": "2.0",
        "id": id,
    });
    if let Value::Object(map) = payload {
        for (k, v) in map {
            envelope[k] = v;
        }
    }
    ResponseTemplate::new(200)
        .set_body_string(serde_json::to_string(&envelope).unwrap())
        .insert_header("content-type", "application/json")
}

/// Mount a happy `initialize` response so probes that exercise the auth
/// or initialize layer can land cleanly.
async fn mount_ok_initialize(server: &MockServer) {
    Mock::given(method("POST"))
        .respond_with(|req: &Request| {
            jsonrpc_envelope(
                req,
                json!({
                    "result": {
                        "protocolVersion": "2025-06-18",
                        "serverInfo": { "name": "fake", "version": "0.1" },
                        "capabilities": {}
                    }
                }),
            )
        })
        .mount(server)
        .await;
}

/// Mount a JSON-RPC error response (with an `error` envelope) so the
/// doctor's `classify_protocol_error` sees a non-timeout failure path.
async fn mount_jsonrpc_error(server: &MockServer, message: &'static str) {
    Mock::given(method("POST"))
        .respond_with(move |req: &Request| {
            jsonrpc_envelope(
                req,
                json!({
                    "error": {
                        "code": -32001,
                        "message": message,
                    }
                }),
            )
        })
        .mount(server)
        .await;
}

/// Run a synchronous probe inside an async test without nesting tokio
/// runtimes. `probe_url` builds its own current-thread runtime, so we
/// dispatch it through `spawn_blocking` and wait for it to finish.
async fn probe(url: String, auth: DoctorAuth) -> mcptest_cli::commands::doctor::DoctorReport {
    tokio::task::spawn_blocking(move || probe_url(&url, auth))
        .await
        .expect("blocking probe task did not panic")
}

// ---------------------------------------------------------------------------
// DNS-layer failures
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invalid_url_fails_at_dns_layer_with_suggestion() {
    let report = probe("not even a url".into(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let first = report.checks.first().expect("at least one check");
    assert_eq!(first.layer, Layer::Dns);
    assert!(first.message.to_lowercase().contains("invalid url"));
    let diag = first
        .diagnostic
        .as_ref()
        .expect("DNS failure must carry a diagnostic");
    let hint = diag.suggestion.as_deref().unwrap_or("");
    assert!(
        hint.contains("absolute URL"),
        "suggestion should steer to an absolute URL, got {hint:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn url_without_hostname_fails_at_dns_layer() {
    let report = probe("http:///".into(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let first = report.checks.first().expect("at least one row");
    assert_eq!(first.layer, Layer::Dns);
}

// ---------------------------------------------------------------------------
// Connection-down (TCP layer)
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn closed_port_fails_at_tcp_or_initialize_layer() {
    // Bind a socket so the kernel allocates an unused port, then drop the
    // listener so the address is guaranteed closed. probing 127.0.0.1
    // means DNS always succeeds, isolating the failure to TCP (or, on
    // platforms that delay the connect refusal, to the initialize layer
    // once the request actually fires).
    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    drop(listener);
    let url = format!("http://127.0.0.1:{port}");

    let report = probe(url, DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let dns_passed = report
        .checks
        .iter()
        .find(|c| c.layer == Layer::Dns)
        .map(|c| c.passed)
        .unwrap_or(false);
    assert!(dns_passed, "DNS for 127.0.0.1 must succeed");

    let failing = report
        .checks
        .iter()
        .find(|c| !c.passed)
        .expect("a fail row");
    // The fail row is either the TCP layer (preferred, this is the
    // common case) or the MCP initialize layer (some platforms return a
    // late refusal when the runtime issues the POST).
    assert!(
        matches!(failing.layer, Layer::Tcp | Layer::McpInitialize),
        "expected TCP or MCP-INIT failure, got {:?}",
        failing.layer
    );
    let diag = failing
        .diagnostic
        .as_ref()
        .expect("failure must carry a diagnostic");
    assert!(diag.suggestion.is_some(), "diagnostic must carry a hint");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipeline_short_circuits_on_first_tcp_failure() {
    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    drop(listener);

    let report = probe(format!("http://127.0.0.1:{port}"), DoctorAuth::default()).await;
    // No row past the first failure should appear.
    let first_failed_idx = report
        .checks
        .iter()
        .position(|c| !c.passed)
        .expect("at least one failure");
    let trailing_rows = &report.checks[first_failed_idx + 1..];
    assert!(
        trailing_rows.is_empty(),
        "rows after the first failure leak through: {trailing_rows:?}"
    );
}

// ---------------------------------------------------------------------------
// Auth-layer routing: a JSON-RPC error whose message embeds the
// "unauthorized" marker classifies as Layer::Authentication.
//
// The streamable HTTP transport silently drops a bare 401 HTTP status
// (no body), so a realistic upstream that needs to surface auth must
// return a JSON-RPC error envelope. That is what the doctor's
// classifier inspects.
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn jsonrpc_unauthorized_error_lands_on_authentication_layer() {
    let server = MockServer::start().await;
    mount_jsonrpc_error(&server, "HTTP 401 Unauthorized: missing bearer token").await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let failing = report
        .checks
        .iter()
        .find(|c| !c.passed)
        .expect("a fail row");
    assert_eq!(failing.layer, Layer::Authentication);
    let hint = failing
        .diagnostic
        .as_ref()
        .and_then(|d| d.suggestion.as_deref())
        .unwrap_or("");
    // The credential-state hint (WOR-1274) names the input to fix; since
    // WOR-1268 it also ends with the troubleshooting link for the family.
    assert!(
        hint.contains("--bearer-token-env") || hint.contains("OAuth"),
        "auth hint should mention credentials, got {hint:?}"
    );
    assert!(
        hint.contains(mcptest_core::error_anchors::AUTH_FAILURE),
        "auth hint should link the troubleshooting entry, got {hint:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn jsonrpc_forbidden_error_lands_on_authentication_layer() {
    let server = MockServer::start().await;
    mount_jsonrpc_error(&server, "HTTP 403 forbidden: scope insufficient").await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    let failing = report.checks.iter().find(|c| !c.passed).unwrap();
    assert_eq!(failing.layer, Layer::Authentication);
}

// ---------------------------------------------------------------------------
// Server returns malformed initialize response
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn jsonrpc_generic_error_lands_on_mcp_initialize_layer() {
    let server = MockServer::start().await;
    mount_jsonrpc_error(&server, "internal server error in initialize handler").await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let failing = report.checks.iter().find(|c| !c.passed).unwrap();
    assert_eq!(failing.layer, Layer::McpInitialize);
    let hint = failing
        .diagnostic
        .as_ref()
        .and_then(|d| d.suggestion.as_deref())
        .unwrap_or("");
    assert!(
        hint.contains("server logs"),
        "initialize hint should mention server logs, got {hint:?}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_500_lands_on_mcp_initialize_layer() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
        .mount(&server)
        .await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    let failing = report.checks.iter().find(|c| !c.passed).unwrap();
    // 5xx is not an auth signal; the doctor classifies it as a generic
    // initialize failure with the retry-and-inspect-logs hint.
    assert_eq!(failing.layer, Layer::McpInitialize);
}

// ---------------------------------------------------------------------------
// Happy path: every layer green when initialize succeeds.
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn happy_initialize_marks_every_layer_green() {
    let server = MockServer::start().await;
    mount_ok_initialize(&server).await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    assert!(
        report.all_passed(),
        "probe failed against a healthy mock: {}",
        report.render_pretty()
    );
    let labels: Vec<_> = report.checks.iter().map(|c| c.layer.label()).collect();
    assert!(labels.contains(&"DNS"));
    assert!(labels.contains(&"TCP"));
    assert!(labels.contains(&"AUTH"));
    assert!(labels.contains(&"MCP-INIT"));
}

// ---------------------------------------------------------------------------
// WOR-1422: the live tool-catalog token check renders the catalog block.
// ---------------------------------------------------------------------------

/// Answer `initialize` and `tools/list` so the doctor token check can run end
/// to end. Routes on the request `method`.
async fn mount_initialize_and_tools(server: &MockServer) {
    Mock::given(method("POST"))
        .respond_with(|req: &Request| {
            let parsed: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
            let payload = if parsed.get("method").and_then(Value::as_str) == Some("tools/list") {
                json!({ "result": { "tools": [
                    {
                        "name": "search_corpus",
                        "description": "Search the corpus for matching documents.",
                        "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } } }
                    }
                ] } })
            } else {
                json!({ "result": {
                    "protocolVersion": "2025-06-18",
                    "serverInfo": { "name": "fake", "version": "0.1" },
                    "capabilities": {}
                } })
            };
            jsonrpc_envelope(req, payload)
        })
        .mount(server)
        .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_catalog_check_renders_the_catalog_block() {
    let server = MockServer::start().await;
    mount_initialize_and_tools(&server).await;
    let url = server.uri();
    // `run_token_catalog_check` builds its own runtime, so dispatch it through
    // `spawn_blocking` like the other doctor probes.
    let block = tokio::task::spawn_blocking(move || {
        run_token_catalog_check(&url, DoctorAuth::default(), "cl100k_base")
    })
    .await
    .unwrap();
    assert!(
        block.contains("Tool catalog context cost"),
        "expected the catalog token block, got: {block}"
    );
    assert!(
        block.contains("search_corpus"),
        "expected the advertised tool in the block, got: {block}"
    );
}

// ---------------------------------------------------------------------------
// Output format stability
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn render_pretty_contains_each_layer_label_on_full_pipeline() {
    let server = MockServer::start().await;
    mount_ok_initialize(&server).await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    let rendered = report.render_pretty();
    for label in ["[DNS]", "[TCP]", "[AUTH]", "[MCP-INIT]"] {
        assert!(
            rendered.contains(label),
            "rendered output is missing {label}: {rendered}"
        );
    }
    assert!(rendered.starts_with("mcptest doctor --url "));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn render_pretty_includes_hint_line_for_each_failure() {
    let server = MockServer::start().await;
    mount_jsonrpc_error(&server, "HTTP 401 Unauthorized").await;

    let report = probe(server.uri(), DoctorAuth::default()).await;
    let rendered = report.render_pretty();
    assert!(rendered.contains("FAIL"));
    assert!(
        rendered.contains("hint:"),
        "every failure row should include a hint line: {rendered}"
    );
}

// ---------------------------------------------------------------------------
// Sanity: probe_url does not panic on extreme inputs
// ---------------------------------------------------------------------------

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_url_string_fails_gracefully() {
    let report = probe(String::new(), DoctorAuth::default()).await;
    assert!(!report.all_passed());
    assert_eq!(report.checks.first().map(|c| c.layer), Some(Layer::Dns));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn extremely_long_url_path_does_not_panic() {
    let long_path = "x".repeat(8192);
    let url = format!("http://127.0.0.1:1/{long_path}");
    // The bound port is closed; the probe should report a TCP failure,
    // not panic on the oversized path.
    let _ = tokio::time::timeout(Duration::from_secs(15), probe(url, DoctorAuth::default()))
        .await
        .expect("probe completed within budget");
}
