//! Canonical, language-neutral error taxonomy for the Sail core.
//!
//! This enum is the contract every binding maps onto its own error type:
//! the Python SDK to its `sail.errors` classes, the native CLI to its exit
//! handling, the TypeScript SDK to its `SailError` subclasses. The `Display` impl
//! is for Rust consumers and logs only. Error message *text* is NOT a
//! cross-language contract, so bindings are free to format their own
//! user-facing messages from the structured fields.

use std::error::Error as StdError;

use thiserror::Error;
use tonic::{Code, Status};

/// gRPC status code, decoupled from any one client library's spelling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GrpcCode {
    /// The operation completed successfully.
    Ok,
    /// The operation was canceled, typically by the caller.
    Cancelled,
    /// An unknown error, often a status with no mapped code.
    Unknown,
    /// The client specified an invalid argument.
    InvalidArgument,
    /// The deadline expired before the operation could complete.
    DeadlineExceeded,
    /// The requested entity was not found.
    NotFound,
    /// An attempt to create an entity that already exists.
    AlreadyExists,
    /// The caller lacks permission to execute the operation.
    PermissionDenied,
    /// A resource (quota, capacity, file space) has been exhausted.
    ResourceExhausted,
    /// The system is not in a state required for the operation.
    FailedPrecondition,
    /// The operation was aborted, often due to a concurrency conflict.
    Aborted,
    /// The operation was attempted past the valid range.
    OutOfRange,
    /// The operation is not implemented or not supported.
    Unimplemented,
    /// An internal error: an invariant expected by the system was broken.
    Internal,
    /// The service is currently unavailable, typically transient.
    Unavailable,
    /// Unrecoverable data loss or corruption.
    DataLoss,
    /// The request lacks valid authentication credentials.
    Unauthenticated,
}

impl GrpcCode {
    /// A stable, lowercase identifier for the code. Bindings that need a
    /// different spelling (e.g. a language's own convention) map from the
    /// `GrpcCode` value rather than parsing this string.
    pub fn as_str(self) -> &'static str {
        match self {
            GrpcCode::Ok => "ok",
            GrpcCode::Cancelled => "cancelled",
            GrpcCode::Unknown => "unknown",
            GrpcCode::InvalidArgument => "invalid_argument",
            GrpcCode::DeadlineExceeded => "deadline_exceeded",
            GrpcCode::NotFound => "not_found",
            GrpcCode::AlreadyExists => "already_exists",
            GrpcCode::PermissionDenied => "permission_denied",
            GrpcCode::ResourceExhausted => "resource_exhausted",
            GrpcCode::FailedPrecondition => "failed_precondition",
            GrpcCode::Aborted => "aborted",
            GrpcCode::OutOfRange => "out_of_range",
            GrpcCode::Unimplemented => "unimplemented",
            GrpcCode::Internal => "internal",
            GrpcCode::Unavailable => "unavailable",
            GrpcCode::DataLoss => "data_loss",
            GrpcCode::Unauthenticated => "unauthenticated",
        }
    }
}

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

impl From<Code> for GrpcCode {
    fn from(code: Code) -> GrpcCode {
        match code {
            Code::Ok => GrpcCode::Ok,
            Code::Cancelled => GrpcCode::Cancelled,
            Code::Unknown => GrpcCode::Unknown,
            Code::InvalidArgument => GrpcCode::InvalidArgument,
            Code::DeadlineExceeded => GrpcCode::DeadlineExceeded,
            Code::NotFound => GrpcCode::NotFound,
            Code::AlreadyExists => GrpcCode::AlreadyExists,
            Code::PermissionDenied => GrpcCode::PermissionDenied,
            Code::ResourceExhausted => GrpcCode::ResourceExhausted,
            Code::FailedPrecondition => GrpcCode::FailedPrecondition,
            Code::Aborted => GrpcCode::Aborted,
            Code::OutOfRange => GrpcCode::OutOfRange,
            Code::Unimplemented => GrpcCode::Unimplemented,
            Code::Internal => GrpcCode::Internal,
            Code::Unavailable => GrpcCode::Unavailable,
            Code::DataLoss => GrpcCode::DataLoss,
            Code::Unauthenticated => GrpcCode::Unauthenticated,
        }
    }
}

