//! Layer enum, diagnostic struct, and `reqwest::Error` classifier.
//!
//! Why split the failure surface by layer? A "connection failed" message is
//! useless on its own. A developer looking at `[dns] mcp.example.com: name
//! resolution failed` knows to check `/etc/hosts` or their DNS resolver,
//! while `[tls] ... certificate verify failed` points at the cert chain.
//! The runner uses [`Layer`] to pick a color and a one-line suggestion in
//! the pretty reporter, and the JSON reporter emits it as a stable kebab
//! case string for downstream tooling.

use serde::{Deserialize, Serialize};

/// Which protocol layer a URL-target failure occurred at.
///
/// The seven variants cover every failure mode for an MCP HTTPS endpoint:
/// DNS lookup, TCP connect, TLS handshake, HTTP transport, authentication,
/// the MCP `initialize` exchange, and post-init readiness probes. Adding a
/// layer is a breaking API change; the runner relies on an exhaustive match
/// to render colors and suggestions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Layer {
    /// DNS lookup failed (no A/AAAA record, NXDOMAIN, resolver timeout).
    Dns,
    /// TCP connect failed (refused, unreachable, connect timeout).
    Tcp,
    /// TLS handshake failed (bad cert, protocol mismatch, SNI rejection).
    Tls,
    /// HTTP transport failed (malformed response, read timeout, 5xx body).
    HttpTransport,
    /// Authentication failed (401, 403, OAuth refresh denied).
    Authentication,
    /// The MCP `initialize` handshake failed after HTTP succeeded.
    McpInitialize,
    /// A post-init readiness probe failed (server accepted init but is not
    /// serving tools or resources yet).
    Readiness,
}

impl Layer {
    /// Short uppercase label used by the pretty reporter (for example
    /// `"DNS"`, `"HTTP"`, `"AUTH"`).
    pub fn label(self) -> &'static str {
        match self {
            Layer::Dns => "DNS",
            Layer::Tcp => "TCP",
            Layer::Tls => "TLS",
            Layer::HttpTransport => "HTTP",
            Layer::Authentication => "AUTH",
            Layer::McpInitialize => "MCP-INIT",
            Layer::Readiness => "READY",
        }
    }

    /// Canonical one-line hint to print under a failure on this layer
    ///. The runner attaches this to a [`NetworkDiagnostic`]
    /// when the failure has no more specific hint, so the operator
    /// always gets a next step rather than a bare layer label. The
    /// hints stay short and actionable; richer flow charts live in
    /// `docs/url-targets.md`.
    pub fn default_suggestion(self) -> &'static str {
        match self {
            Layer::Dns => "check the hostname or your DNS resolver",
            Layer::Tcp => "verify the server is running and the port is reachable",
            Layer::Tls => "inspect the certificate chain, or pass --ca-bundle / --insecure-skip-verify for a local probe",
            Layer::HttpTransport => "retry, then inspect the server logs for a 5xx body",
            Layer::Authentication => {
                "re-check the bearer token env var, OAuth scope, or custom header values"
            }
            Layer::McpInitialize => {
                "retry, then inspect the server logs for the initialize call"
            }
            Layer::Readiness => {
                "increase --wait-for-ready, or check whether the server reports ready before serving traffic"
            }
        }
    }
}

/// Diagnostic returned by network probes and the runner's request pipeline.
///
/// Carries the layer plus a human message and an optional suggestion. The
/// pretty reporter uses [`Layer::label`] for grouping and coloring; the JSON
/// reporter emits the kebab-case serde form so downstream tooling can pivot.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetworkDiagnostic {
    /// Which layer the failure surfaced at.
    pub layer: Layer,
    /// The URL the runner was trying to reach (target endpoint, not the
    /// final redirect destination).
    pub url: String,
    /// Human-readable description of the failure.
    pub message: String,
    /// Optional one-line hint that points the developer at a likely fix.
    pub suggestion: Option<String>,
}

impl std::fmt::Display for NetworkDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.render_pretty())
    }
}

