//! `mcptest doctor --url <base>` connectivity checks.
//!
//! Reports each layer of a URL-target connection separately so the
//! operator's next step is obvious. The checks run top-down (DNS, TCP,
//! TLS, auth, MCP `initialize`) and short-circuit on the first failure,
//! emitting the layer label, the URL, the underlying message, and a
//! suggested fix. Diagnostics are built from
//! [`mcptest_core::network::NetworkDiagnostic`] so the wire format matches
//! the runner's failure reports.
//!
//! The dotenv portion of `mcptest doctor` still lives in
//! `cli::doctor_command`; this module is dispatched only when
//! `--url` is supplied. Splitting the URL path out keeps the local
//! diagnostics simple (no network calls when there is no URL).

use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result};
use mcptest_core::network::{Layer, NetworkDiagnostic};
use mcptest_core::protocol::{Client, ProtocolError, ProtocolVersion};
use mcptest_core::transport::{StreamableHttp, StreamableHttpConfig};

/// One layer's check outcome.
///
/// `passed` is true when the layer succeeded. `diagnostic` is `Some` when
/// the layer failed and carries the structured report the pretty
/// renderer surfaces. Layers that did not run (because an earlier layer
/// failed) are not represented; the caller stops appending after the
/// first failure.
#[derive(Debug)]
pub struct LayerCheck {
    /// Which layer this row reports on.
    pub layer: Layer,
    /// True when the layer succeeded.
    pub passed: bool,
    /// One-line success or failure message rendered into the status row.
    pub message: String,
    /// Structured diagnostic, populated only on failure.
    pub diagnostic: Option<NetworkDiagnostic>,
}

/// Result of running the full doctor pipeline against a URL target.
///
/// Carries every layer check that ran (one per layer up to the first
/// failure). `Display` renders a banner plus a one-line-per-layer report
/// and, on failure, the diagnostic's suggestion.
#[derive(Debug, Default)]
pub struct DoctorReport {
    /// Per-layer rows in the order they ran.
    pub checks: Vec<LayerCheck>,
    /// URL the doctor was probing.
    pub url: String,
}

impl DoctorReport {
    /// True when every layer that ran reported success.
    pub fn all_passed(&self) -> bool {
        self.checks.iter().all(|c| c.passed)
    }

    /// Render the report as a multi-line block, one row per layer.
    pub fn render_pretty(&self) -> String {
        let mut out = format!("mcptest doctor --url {}\n", self.url);
        for check in &self.checks {
            let marker = if check.passed { "OK" } else { "FAIL" };
            out.push_str(&format!(
                "  [{}] {}: {}\n",
                check.layer.label(),
                marker,
                check.message
            ));
            if let Some(diag) = &check.diagnostic {
                if let Some(hint) = &diag.suggestion {
                    out.push_str(&format!("    hint: {hint}\n"));
                }
            }
        }
        out
    }
}

/// Bearer token shape forwarded to the streamable HTTP transport.
///
/// Kept as a struct so the caller can decide where the token came from
/// (`MCPTEST_API_TOKEN`, an explicit `--bearer-token`, an OAuth cache).
/// The CLI dispatch in [`crate::cli`] reads the env var and hands the
/// resolved string here.
#[derive(Debug, Clone, Default)]
pub struct DoctorAuth {
    /// Resolved bearer token, when supplied.
    pub bearer_token: Option<String>,
    /// Name of the env var `--bearer-token-env` pointed at, when the flag
    /// was given. Carried separately from the resolved value so the AUTH
    /// failure hint can distinguish "no flag at all" from "flag named a var
    /// that is unset or empty" (WOR-1274).
    pub bearer_token_env: Option<String>,
}

/// Warn when a single tool's serialized surface exceeds this many tokens.
const DOCTOR_PER_TOOL_TOKEN_WARN: usize = 1000;
/// Warn when the whole tool catalog exceeds this many tokens.
const DOCTOR_CATALOG_TOKEN_WARN: usize = 10_000;