/// How a transport attempt failed, independent of any language's exception
/// names. Bindings map these onto their own transport-error types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransportKind {
    /// The attempt exceeded its deadline before a response arrived.
    Timeout,
    /// The transport could not establish or maintain a connection.
    Connection,
}

impl std::fmt::Display for TransportKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            TransportKind::Timeout => "timeout",
            TransportKind::Connection => "connection",
        })
    }
}

/// The canonical Sail error. Variants carry structured fields so bindings can
/// populate their own exception attributes without parsing strings.
///
/// `Display` is for Rust logs only; bindings format their own user-facing text.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SailError {
    /// Configuration/usage error (e.g. a missing API key).
    #[error("{message}")]
    Config {
        /// Human-readable description of the misconfiguration.
        message: String,
    },
    /// Unexpected internal failure (client build, TLS config, invariant).
    #[error("{message}")]
    Internal {
        /// Human-readable description of the internal failure.
        message: String,
    },
    /// HTTP/gRPC dial-time transport failure. `source` is the underlying
    /// transport error (e.g. the originating `tonic::Status`) for Rust callers.
    #[error("{kind} error: {message}")]
    Transport {
        /// Whether the transport failed by timeout or by connection error.
        kind: TransportKind,
        /// Human-readable description of the transport failure.
        message: String,
        /// The underlying transport error, retained for Rust callers; `None`
        /// when no originating error was available.
        #[source]
        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
    },
    /// Sailbox creation failed (non-2xx on create).
    #[error("{message}")]
    Creation {
        /// Human-readable description of the creation failure.
        message: String,
        /// HTTP status code returned by the create request.
        status: u16,
        /// Parsed response body from the failed create request.
        body: serde_json::Value,
    },
    /// A resource was not found (404 on get / unknown id across orgs).
    #[error("{message}")]
    NotFound {
        /// Human-readable description of the missing resource.
        message: String,
    },
    /// A credential problem: missing/invalid API key or insufficient scope.
    #[error("{message}")]
    PermissionDenied {
        /// Human-readable description of the credential or scope problem.
        message: String,
    },
    /// A file operation referenced a path that does not exist.
    #[error("{message}")]
    FileNotFound {
        /// Human-readable description of the missing path.
        message: String,
    },
    /// A request argument was rejected as invalid.
    #[error("{message}")]
    InvalidArgument {
        /// Human-readable description of why the argument was rejected.
        message: String,
    },
    /// A custom image build failed (the build reported `failed`).
    #[error("image build failed: {message}")]
    ImageBuild {
        /// Human-readable build failure detail.
        message: String,
    },
    /// Any other non-2xx API response.
    #[error("{message}")]
    Api {
        /// HTTP status code returned by the API.
        status: u16,
        /// Human-readable description of the API failure.
        message: String,
        /// Parsed response body from the failed API request.
        body: serde_json::Value,
    },
    /// A wait referenced an unknown exec request.
    #[error("{message}")]
    ExecRequestNotFound {
        /// Human-readable description of the unknown exec request.
        message: String,
    },
    /// The sailbox no longer exists on the worker.
    #[error("{message}")]
    Terminated {
        /// Human-readable description noting the sailbox is gone.
        message: String,
    },
    /// The worker hosting the sailbox was lost; output is unrecoverable.
    #[error("{message}")]
    WorkerLost {
        /// Human-readable description of the worker loss.
        message: String,
    },
    /// A stdin write hit a command that already finished or closed its stdin.
    #[error("{message}")]
    BrokenPipe {
        /// Human-readable description of the broken-pipe condition.
        message: String,
    },
    /// Any other exec-RPC failure, classified by gRPC code.
    #[error("{code}: {detail}")]
    Execution {
        /// The gRPC status code that classifies the failure.
        code: GrpcCode,
        /// Human-readable detail describing the failure.
        detail: String,
    },
}

