//! Typed runner errors.
//!
//! The runner state machine that consumes this enum is still gated on
//! ; the variants here document the contract callers should
//! pattern-match against today so future wiring stays predictable.

use thiserror::Error;

use crate::auth::AuthError;
use crate::network::NetworkDiagnostic;

/// Errors raised by the runner before it produces a [`crate::report::TestResult`].
///
/// We surface auth failures as their own variant rather than folding
/// them into a generic transport error because the operator response
/// is different: `AuthFailedAfterRefresh` means "go re-authenticate,"
/// `TransportFailed` usually means "fix your URL or check the server."
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RunnerError {
    /// Auth refresh succeeded but the server still returned 401. The
    /// operator's refresh token is valid but the access token it
    /// minted is not being honored. Usually a server-side bug or a
    /// scope mismatch.
    #[error("authentication failed after refresh: server still returned 401")]
    AuthFailedAfterRefresh,

    /// An OAuth refresh attempt failed and we cannot recover.
    #[error("oauth refresh failed: {0}")]
    AuthRefresh(#[from] AuthError),

    /// Transport-level failure (timeout, DNS, TLS) that has nothing to
    /// do with auth.
    #[error("transport failed: {0}")]
    TransportFailed(String),

    /// Layered network failure carrying a structured diagnostic
    ///. Wraps a [`NetworkDiagnostic`] so the runner can
    /// route DNS / TCP / TLS / HTTP / readiness failures through one
    /// variant without losing the URL, layer, and suggestion. The
    /// `Display` impl renders via [`NetworkDiagnostic::render_pretty`]
    /// so the wire format matches `mcptest doctor --url` output.
    #[error("{0}")]
    Network(NetworkDiagnostic),

    /// `HeaderValidator::validate` rejected a configured header at
    /// load time. The runner refuses to start when this fires so the
    /// operator sees the error before any tests run.
    #[error("invalid header configuration: {0}")]
    InvalidHeader(String),

    /// A runner worker task panicked or was cancelled before reporting
    ///. Surfaces from the engine's join loop so the caller
    /// sees a single typed error rather than a raw `tokio::task::JoinError`.
    #[error("runner worker join failed: {reason}")]
    WorkerJoin {
        /// Render of the underlying tokio join error.
        reason: String,
    },
}

impl RunnerError {
    /// Wrap a `reqwest::Error` into a [`RunnerError::Network`].
    ///
    /// Classifies the failure via
    /// [`classify_reqwest_error`](crate::network::classify_reqwest_error)
    /// and attaches the layer's
    /// [`Layer::default_suggestion`](crate::network::Layer::default_suggestion)
    /// so the
    /// rendered message ends with an actionable hint. Used by the
    /// streamable HTTP transport when an outgoing POST cannot be
    /// completed; replaces the historic ad-hoc `format!("{err}")`
    /// rendering with a structured diagnostic.
    pub fn from_reqwest(url: impl Into<String>, err: &reqwest::Error) -> Self {
        let layer = crate::network::classify_reqwest_error(err);
        let diag = NetworkDiagnostic::new(layer, url, err.to_string())
            .with_suggestion(layer.default_suggestion());
        RunnerError::Network(diag)
    }

    /// Return the underlying [`NetworkDiagnostic`] when this is a
    /// network-layer failure. The runner uses this to populate the
    /// `network_diagnostic` field on a JSON test failure so the JSON
    /// reporter surfaces the layer (kebab-case) without re-deriving it.
    pub fn diagnostic(&self) -> Option<&NetworkDiagnostic> {
        match self {
            RunnerError::Network(d) => Some(d),
            _ => None,
        }
    }
}

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

    #[test]
    fn auth_failed_after_refresh_renders_a_clear_message() {
        let err = RunnerError::AuthFailedAfterRefresh;
        let msg = err.to_string();
        assert!(msg.contains("after refresh"), "got: {msg}");
        assert!(msg.contains("401"), "got: {msg}");
    }

    #[test]
    fn auth_refresh_wraps_auth_error_with_source() {
        let inner = AuthError::NoRefreshToken;
        let err: RunnerError = inner.into();
        match err {
            RunnerError::AuthRefresh(AuthError::NoRefreshToken) => {}
            other => panic!("expected AuthRefresh wrapping NoRefreshToken, got {other:?}"),
        }
    }

    #[test]
    fn network_variant_renders_via_diagnostic_pretty() {
        use crate::network::Layer;
        let diag = NetworkDiagnostic::new(Layer::Tls, "https://mcp.example.com", "bad cert")
            .with_suggestion("inspect the chain");
        let err = RunnerError::Network(diag);
        let rendered = err.to_string();
        assert!(rendered.contains("[TLS]"));
        assert!(rendered.contains("bad cert"));
        assert!(rendered.contains("hint: inspect the chain"));
    }

    #[test]
    fn diagnostic_returns_some_for_network_variant() {
        use crate::network::Layer;
        let diag = NetworkDiagnostic::new(Layer::Tcp, "https://mcp.example.com", "refused");
        let err = RunnerError::Network(diag);
        assert!(err.diagnostic().is_some());
    }

    #[test]
    fn diagnostic_returns_none_for_other_variants() {
        let err = RunnerError::AuthFailedAfterRefresh;
        assert!(err.diagnostic().is_none());
    }

    #[test]
    fn from_reqwest_attaches_default_suggestion() {
        // Build a real reqwest error via a connect to a closed loopback
        // port with a tiny timeout. The classifier maps this to either
        // Tcp or HttpTransport; both layers ship default suggestions.
        let client = reqwest::blocking::Client::builder().build().unwrap();
        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 err = client
            .get(&url)
            .timeout(std::time::Duration::from_millis(50))
            .send()
            .unwrap_err();
        let runner_err = RunnerError::from_reqwest(&url, &err);
        let diag = runner_err.diagnostic().expect("network variant");
        assert!(diag.suggestion.is_some(), "suggestion should be populated");
    }
}