/// Entry point for `mcptest doctor --url <base>`.
///
/// Runs the layered pipeline and prints the report. When `tokenizer` is `Some`
/// and the probe reached a working `initialize`, also runs the live
/// tool-catalog token check (WOR-1422). Returns `0` when every layer passes and
/// `1` on any failure; the token check is advisory and never changes the code.
pub fn run_doctor_url(
    base_url: &str,
    auth: DoctorAuth,
    quiet: bool,
    tokenizer: Option<&str>,
) -> Result<i32> {
    let report = probe_url(base_url, auth.clone());
    if !quiet {
        print!("{}", report.render_pretty());
    }
    // The token check needs a live `tools/list`, so only run it once the probe
    // confirms the server answered `initialize`. A bloated catalog is a warning,
    // not a connectivity failure, so it never flips the exit code.
    if report.all_passed() {
        if let Some(name) = tokenizer {
            let block = run_token_catalog_check(base_url, auth, name);
            if !quiet {
                print!("{block}");
            }
        }
    }
    if report.all_passed() {
        Ok(0)
    } else {
        Ok(1)
    }
}

/// Live tool-catalog token check (WOR-1422): connect to `base_url`, run
/// `initialize` + `tools/list`, and render the catalog token-cost report.
///
/// Returns the rendered block (or a one-line skip/failure note). Best-effort:
/// a connect or list failure is reported but not fatal, since the layered
/// probe in [`run_doctor_url`] already owns the hard connectivity verdict.
///
/// `pub` so the integration tests can drive the live `tools/list` path against
/// a mock server without spawning the binary.
pub fn run_token_catalog_check(base_url: &str, auth: DoctorAuth, tokenizer_name: &str) -> String {
    let Some(tokenizer) = mcptest_core::matchers::budgets::tokenizer_by_name(tokenizer_name)
        .ok()
        .flatten()
    else {
        return format!("tool catalog token check: unknown tokenizer `{tokenizer_name}`\n");
    };
    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(e) => return format!("tool catalog token check: could not start probe runtime: {e}\n"),
    };
    let tools = runtime.block_on(async move {
        let cfg = StreamableHttpConfig {
            url: base_url.to_owned(),
            bearer_token: auth.bearer_token.clone(),
            request_timeout: Duration::from_secs(10),
            connect_timeout: Duration::from_secs(10),
            ..StreamableHttpConfig::default()
        };
        let (transport, handle) = StreamableHttp::connect(cfg).map_err(|e| e.to_string())?;
        let client = Client::builder(transport)
            .with_request_timeout(Duration::from_secs(10))
            .build();
        client
            .initialize(ProtocolVersion::current())
            .await
            .map_err(|e| e.to_string())?;
        let tools = client
            .request("tools/list", serde_json::json!({}))
            .await
            .map_err(|e| e.to_string());
        client.close().await.ok();
        handle.shutdown().await;
        tools
    });
    match tools {
        Ok(tools_list) => crate::doctor::render_report(&crate::doctor::compute_catalog_tokens(
            &tools_list,
            &*tokenizer,
            DOCTOR_PER_TOOL_TOKEN_WARN,
            DOCTOR_CATALOG_TOKEN_WARN,
        )),
        Err(e) => format!("tool catalog token check: tools/list failed: {e}\n"),
    }
}

/// Synchronously run every layer check against `base_url`.
///
/// Pulled out as `pub` so tests can drive the pipeline without spawning
/// a process. Each helper short-circuits the next layer when it fails.
pub fn probe_url(base_url: &str, auth: DoctorAuth) -> DoctorReport {
    let mut report = DoctorReport {
        url: base_url.to_owned(),
        ..DoctorReport::default()
    };

    let parsed = match url::Url::parse(base_url) {
        Ok(p) => p,
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::Dns,
                passed: false,
                message: format!("invalid URL: {err}"),
                diagnostic: Some(
                    NetworkDiagnostic::new(Layer::Dns, base_url, format!("invalid URL: {err}"))
                        .with_suggestion("supply an absolute URL like https://mcp.example.com"),
                ),
            });
            return report;
        }
    };

    let host = match parsed.host_str() {
        Some(h) => h.to_owned(),
        None => {
            report.checks.push(LayerCheck {
                layer: Layer::Dns,
                passed: false,
                message: "URL has no hostname".into(),
                diagnostic: Some(
                    NetworkDiagnostic::new(Layer::Dns, base_url, "URL has no hostname")
                        .with_suggestion("URLs must include a host, e.g. https://mcp.example.com"),
                ),
            });
            return report;
        }
    };
    let scheme = parsed.scheme().to_owned();
    let port = parsed.port_or_known_default().unwrap_or(443);

    let socket_addrs = match check_dns(&mut report, &host, port) {
        Some(a) => a,
        None => return report,
    };
    let first_addr = match socket_addrs.first().copied() {
        Some(a) => a,
        None => {
            report.checks.push(LayerCheck {
                layer: Layer::Dns,
                passed: false,
                message: format!("no socket addresses for {host}"),
                diagnostic: Some(
                    NetworkDiagnostic::new(
                        Layer::Dns,
                        base_url,
                        format!("no socket addresses for {host}"),
                    )
                    .with_suggestion("check the hostname or your DNS resolver"),
                ),
            });
            return report;
        }
    };

    if !check_tcp(&mut report, base_url, first_addr) {
        return report;
    }

    if scheme == "https" && !check_tls(&mut report, base_url, &host, first_addr) {
        return report;
    }

    if !check_auth_and_initialize(&mut report, base_url, auth) {
        return report;
    }

    report
}

