//! Error types surfaced by the Rust client.
//!
//! [`Error`] is the umbrella enum returned by every async wrapper. It
//! classifies `tonic::Status` codes (auth, timeout, cancellation) and folds
//! gateway protocol failures and command-level rejections into structured
//! variants. Credentials embedded in status messages are scrubbed before the
//! message reaches a caller.

use thiserror::Error as ThisError;
use tonic::Code;

use crate::generated::mxaccess_gateway::v1::{MxCommandReply, ProtocolStatus, ProtocolStatusCode};

/// Top-level error type returned by the Rust client wrappers.
///
/// The variants distinguish transport/setup failures, classified gRPC status
/// codes, gateway protocol-level failures (`OpenSession`, `CloseSession`),
/// and command-level rejections that surface a populated [`MxCommandReply`].
#[derive(Debug, ThisError)]
pub enum Error {
    /// Endpoint URL could not be parsed or its TLS material could not be
    /// loaded.
    #[error("invalid gateway endpoint `{endpoint}`: {detail}")]
    InvalidEndpoint {
        /// Endpoint string supplied by the caller.
        endpoint: String,
        /// Human-readable explanation of the parse/load failure.
        detail: String,
    },

    /// A caller-provided argument failed local validation before any RPC
    /// was dispatched (for example, a bulk command exceeding the size cap).
    #[error("invalid argument `{name}`: {detail}")]
    InvalidArgument {
        /// Name of the offending argument.
        name: String,
        /// Reason the argument was rejected.
        detail: String,
    },

    /// Tonic transport-level failure (DNS, connect, TLS handshake).
    #[error("gateway transport error: {0}")]
    Transport(#[from] tonic::transport::Error),

    /// Server returned `Unauthenticated` — the API key was missing or
    /// rejected.
    #[error("authentication failed: {message}")]
    Authentication {
        /// Redacted server-supplied detail message.
        message: String,
        /// Original `tonic::Status` for callers that need the full context.
        #[source]
        status: Box<tonic::Status>,
    },

    /// Server returned `PermissionDenied` — the API key is valid but lacks
    /// the required scope.
    #[error("authorization failed: {message}")]
    Authorization {
        /// Redacted server-supplied detail message.
        message: String,
        /// Original `tonic::Status`.
        #[source]
        status: Box<tonic::Status>,
    },

    /// Server returned `DeadlineExceeded`. Usually the per-call deadline
    /// configured via [`crate::options::ClientOptions::with_call_timeout`].
    #[error("gateway call timed out: {message}")]
    Timeout {
        /// Redacted server-supplied detail message.
        message: String,
        /// Original `tonic::Status`.
        #[source]
        status: Box<tonic::Status>,
    },

    /// Server (or client) cancelled the call before a reply was produced.
    #[error("gateway call cancelled: {message}")]
    Cancelled {
        /// Redacted server-supplied detail message.
        message: String,
        /// Original `tonic::Status`.
        #[source]
        status: Box<tonic::Status>,
    },

    /// Any other `tonic::Status` that did not match a more specific variant.
    #[error("gateway status error: {0}")]
    Status(Box<tonic::Status>),

    /// Gateway accepted the call but the worker reply carried a non-OK
    /// protocol status. The wrapped [`CommandError`] preserves the full
    /// reply so callers can inspect the worker's status payload.
    #[error("gateway command failed: {0}")]
    Command(#[from] Box<CommandError>),

    /// Protocol-level operation (open/close session) returned a non-OK
    /// [`ProtocolStatus`] envelope.
    #[error("gateway {operation} failed: {code:?}: {message}")]
    ProtocolStatus {
        /// Operation name, e.g. `"open session"`.
        operation: &'static str,
        /// Decoded protocol status code from the server.
        code: ProtocolStatusCode,
        /// Detail message from the server.
        message: String,
    },

    /// Gateway returned an `Ok` protocol status but the reply lacked the
    /// expected typed payload (or carried the wrong payload arm). Distinct
    /// from [`Error::ProtocolStatus`] because the protocol-level envelope
    /// itself succeeded — the corruption is in the payload shape.
    #[error("gateway returned a malformed reply: {detail}")]
    MalformedReply {
        /// Human-readable description of what was missing or mismatched.
        detail: String,
    },

    /// Server returned `Unavailable` or `ResourceExhausted` — classify
    /// transient failures separately from the catch-all [`Error::Status`].
    #[error("gateway unavailable: {message}")]
    Unavailable {
        /// Redacted server-supplied detail message.
        message: String,
        /// Original `tonic::Status`.
        #[source]
        status: Box<tonic::Status>,
    },
}

/// Wrapper around an [`MxCommandReply`] whose `protocol_status` reported a
/// non-OK code.
///
/// The wrapper is heap-allocated inside [`Error::Command`] to keep the
/// containing enum small. Callers can recover the reply with
/// [`CommandError::reply`] or [`CommandError::into_reply`].
#[derive(Clone, Debug)]
pub struct CommandError {
    reply: MxCommandReply,
}

impl CommandError {
    /// Wrap an already-failed command reply.
    pub fn new(reply: MxCommandReply) -> Self {
        Self { reply }
    }

    /// Borrow the underlying reply (correlation id, status, payload).
    pub fn reply(&self) -> &MxCommandReply {
        &self.reply
    }

    /// Consume the error and return the underlying reply.
    pub fn into_reply(self) -> MxCommandReply {
        self.reply
    }
}

impl std::fmt::Display for CommandError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let status = self.reply.protocol_status.as_ref();
        let code = status
            .and_then(|status| ProtocolStatusCode::try_from(status.code).ok())
            .unwrap_or(ProtocolStatusCode::Unspecified);
        let message = status.map(|status| status.message.as_str()).unwrap_or("");

        if message.is_empty() {
            write!(formatter, "{code:?}")
        } else {
            write!(formatter, "{code:?}: {message}")
        }
    }
}