impl NetworkDiagnostic {
    /// Construct a diagnostic with no suggestion.
    pub fn new(layer: Layer, url: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            layer,
            url: url.into(),
            message: message.into(),
            suggestion: None,
        }
    }

    /// Attach a one-line suggestion. The pretty reporter renders the
    /// suggestion on a second line under the failure.
    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
        self.suggestion = Some(suggestion.into());
        self
    }

    /// Render as `"[LABEL] url: message"`, optionally followed by a second
    /// line `"  hint: suggestion"` when one is set. A TCP-layer failure
    /// (connection refused, unreachable server) also gets a trailing
    /// `see:` line pointing at the troubleshooting entry for that family
    /// (WOR-1268); other layers stay link-free on purpose.
    pub fn render_pretty(&self) -> String {
        let mut out = format!("[{}] {}: {}", self.layer.label(), self.url, self.message);
        if let Some(hint) = &self.suggestion {
            out.push_str(&format!("\n  hint: {hint}"));
        }
        if self.layer == Layer::Tcp {
            out.push_str(&format!(
                "\nsee: {}",
                crate::error_anchors::SERVER_UNREACHABLE
            ));
        }
        out
    }
}

/// Classify a `reqwest::Error` into a [`Layer`].
///
/// The mapping uses `reqwest`'s public predicates plus a substring sniff on
/// the error chain to tell DNS apart from TCP and TLS apart from generic
/// HTTP. The substring sniff is best-effort: `reqwest` and `hyper` do not
/// expose a structured layer, so the heuristic is documented and tested.
pub fn classify_reqwest_error(err: &reqwest::Error) -> Layer {
    let msg = format!("{err:#}").to_lowercase();
    classify_from_signals(
        err.is_connect(),
        err.is_timeout(),
        err.is_status(),
        err.status().map(|s| s.as_u16()),
        &msg,
    )
}

/// Inner classifier driven by booleans and a lower-cased message string.
///
/// Exposed `pub(crate)` so the unit tests can drive the decision tree
/// without fabricating a `reqwest::Error` (the type's constructors are
/// private).
pub(crate) fn classify_from_signals(
    is_connect: bool,
    is_timeout: bool,
    is_status: bool,
    status: Option<u16>,
    lower_msg: &str,
) -> Layer {
    if is_status {
        return match status {
            Some(401) | Some(403) => Layer::Authentication,
            _ => Layer::HttpTransport,
        };
    }
    if mentions_tls(lower_msg) {
        return Layer::Tls;
    }
    if is_connect {
        if mentions_dns(lower_msg) {
            return Layer::Dns;
        }
        return Layer::Tcp;
    }
    if is_timeout {
        // Connect-phase timeouts surface with is_connect=true above; a
        // timeout that reaches here is the response-phase variety.
        return Layer::HttpTransport;
    }
    Layer::HttpTransport
}