/// Resolve the host to one or more socket addresses. Records a DNS
/// success row on success and a DNS failure row on error.
fn check_dns(report: &mut DoctorReport, host: &str, port: u16) -> Option<Vec<SocketAddr>> {
    match (host, port).to_socket_addrs() {
        Ok(iter) => {
            let addrs: Vec<SocketAddr> = iter.collect();
            report.checks.push(LayerCheck {
                layer: Layer::Dns,
                passed: true,
                message: format!("resolved {host} to {} address(es)", addrs.len()),
                diagnostic: None,
            });
            Some(addrs)
        }
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::Dns,
                passed: false,
                message: format!("name resolution failed: {err}"),
                diagnostic: Some(
                    NetworkDiagnostic::new(
                        Layer::Dns,
                        format!("dns://{host}:{port}"),
                        format!("name resolution failed: {err}"),
                    )
                    .with_suggestion("check the hostname or your DNS resolver"),
                ),
            });
            None
        }
    }
}

/// TCP connect with a 5s budget. Records a TCP row either way.
fn check_tcp(report: &mut DoctorReport, base_url: &str, addr: SocketAddr) -> bool {
    match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
        Ok(_) => {
            report.checks.push(LayerCheck {
                layer: Layer::Tcp,
                passed: true,
                message: format!("connected to {addr}"),
                diagnostic: None,
            });
            true
        }
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::Tcp,
                passed: false,
                message: format!("connect failed: {err}"),
                diagnostic: Some(
                    NetworkDiagnostic::new(
                        Layer::Tcp,
                        base_url,
                        format!("connect to {addr} failed: {err}"),
                    )
                    .with_suggestion("verify the server is running and the port is reachable"),
                ),
            });
            false
        }
    }
}

/// Build a TLS connection and inspect the peer certificate.
///
/// Uses `rustls` defaults via reqwest; building a reqwest client with
/// `connect_only` is the simplest way to exercise the TLS handshake
/// without driving any application-layer protocol.
fn check_tls(report: &mut DoctorReport, base_url: &str, host: &str, addr: SocketAddr) -> bool {
    // Open a fresh TCP stream and run a TLS handshake against it via
    // reqwest's blocking client. The simplest TLS-only probe is a HEAD
    // request to the URL; on TLS failure reqwest returns an `is_connect`
    // error whose message mentions "tls"/"certificate".
    let _ = addr; // The socket address is for logging only.
    let client = match reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(5))
        .build()
    {
        Ok(c) => c,
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::Tls,
                passed: false,
                message: format!("could not build TLS client: {err}"),
                diagnostic: Some(NetworkDiagnostic::new(
                    Layer::Tls,
                    base_url,
                    format!("could not build TLS client: {err}"),
                )),
            });
            return false;
        }
    };
    match client.head(base_url).send() {
        Ok(_) => {
            report.checks.push(LayerCheck {
                layer: Layer::Tls,
                passed: true,
                message: format!("TLS handshake to {host} succeeded"),
                diagnostic: None,
            });
            true
        }
        Err(err) => {
            let lower = format!("{err:#}").to_lowercase();
            if mentions_tls(&lower) {
                report.checks.push(LayerCheck {
                    layer: Layer::Tls,
                    passed: false,
                    message: format!("TLS handshake failed: {err}"),
                    diagnostic: Some(
                        NetworkDiagnostic::new(
                            Layer::Tls,
                            base_url,
                            format!("TLS handshake failed: {err}"),
                        )
                        .with_suggestion(
                            "inspect the certificate chain or set --insecure-skip-verify for a local probe",
                        ),
                    ),
                });
                false
            } else {
                // A non-TLS failure (404, 401, etc.) means the handshake
                // itself was fine. We treat that as a TLS pass; the
                // initialize layer will surface the deeper failure.
                report.checks.push(LayerCheck {
                    layer: Layer::Tls,
                    passed: true,
                    message: format!("TLS handshake to {host} succeeded"),
                    diagnostic: None,
                });
                true
            }
        }
    }
}

