//! 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, MxStatusCategory, 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>),

    /// Gateway accepted the call and returned an `Ok` protocol envelope, but
    /// the reply reported an MXAccess-level failure — a negative `hresult`
    /// (COM failure semantics) or one or more `MXSTATUS_PROXY` entries that
    /// did not indicate success. The wrapped [`MxAccessError`] preserves the
    /// full reply so callers can inspect the native status payload. Boxed to
    /// keep the containing enum small, matching [`Error::Command`].
    #[error("gateway command reported an MXAccess failure: {0}")]
    MxAccess(#[from] Box<MxAccessError>),

    /// 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 {}

/// Wrapper around an [`MxCommandReply`] whose protocol envelope succeeded but
/// whose MXAccess-level result reported a failure — a negative `hresult` or a
/// non-success `MXSTATUS_PROXY` entry.
///
/// The wrapper is heap-allocated inside [`Error::MxAccess`] to keep the
/// containing enum small. Callers can recover the reply with
/// [`MxAccessError::reply`] or [`MxAccessError::into_reply`]. Its `Display`
/// summarizes the `hresult` and status entries and scrubs credentials from the
/// rendered text before it reaches a caller: credential-*shaped* tokens
/// (`mxgw_...`, `bearer`) via a pattern scrub, plus any exact caller-supplied
/// secrets registered with [`MxAccessError::with_secrets`] — the latter catches
/// a password MXAccess echoed back verbatim even though it has no token shape.
///
/// `Debug` is hand-written (not derived) so the attached exact secrets never
/// reach `{:?}` output either: it scrubs them from the reply rendering and
/// prints only the count of attached secrets, never their values.
#[derive(Clone)]
pub struct MxAccessError {
    reply: MxCommandReply,
    /// Exact caller-supplied secrets (e.g. an `AuthenticateUser` password or a
    /// `WriteSecured` string value) scrubbed from the rendered message. Empty
    /// unless a helper attaches them via [`Self::with_secrets`].
    secrets: Vec<String>,
}

impl MxAccessError {
    /// Wrap a reply whose MXAccess-level result reported a failure.
    pub fn new(reply: MxCommandReply) -> Self {
        Self {
            reply,
            secrets: Vec::new(),
        }
    }

    /// Register exact caller-supplied secrets to scrub from the rendered
    /// message, returning the updated error.
    ///
    /// A credential MXAccess echoes back into its diagnostic text has no
    /// `mxgw_`/`bearer` shape, so the pattern scrub cannot catch it. Attaching
    /// the exact secret lets `Display` replace every occurrence with
    /// `<redacted>`.
    pub fn with_secrets(mut self, secrets: Vec<String>) -> Self {
        self.secrets = secrets;
        self
    }

    /// Borrow the underlying reply (correlation id, hresult, statuses).
    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::Debug for MxAccessError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Render the reply, scrub any exact caller secret from it, and never
        // print the raw secrets themselves — only how many are attached.
        let mut reply = format!("{:?}", self.reply);
        for secret in &self.secrets {
            if !secret.is_empty() {
                reply = reply.replace(secret.as_str(), "<redacted>");
            }
        }

        formatter
            .debug_struct("MxAccessError")
            .field("reply", &format_args!("{reply}"))
            .field(
                "secrets",
                &format_args!("[{} redacted]", self.secrets.len()),
            )
            .finish()
    }
}