fn mentions_dns(msg: &str) -> bool {
    msg.contains("dns") || msg.contains("name resolution") || msg.contains("getaddrinfo")
}

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

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

    #[test]
    fn layer_serializes_as_kebab_case() {
        let cases = [
            (Layer::Dns, "\"dns\""),
            (Layer::Tcp, "\"tcp\""),
            (Layer::Tls, "\"tls\""),
            (Layer::HttpTransport, "\"http-transport\""),
            (Layer::Authentication, "\"authentication\""),
            (Layer::McpInitialize, "\"mcp-initialize\""),
            (Layer::Readiness, "\"readiness\""),
        ];
        for (layer, expected) in cases {
            let json = serde_json::to_string(&layer).unwrap();
            assert_eq!(json, expected, "layer {layer:?}");
        }
    }

    #[test]
    fn layer_deserializes_from_kebab_case() {
        let parsed: Layer = serde_json::from_str("\"mcp-initialize\"").unwrap();
        assert_eq!(parsed, Layer::McpInitialize);
    }

    #[test]
    fn render_pretty_without_suggestion_is_single_line() {
        let diag = NetworkDiagnostic::new(Layer::Tls, "https://mcp.example.com", "bad certificate");
        assert_eq!(
            diag.render_pretty(),
            "[TLS] https://mcp.example.com: bad certificate"
        );
    }

    #[test]
    fn render_pretty_links_troubleshooting_for_tcp_failures_only() {
        let tcp =
            NetworkDiagnostic::new(Layer::Tcp, "https://mcp.example.com", "connection refused");
        assert_eq!(
            tcp.render_pretty(),
            format!(
                "[TCP] https://mcp.example.com: connection refused\nsee: {}",
                crate::error_anchors::SERVER_UNREACHABLE
            )
        );
        let dns = NetworkDiagnostic::new(Layer::Dns, "https://nope.invalid", "nxdomain");
        assert!(
            !dns.render_pretty().contains("see:"),
            "non-TCP layers must not link the unreachable entry"
        );
    }

    #[test]
    fn render_pretty_with_suggestion_adds_second_line() {
        let diag = NetworkDiagnostic::new(Layer::Dns, "https://nope.invalid", "nxdomain")
            .with_suggestion("check the hostname or your resolver");
        assert_eq!(
            diag.render_pretty(),
            "[DNS] https://nope.invalid: nxdomain\n  hint: check the hostname or your resolver"
        );
    }

    #[test]
    fn classify_status_401_is_authentication() {
        assert_eq!(
            classify_from_signals(false, false, true, Some(401), ""),
            Layer::Authentication
        );
    }

    #[test]
    fn classify_status_403_is_authentication() {
        assert_eq!(
            classify_from_signals(false, false, true, Some(403), ""),
            Layer::Authentication
        );
    }

    #[test]
    fn classify_status_500_is_http_transport() {
        assert_eq!(
            classify_from_signals(false, false, true, Some(500), ""),
            Layer::HttpTransport
        );
    }

    #[test]
    fn classify_connect_with_dns_message_is_dns() {
        assert_eq!(
            classify_from_signals(
                true,
                false,
                false,
                None,
                "dns error: name resolution failed"
            ),
            Layer::Dns
        );
    }

    #[test]
    fn classify_connect_with_getaddrinfo_is_dns() {
        assert_eq!(
            classify_from_signals(true, false, false, None, "getaddrinfo enotfound"),
            Layer::Dns
        );
    }

    #[test]
    fn classify_connect_without_dns_hint_is_tcp() {
        assert_eq!(
            classify_from_signals(true, false, false, None, "connection refused"),
            Layer::Tcp
        );
    }

    #[test]
    fn classify_tls_message_is_tls_even_when_connect() {
        // A failed TLS handshake often reports is_connect=true under the
        // hood. TLS substring wins because the developer's next step is to
        // inspect the cert chain, not the TCP path.
        assert_eq!(
            classify_from_signals(true, false, false, None, "tls handshake failure"),
            Layer::Tls
        );
        assert_eq!(
            classify_from_signals(false, false, false, None, "invalid peer certificate"),
            Layer::Tls
        );
        assert_eq!(
            classify_from_signals(false, false, false, None, "ssl routines: bad record"),
            Layer::Tls
        );
    }

    #[test]
    fn classify_response_timeout_is_http_transport() {
        assert_eq!(
            classify_from_signals(false, true, false, None, "operation timed out"),
            Layer::HttpTransport
        );
    }

    #[test]
    fn classify_fallback_is_http_transport() {
        assert_eq!(
            classify_from_signals(false, false, false, None, "stream closed"),
            Layer::HttpTransport
        );
    }

    #[test]
    fn classify_reqwest_error_routes_through_helper() {
        // We cannot fabricate a `reqwest::Error` with arbitrary inner state
        // (the constructors are private), so we exercise the wrapper with a
        // real one produced by an invalid URL build. The exact layer is not
        // the point; the point is that classify_reqwest_error returns *a*
        // Layer without panicking, proving the wrapper is wired up.
        let client = reqwest::blocking::Client::builder().build().unwrap();
        let err = client
            .get("http://127.0.0.1:1")
            .timeout(std::time::Duration::from_millis(1))
            .send()
            .unwrap_err();
        let layer = classify_reqwest_error(&err);
        // Loopback to a closed port plus a 1ms budget can surface as either
        // Tcp (connect refused) or HttpTransport (timeout). Both are valid
        // and exercise distinct branches of the classifier.
        assert!(
            matches!(layer, Layer::Tcp | Layer::HttpTransport),
            "unexpected layer: {layer:?}"
        );
    }
}