/// Authenticate (when a token is set) and call MCP `initialize`.
///
/// A 401/403 surfaces as an [`Layer::Authentication`] row with a hint to
/// re-check credentials; every other failure rolls up under
/// [`Layer::McpInitialize`] with a hint to retry or inspect server logs.
fn check_auth_and_initialize(report: &mut DoctorReport, base_url: &str, auth: DoctorAuth) -> bool {
    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("failed to build tokio runtime for doctor probe")
    {
        Ok(rt) => rt,
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::McpInitialize,
                passed: false,
                message: format!("could not start probe runtime: {err}"),
                diagnostic: Some(NetworkDiagnostic::new(
                    Layer::McpInitialize,
                    base_url,
                    format!("could not start probe runtime: {err}"),
                )),
            });
            return false;
        }
    };
    runtime.block_on(async move { run_initialize_probe(report, base_url, auth).await })
}

/// Issue the streamable HTTP `initialize` request and route failures to
/// the auth or mcp-initialize layer.
async fn run_initialize_probe(report: &mut DoctorReport, base_url: &str, auth: DoctorAuth) -> bool {
    let cfg = StreamableHttpConfig {
        url: base_url.to_owned(),
        bearer_token: auth.bearer_token.clone(),
        request_timeout: Duration::from_secs(10),
        connect_timeout: Duration::from_secs(10),
        ..StreamableHttpConfig::default()
    };
    let (transport, handle) = match StreamableHttp::connect(cfg) {
        Ok(t) => t,
        Err(err) => {
            report.checks.push(LayerCheck {
                layer: Layer::McpInitialize,
                passed: false,
                message: format!("transport setup failed: {err}"),
                diagnostic: Some(
                    NetworkDiagnostic::new(
                        Layer::McpInitialize,
                        base_url,
                        format!("transport setup failed: {err}"),
                    )
                    .with_suggestion("re-check the URL scheme and header configuration"),
                ),
            });
            return false;
        }
    };
    let client = Client::builder(transport)
        .with_request_timeout(Duration::from_secs(10))
        .build();
    let result = client.initialize(ProtocolVersion::current()).await;
    client.close().await.ok();
    handle.shutdown().await;
    match result {
        Ok(_) => {
            // Auth implicitly passed when initialize succeeded with a
            // bearer token attached, so record both rows.
            report.checks.push(LayerCheck {
                layer: Layer::Authentication,
                passed: true,
                message: "credentials accepted by the server".into(),
                diagnostic: None,
            });
            report.checks.push(LayerCheck {
                layer: Layer::McpInitialize,
                passed: true,
                message: "initialize handshake succeeded".into(),
                diagnostic: None,
            });
            true
        }
        Err(err) => {
            let (layer, generic_hint) = classify_protocol_error(&err);
            let message = format!("{err}");
            // The AUTH layer gets a credential-state-specific hint (which
            // input to set, whether a named env var is empty, whether the
            // cached OAuth token expired) instead of the generic one.
            let hint = if layer == Layer::Authentication {
                auth_failure_hint(&auth, base_url, &message)
            } else {
                generic_hint.to_string()
            };
            report.checks.push(LayerCheck {
                layer,
                passed: false,
                message: message.clone(),
                diagnostic: Some(
                    NetworkDiagnostic::new(layer, base_url, message).with_suggestion(hint),
                ),
            });
            false
        }
    }
}

/// Pick the right layer for a `ProtocolError`.
///
/// Substring-based: the protocol module folds HTTP status codes into a
/// generic transport/server error string, so we sniff for the well-known
/// auth markers ("401", "403", "unauthorized", "forbidden") and route
/// every other failure to [`Layer::McpInitialize`].
fn classify_protocol_error(err: &ProtocolError) -> (Layer, &'static str) {
    let msg = format!("{err:#}").to_lowercase();
    if mentions_auth(&msg) {
        (
            Layer::Authentication,
            "re-check the bearer token env var, OAuth scope, or custom header values",
        )
    } else {
        (
            Layer::McpInitialize,
            "retry, then inspect the server logs for the initialize call",
        )
    }
}