impl std::fmt::Display for MxAccessError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use std::fmt::Write as _;

        let hresult = match self.reply.hresult {
            Some(value) => value.to_string(),
            None => "none".to_owned(),
        };

        // Render the whole body first so the exact-secret scrub can sweep every
        // field — including diagnostic text that already went through the
        // credential-shape scrub — before any of it reaches the caller.
        let mut body = String::new();
        write!(
            body,
            "hresult={hresult}, {} status entr{}",
            self.reply.statuses.len(),
            if self.reply.statuses.len() == 1 {
                "y"
            } else {
                "ies"
            }
        )
        .expect("writing to a String is infallible");

        for status in &self.reply.statuses {
            let category = MxStatusCategory::try_from(status.category)
                .unwrap_or(MxStatusCategory::Unspecified);
            let diagnostic = redact_credentials(&status.diagnostic_text);
            write!(
                body,
                "; [success={}, category={category:?}, detail={}, {}]",
                status.success, status.detail, diagnostic
            )
            .expect("writing to a String is infallible");
        }

        for secret in &self.secrets {
            if !secret.is_empty() {
                body = body.replace(secret.as_str(), "<redacted>");
            }
        }

        formatter.write_str(&body)
    }
}

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

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`].
///
/// [`ProtocolStatusCode::MxaccessFailure`] is deliberately **not** a
/// command-level failure here: it signals an MXAccess-level rejection, so it
/// falls through to [`ensure_mxaccess_success`] and surfaces as
/// [`Error::MxAccess`] — matching the .NET, Java, Go, and Python clients. Every
/// other non-`Ok` code stays [`Error::Command`].
///
/// # Errors
///
/// Returns [`Error::Command`] when `reply.protocol_status` is missing or
/// reports any code other than [`ProtocolStatusCode::Ok`] or
/// [`ProtocolStatusCode::MxaccessFailure`].
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 || code == ProtocolStatusCode::MxaccessFailure {
        Ok(reply)
    } else {
        Err(Box::new(CommandError::new(reply)).into())
    }
}

/// Promote an MXAccess-level failure carried inside an otherwise-successful
/// [`MxCommandReply`] to an [`Error::MxAccess`].
///
/// This is the second reply check applied to the typed command path, after
/// [`ensure_command_success`] confirms the protocol envelope is `Ok` (or a
/// [`ProtocolStatusCode::MxaccessFailure`] the first check lets fall through).
/// It enforces MXAccess parity: a reply can carry an `Ok` protocol envelope
/// while MXAccess itself rejected the operation, and a
/// [`ProtocolStatusCode::MxaccessFailure`] envelope is itself an MXAccess-level
/// failure regardless of `hresult`. Following COM semantics, only a
/// **negative** `hresult` is a failure — positive codes such as `S_FALSE = 1`
/// are success. A `MXSTATUS_PROXY` entry is treated as a failure when its
/// `category` is not [`MxStatusCategory::Ok`]; the `success` member mirrors the
/// raw COM value verbatim for diagnostics and never enters the verdict, so an
/// entry with an unspecified category fails even when `success` is non-zero.
///
/// Per-item bulk failures are reported inside each result entry
/// (`was_successful = false`) rather than in the top-level `hresult`/`statuses`
/// fields, so this check does not trip on them — matching the other clients.
///
/// # Errors
///
/// Returns [`Error::MxAccess`] when the reply's protocol code is
/// [`ProtocolStatusCode::MxaccessFailure`], `reply.hresult` is negative, or any
/// `reply.statuses` entry reports a category other than
/// [`MxStatusCategory::Ok`].
pub fn ensure_mxaccess_success(reply: MxCommandReply) -> Result<MxCommandReply, Error> {
    let protocol_code = reply
        .protocol_status
        .as_ref()
        .and_then(|status| ProtocolStatusCode::try_from(status.code).ok())
        .unwrap_or(ProtocolStatusCode::Unspecified);
    let mxaccess_failure = protocol_code == ProtocolStatusCode::MxaccessFailure;
    let hresult_failure = reply.hresult.is_some_and(|hresult| hresult < 0);
    let status_failure = reply
        .statuses
        .iter()
        .any(|status| status.category != MxStatusCategory::Ok as i32);

    if mxaccess_failure || hresult_failure || status_failure {
        Err(Box::new(MxAccessError::new(reply)).into())
    } else {
        Ok(reply)
    }
}

/// 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::{ensure_mxaccess_success, Error};
    use crate::generated::mxaccess_gateway::v1::{
        MxCommandReply, MxStatusCategory, MxStatusProxy, ProtocolStatus, ProtocolStatusCode,
    };

    fn ok_reply() -> MxCommandReply {
        MxCommandReply {
            protocol_status: Some(ProtocolStatus {
                code: ProtocolStatusCode::Ok as i32,
                message: String::new(),
            }),
            ..MxCommandReply::default()
        }
    }

    #[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"));
    }

    #[test]
    fn ensure_mxaccess_success_passes_clean_reply() {
        let mut reply = ok_reply();
        // Positive hresult (e.g. S_FALSE = 1) is a success, not a failure.
        reply.hresult = Some(1);
        // A zero `success` member with an OK category is still a success: the
        // category is authoritative and `success` is diagnostics only.
        reply.statuses = vec![MxStatusProxy {
            success: 0,
            category: MxStatusCategory::Ok as i32,
            ..MxStatusProxy::default()
        }];

        assert!(ensure_mxaccess_success(reply).is_ok());
    }

    #[test]
    fn ensure_mxaccess_success_flags_failing_status_entry() {
        let mut reply = ok_reply();
        // A non-OK category fails even though the raw `success` member is set.
        reply.statuses = vec![MxStatusProxy {
            success: 1,
            category: MxStatusCategory::CommunicationError as i32,
            detail: 42,
            diagnostic_text: "write rejected for mxgw_visible_secret".to_owned(),
            ..MxStatusProxy::default()
        }];

        let error = ensure_mxaccess_success(reply).expect_err("failing status must error");
        let message = error.to_string();

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

    #[test]
    fn ensure_mxaccess_success_flags_negative_hresult() {
        let mut reply = ok_reply();
        // 0x80004005 (E_FAIL) as a signed 32-bit value.
        reply.hresult = Some(-2_147_467_259);

        let error = ensure_mxaccess_success(reply).expect_err("negative hresult must error");

        assert!(matches!(error, Error::MxAccess(_)));
    }
}