/// Detect a client-side transport failure that tonic surfaces as a gRPC
/// `Status`. A failed connect or transport timeout carries a populated
/// `source()`, whereas a status decoded from server response trailers does
/// not. We use that to keep true transport failures (which bindings map to
/// their own connection/timeout error types) out of the server-status
/// taxonomy, instead of misreporting them as an `Execution` error.
fn transport_failure(status: &Status) -> Option<SailError> {
    status.source()?;
    let message = status.message().to_string();
    let lower = message.to_lowercase();
    let kind = if status.code() == Code::DeadlineExceeded
        || lower.contains("timed out")
        || lower.contains("timeout")
    {
        TransportKind::Timeout
    } else {
        TransportKind::Connection
    };
    Some(SailError::Transport {
        kind,
        message,
        source: Some(Box::new(status.clone())),
    })
}

impl SailError {
    /// Classify an exec-context gRPC status into the canonical taxonomy.
    ///
    /// NOT_FOUND splits into [`SailError::ExecRequestNotFound`] (the wait
    /// referenced an unknown exec request) versus [`SailError::Terminated`]
    /// (the sailbox itself is gone). A credential failure maps to
    /// [`SailError::PermissionDenied`], matching the listener/file mappers.
    /// Everything else is a generic [`SailError::Execution`] carrying the
    /// structured code; bindings format their own message from `code` + `detail`.
    pub(crate) fn from_exec_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown sailbox exec error"
        } else {
            status.message()
        };
        if status.code() == Code::NotFound {
            if detail.contains("exec request") {
                return SailError::ExecRequestNotFound {
                    message: detail.to_string(),
                };
            }
            return SailError::Terminated {
                message: format!(
                    "{detail}; this is likely because this Sailbox is no longer running"
                ),
            };
        }
        if matches!(
            status.code(),
            Code::PermissionDenied | Code::Unauthenticated
        ) {
            return SailError::PermissionDenied {
                message: detail.to_string(),
            };
        }
        SailError::Execution {
            code: status.code().into(),
            detail: detail.to_string(),
        }
    }

    /// Classify a general (non-exec) worker-proxy gRPC status (listeners,
    /// files). NOT_FOUND maps to [`SailError::NotFound`], a credential failure
    /// to [`SailError::PermissionDenied`], and everything else to
    /// [`SailError::Execution`] carrying the structured code.
    pub(crate) fn from_rpc_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown worker-proxy error"
        } else {
            status.message()
        };
        match status.code() {
            Code::NotFound => SailError::NotFound {
                message: detail.to_string(),
            },
            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
                message: detail.to_string(),
            },
            code => SailError::Execution {
                code: code.into(),
                detail: detail.to_string(),
            },
        }
    }

    /// Classify a file-RPC gRPC status. Files have their own taxonomy: a missing
    /// path is [`SailError::FileNotFound`] (not the generic NotFound), and a
    /// rejected argument is [`SailError::InvalidArgument`].
    pub(crate) fn from_file_rpc_status(status: &Status) -> SailError {
        if let Some(err) = transport_failure(status) {
            return err;
        }
        let detail = if status.message().is_empty() {
            "unknown sailbox file error"
        } else {
            status.message()
        };
        match status.code() {
            Code::NotFound => SailError::FileNotFound {
                message: detail.to_string(),
            },
            Code::PermissionDenied | Code::Unauthenticated => SailError::PermissionDenied {
                message: detail.to_string(),
            },
            Code::InvalidArgument => SailError::InvalidArgument {
                message: detail.to_string(),
            },
            code => SailError::Execution {
                code: code.into(),
                detail: detail.to_string(),
            },
        }
    }
}

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

    #[test]
    fn not_found_without_exec_request_is_terminated() {
        let err = SailError::from_exec_status(&Status::not_found("sailbox sb_x not found"));
        match err {
            SailError::Terminated { message } => {
                assert!(message.contains("no longer running"));
            }
            other => panic!("expected Terminated, got {other:?}"),
        }
    }

    #[test]
    fn not_found_with_exec_request_is_request_not_found() {
        let err = SailError::from_exec_status(&Status::not_found("exec request er_x not found"));
        assert!(matches!(err, SailError::ExecRequestNotFound { .. }));
    }

    #[test]
    fn exec_auth_failure_is_permission_denied() {
        // exec/wait/cancel auth failures map to PermissionDenied, matching the
        // listener/file mappers, not the generic Execution catch-all.
        for status in [
            Status::unauthenticated("invalid API key"),
            Status::permission_denied("sailbox owned by another org"),
        ] {
            let err = SailError::from_exec_status(&status);
            assert!(
                matches!(err, SailError::PermissionDenied { .. }),
                "got {err:?}"
            );
        }
    }

    #[test]
    fn other_codes_carry_structured_grpc_code() {
        let err = SailError::from_exec_status(&Status::unavailable("upstream draining"));
        match err {
            SailError::Execution { code, detail } => {
                assert_eq!(code, GrpcCode::Unavailable);
                assert_eq!(detail, "upstream draining");
            }
            other => panic!("expected Execution, got {other:?}"),
        }
    }

    #[test]
    fn empty_detail_uses_default() {
        let err = SailError::from_exec_status(&Status::internal(""));
        match err {
            SailError::Execution { code, detail } => {
                assert_eq!(code, GrpcCode::Internal);
                assert_eq!(detail, "unknown sailbox exec error");
            }
            other => panic!("expected Execution, got {other:?}"),
        }
    }

    #[test]
    fn client_transport_failure_maps_to_transport() {
        // A failed connect surfaces as a status carrying a transport source,
        // unlike a server-sent status decoded from response trailers.
        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect error");
        let status = Status::from_error(Box::new(io));
        match SailError::from_rpc_status(&status) {
            SailError::Transport { kind, source, .. } => {
                assert_eq!(kind, TransportKind::Connection);
                // The originating status is retained as the structured source.
                assert!(source.is_some());
            }
            other => panic!("expected Transport, got {other:?}"),
        }
    }

    #[test]
    fn server_sent_unavailable_stays_in_status_taxonomy() {
        // No source => a real server status, not a client transport failure.
        let err = SailError::from_rpc_status(&Status::unavailable("workerproxy is draining"));
        assert!(matches!(err, SailError::Execution { .. }));
    }

    #[test]
    fn rpc_and_file_status_taxonomies_diverge_where_intended() {
        use assert_matches::assert_matches;
        // General worker-proxy RPCs: NOT_FOUND is a generic miss; a bad
        // argument has no dedicated variant and stays Execution.
        assert_matches!(
            SailError::from_rpc_status(&Status::not_found("x")),
            SailError::NotFound { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::permission_denied("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::unauthenticated("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_rpc_status(&Status::invalid_argument("x")),
            SailError::Execution {
                code: GrpcCode::InvalidArgument,
                ..
            }
        );
        // File RPCs have their own taxonomy: a missing path is FileNotFound (not
        // the generic NotFound) and a rejected argument is InvalidArgument.
        assert_matches!(
            SailError::from_file_rpc_status(&Status::not_found("x")),
            SailError::FileNotFound { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::invalid_argument("x")),
            SailError::InvalidArgument { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::permission_denied("x")),
            SailError::PermissionDenied { .. }
        );
        assert_matches!(
            SailError::from_file_rpc_status(&Status::internal("x")),
            SailError::Execution {
                code: GrpcCode::Internal,
                ..
            }
        );
    }

    #[test]
    fn grpc_code_maps_every_tonic_code_to_a_stable_name() {
        // The lowercase ids are a cross-binding contract, so cover every code
        // exhaustively: a typo or missed arm in either mapping fails here.
        let table = [
            (Code::Ok, "ok"),
            (Code::Cancelled, "cancelled"),
            (Code::Unknown, "unknown"),
            (Code::InvalidArgument, "invalid_argument"),
            (Code::DeadlineExceeded, "deadline_exceeded"),
            (Code::NotFound, "not_found"),
            (Code::AlreadyExists, "already_exists"),
            (Code::PermissionDenied, "permission_denied"),
            (Code::ResourceExhausted, "resource_exhausted"),
            (Code::FailedPrecondition, "failed_precondition"),
            (Code::Aborted, "aborted"),
            (Code::OutOfRange, "out_of_range"),
            (Code::Unimplemented, "unimplemented"),
            (Code::Internal, "internal"),
            (Code::Unavailable, "unavailable"),
            (Code::DataLoss, "data_loss"),
            (Code::Unauthenticated, "unauthenticated"),
        ];
        for (code, expected) in table {
            assert_eq!(GrpcCode::from(code).as_str(), expected);
        }
    }
}