fn mentions_auth(msg: &str) -> bool {
    msg.contains("401")
        || msg.contains("403")
        || msg.contains("unauthorized")
        || msg.contains("forbidden")
}

/// Build the one-line AUTH hint for a failed probe (WOR-1274). The hint
/// names the credential state the operator is actually in (no credentials,
/// named-but-empty env var, rejected token, insufficient token, expired
/// cached login) so the next command is obvious without reading docs.
fn auth_failure_hint(auth: &DoctorAuth, base_url: &str, message: &str) -> String {
    let forbidden = {
        let lower = message.to_lowercase();
        lower.contains("403") || lower.contains("forbidden")
    };
    let token_present = auth.bearer_token.as_deref().is_some_and(|t| !t.is_empty());
    let mut hint = match (&auth.bearer_token_env, token_present, forbidden) {
        (None, false, _) => format!(
            "no credentials were supplied: pass --bearer-token-env <VAR> naming an env var \
             that holds a token, or run `mcptest login {base_url}`"
        ),
        (Some(var), false, _) => format!(
            "env var `{var}` (named by --bearer-token-env) is not set or is empty: export a \
             token into it and re-run"
        ),
        (named, true, false) => format!(
            "the token from {} was rejected (HTTP 401): mint a fresh token, or run \
             `mcptest login {base_url}` to re-authenticate",
            describe_token_source(named.as_deref())
        ),
        (named, true, true) => format!(
            "the token from {} was accepted but lacks permission (HTTP 403): check the \
             token's scope or audience",
            describe_token_source(named.as_deref())
        ),
    };
    if !forbidden && cached_token_expired(base_url) {
        hint.push_str(&format!(
            "; note: the cached OAuth token for this URL has expired, run \
             `mcptest login {base_url}` to refresh it"
        ));
    }
    // WOR-1268: the troubleshooting link goes last so the credential-state
    // hint stays the first thing the operator reads.
    hint.push_str(&format!(
        "; see: {}",
        mcptest_core::error_anchors::AUTH_FAILURE
    ));
    hint
}

/// Name where the probed token came from, for the rejected/insufficient
/// hints. A token can reach the probe without `--bearer-token-env` (callers
/// other than the CLI fill `bearer_token` directly), so fall back to a
/// generic label rather than inventing a flag the operator never passed.
fn describe_token_source(var: Option<&str>) -> String {
    match var {
        Some(var) => format!("env var `{var}`"),
        None => "the supplied bearer token".to_string(),
    }
}

/// True when the `mcptest login` cache holds a token for `base_url` that is
/// already past its expiry. Best-effort: any cache error reads as "no
/// signal" because this only sharpens a hint, it never gates the probe.
/// Honors `MCPTEST_AUTH_DIR` the way the connector and `mcptest login` do,
/// so the three surfaces agree on which cache they are talking about.
fn cached_token_expired(base_url: &str) -> bool {
    use mcptest_core::auth::TokenCache;
    let cache = match std::env::var_os("MCPTEST_AUTH_DIR") {
        Some(dir) => TokenCache::with_root(std::path::PathBuf::from(dir)),
        None => match TokenCache::user_default() {
            Ok(c) => c,
            Err(_) => return false,
        },
    };
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    matches!(cache.read(base_url), Ok(Some(token)) if token.is_expired_at(now))
}

fn mentions_tls(msg: &str) -> bool {
    msg.contains("certificate") || msg.contains("tls") || msg.contains("ssl")
}

/// Write the doctor report to `path`, creating parent directories if
/// missing. Used by the (future) `--report` flag; today the CLI calls
/// [`DoctorReport::render_pretty`] directly.
pub fn write_doctor_report(report: &DoctorReport, path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
    }
    let mut file = std::fs::File::create(path)
        .with_context(|| format!("failed to create {}", path.display()))?;
    file.write_all(report.render_pretty().as_bytes())?;
    // Read the file back as a sanity check that the report landed on
    // disk and is readable before we report success to the caller.
    let mut buf = String::new();
    std::fs::File::open(path)?.read_to_string(&mut buf).ok();
    Ok(())
}