impl std::error::Error for CommandError {}

impl From<tonic::Status> for Error {
    fn from(status: tonic::Status) -> Self {
        let message = redact_credentials(status.message());
        match status.code() {
            Code::Unauthenticated => Self::Authentication {
                message,
                status: Box::new(status),
            },
            Code::PermissionDenied => Self::Authorization {
                message,
                status: Box::new(status),
            },
            Code::DeadlineExceeded => Self::Timeout {
                message,
                status: Box::new(status),
            },
            Code::Cancelled => Self::Cancelled {
                message,
                status: Box::new(status),
            },
            Code::Unavailable | Code::ResourceExhausted => Self::Unavailable {
                message,
                status: Box::new(status),
            },
            _ => Self::Status(Box::new(status)),
        }
    }
}

/// Promote a non-OK protocol status carried inside an [`MxCommandReply`]
/// to an [`Error::Command`].
///
/// # Errors
///
/// Returns [`Error::Command`] when `reply.protocol_status` is missing or
/// reports any code other than [`ProtocolStatusCode::Ok`].
pub fn ensure_command_success(reply: MxCommandReply) -> Result<MxCommandReply, Error> {
    let code = reply
        .protocol_status
        .as_ref()
        .and_then(|status| ProtocolStatusCode::try_from(status.code).ok())
        .unwrap_or(ProtocolStatusCode::Unspecified);

    if code == ProtocolStatusCode::Ok {
        Ok(reply)
    } else {
        Err(Box::new(CommandError::new(reply)).into())
    }
}

/// Validate a [`ProtocolStatus`] envelope returned by an open/close-session
/// reply.
///
/// # Errors
///
/// Returns [`Error::ProtocolStatus`] tagged with `operation` when `status`
/// is missing or reports any code other than [`ProtocolStatusCode::Ok`].
pub fn ensure_protocol_success(
    operation: &'static str,
    status: Option<&ProtocolStatus>,
) -> Result<(), Error> {
    let code = status
        .and_then(|status| ProtocolStatusCode::try_from(status.code).ok())
        .unwrap_or(ProtocolStatusCode::Unspecified);

    if code == ProtocolStatusCode::Ok {
        Ok(())
    } else {
        Err(Error::ProtocolStatus {
            operation,
            code,
            message: status
                .map(|status| status.message.clone())
                .unwrap_or_default(),
        })
    }
}

fn redact_credentials(message: &str) -> String {
    message
        .split_whitespace()
        .map(|part| {
            if part.starts_with("mxgw_") || part.eq_ignore_ascii_case("bearer") {
                "<redacted>"
            } else {
                part
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use tonic::{Code, Status};

    use super::Error;

    #[test]
    fn classifies_authentication_status() {
        let error = Error::from(Status::new(
            Code::Unauthenticated,
            "invalid API key mxgw_visible_secret",
        ));

        let message = error.to_string();

        assert!(matches!(error, Error::Authentication { .. }));
        assert!(message.contains("<redacted>"));
        assert!(!message.contains("visible_secret"));
    }
}