/// Render the local client-config inventory for `mcptest doctor`: every MCP
/// server declared across the supported clients (Claude Desktop, Claude Code,
/// Cursor, VS Code, Windsurf, Codex), as a test-readiness list.
///
/// Privacy is the whole point of the rendered shape: it emits server identity,
/// transport, and presence-of-auth only. It never prints the env values inlined
/// in a client's `env` block, an absolute filesystem path, or a raw command
/// line. The server name (the one free-text field) is passed through the
/// [`Redactor`](mcptest_core::redaction::Redactor) so a name that itself looks
/// like a token is scrubbed.
pub fn render_discovery_inventory() -> String {
    use mcptest_core::discovery::clients::discover_all_clients;
    use mcptest_core::redaction::Redactor;

    let servers = discover_all_clients();
    if servers.is_empty() {
        return "discovered MCP servers: none\n".to_string();
    }
    let redactor = Redactor::new();
    let mut out = format!(
        "discovered MCP servers ({}, test readiness):\n",
        servers.len()
    );
    for server in &servers {
        // stdio is the only transport a client `command:` entry expresses; the
        // discovery library does not parse URL servers from client configs.
        let auth = if server.server_config.env.is_empty() {
            "none"
        } else {
            "present"
        };
        out.push_str(&format!(
            "  {:<28} source={:<14} transport=stdio  auth={auth}\n",
            redactor.redact_value(&server.name),
            server.source.label(),
        ));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn invalid_url_renders_dns_failure() {
        let report = probe_url("not a url", DoctorAuth::default());
        assert!(!report.all_passed());
        assert_eq!(report.checks[0].layer, Layer::Dns);
    }

    #[test]
    fn dns_failure_short_circuits_pipeline() {
        // Use a hostname that cannot resolve. We deliberately do not hit
        // the network for a passing case here; the integration tests
        // cover the wiremock-backed happy path.
        let report = probe_url("http://nope.invalid.example", DoctorAuth::default());
        assert!(!report.all_passed());
        let first = report.checks.first().expect("at least one check");
        // DNS may pass on systems that wildcard NXDOMAIN, in which case
        // TCP will fail; either way the pipeline short-circuits before
        // hitting the MCP initialize layer.
        assert!(matches!(first.layer, Layer::Dns | Layer::Tcp));
        assert!(!report
            .checks
            .iter()
            .any(|c| c.layer == Layer::McpInitialize));
    }

    #[test]
    fn render_pretty_includes_url_banner_and_each_layer() {
        let mut report = DoctorReport {
            url: "https://mcp.example.com/v1".into(),
            ..DoctorReport::default()
        };
        report.checks.push(LayerCheck {
            layer: Layer::Dns,
            passed: true,
            message: "resolved mcp.example.com to 1 address(es)".into(),
            diagnostic: None,
        });
        report.checks.push(LayerCheck {
            layer: Layer::Tcp,
            passed: false,
            message: "connect refused".into(),
            diagnostic: Some(
                NetworkDiagnostic::new(Layer::Tcp, "https://mcp.example.com/v1", "connect refused")
                    .with_suggestion("verify the server is running and the port is reachable"),
            ),
        });
        let rendered = report.render_pretty();
        assert!(rendered.contains("mcptest doctor --url https://mcp.example.com/v1"));
        assert!(rendered.contains("[DNS] OK"));
        assert!(rendered.contains("[TCP] FAIL"));
        assert!(rendered.contains("hint: verify the server is running"));
    }

    #[test]
    fn classify_protocol_error_routes_401_to_authentication() {
        let err = ProtocolError::TransportClosed("HTTP 401 Unauthorized".into());
        let (layer, hint) = classify_protocol_error(&err);
        assert_eq!(layer, Layer::Authentication);
        assert!(hint.contains("bearer token"));
    }

    #[test]
    fn classify_protocol_error_routes_other_failures_to_mcp_initialize() {
        let err = ProtocolError::TransportClosed("connection closed".into());
        let (layer, _) = classify_protocol_error(&err);
        assert_eq!(layer, Layer::McpInitialize);
    }

    #[test]
    fn auth_hint_without_any_credentials_says_what_to_pass() {
        let auth = DoctorAuth::default();
        let hint = auth_failure_hint(&auth, "https://mcp.example.com", "HTTP 401 Unauthorized");
        assert!(hint.contains("no credentials were supplied"), "{hint}");
        assert!(hint.contains("--bearer-token-env"), "{hint}");
        assert!(
            hint.contains("mcptest login https://mcp.example.com"),
            "{hint}"
        );
    }

    #[test]
    fn auth_hint_names_an_env_var_that_is_set_but_empty() {
        let auth = DoctorAuth {
            bearer_token: Some(String::new()),
            bearer_token_env: Some("MY_TOKEN".into()),
        };
        let hint = auth_failure_hint(&auth, "https://mcp.example.com", "HTTP 401 Unauthorized");
        assert!(hint.contains("`MY_TOKEN`"), "{hint}");
        assert!(hint.contains("not set or is empty"), "{hint}");
    }

    #[test]
    fn auth_hint_names_an_env_var_that_is_not_set_at_all() {
        let auth = DoctorAuth {
            bearer_token: None,
            bearer_token_env: Some("MY_TOKEN".into()),
        };
        let hint = auth_failure_hint(&auth, "https://mcp.example.com", "HTTP 401 Unauthorized");
        assert!(hint.contains("`MY_TOKEN`"), "{hint}");
        assert!(hint.contains("not set or is empty"), "{hint}");
    }

    #[test]
    fn auth_hint_for_a_rejected_token_points_at_re_authentication() {
        let auth = DoctorAuth {
            bearer_token: Some("tok".into()),
            bearer_token_env: Some("MY_TOKEN".into()),
        };
        let hint = auth_failure_hint(&auth, "https://mcp.example.com", "HTTP 401 Unauthorized");
        assert!(hint.contains("`MY_TOKEN`"), "{hint}");
        assert!(hint.contains("rejected (HTTP 401)"), "{hint}");
        assert!(hint.contains("mcptest login"), "{hint}");
    }

    #[test]
    fn auth_hint_for_an_insufficient_token_points_at_scope() {
        let auth = DoctorAuth {
            bearer_token: Some("tok".into()),
            bearer_token_env: Some("MY_TOKEN".into()),
        };
        let hint = auth_failure_hint(&auth, "https://mcp.example.com", "HTTP 403 Forbidden");
        assert!(hint.contains("lacks permission (HTTP 403)"), "{hint}");
        assert!(hint.contains("scope or audience"), "{hint}");
    }

    #[test]
    fn auth_hint_ends_with_the_troubleshooting_link() {
        let see = format!("see: {}", mcptest_core::error_anchors::AUTH_FAILURE);
        for message in ["HTTP 401 Unauthorized", "HTTP 403 Forbidden"] {
            let hint = auth_failure_hint(&DoctorAuth::default(), "https://x.example", message);
            assert!(
                hint.ends_with(&see),
                "hint for `{message}` should end with the see link: {hint}"
            );
        }
    }

    #[test]
    fn auth_hint_flags_an_expired_cached_login_token() {
        use mcptest_core::auth::{Token, TokenCache};
        let dir = tempfile::tempdir().unwrap();
        let url = "https://expired.example.com/mcp";
        let cache = TokenCache::with_root(dir.path());
        cache
            .write(
                url,
                &Token {
                    access_token: "stale".into(),
                    refresh_token: None,
                    expires_at_unix: 1, // long past
                    token_type: "Bearer".into(),
                    scope: None,
                    last_refreshed: "2020-01-01T00:00:00Z".into(),
                    token_endpoint: None,
                    client_id: None,
                },
            )
            .unwrap();
        // Point the cache lookup at the temp root the same way the login
        // tests do. Serialized by running in one test body.
        std::env::set_var("MCPTEST_AUTH_DIR", dir.path());
        let hint = auth_failure_hint(&DoctorAuth::default(), url, "HTTP 401 Unauthorized");
        std::env::remove_var("MCPTEST_AUTH_DIR");
        assert!(hint.contains("cached OAuth token"), "{hint}");
        assert!(hint.contains("has expired"), "{hint}");
        assert!(hint.contains("mcptest login"), "{hint}");
    }

    #[test]
    fn unreachable_url_returns_one_from_run_doctor_url() {
        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 code = run_doctor_url(&url, DoctorAuth::default(), true, None).unwrap();
        assert_eq!(code, 1);
    }
}
