//! Error types for the HTTP server.

// Reason: docspec-http is an HTTP server unconditionally requiring std;
// alloc is not in the extern prelude for std crates without `extern crate alloc`.
#![allow(clippy::std_instead_of_alloc)]

use std::borrow::Cow;

use axum::{
    http::{
        header::{ALLOW, CONTENT_TYPE},
        HeaderValue, StatusCode,
    },
    response::{IntoResponse, Response},
};
use docspec_json::{JsonEmitter, StrusonBackend};

/// RFC 7807 Problem Details for HTTP APIs.
///
/// Contains exactly four fields per [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807):
/// `type`, `title`, `status`, and `detail`. Serialized without `serde`
/// via [`ProblemJson::to_json_bytes`].
#[derive(Debug)]
pub struct ProblemJson {
    /// Human-readable explanation specific to this occurrence.
    ///
    /// May contain user-supplied data such as path names or MIME type strings.
    /// Dynamic values are stored as [`Cow::Owned`]; static strings as [`Cow::Borrowed`].
    pub detail: Cow<'static, str>,
    /// HTTP status code generated by this occurrence.
    pub status: u16,
    /// Short, human-readable summary of the problem type.
    ///
    /// Matches the standard HTTP reason phrase for the status code.
    pub title: &'static str,
    /// URI reference identifying the problem type.
    ///
    /// This server always uses `"about:blank"`.
    pub type_uri: &'static str,
}

impl ProblemJson {
    /// Serialize this problem detail as a JSON-encoded byte vector.
    ///
    /// Emits exactly four fields in document order:
    /// `"type"`, `"title"`, `"status"`, `"detail"`. String fields are
    /// JSON-escaped (RFC 8259 §7) by the underlying writer; the status is
    /// an unquoted integer.
    ///
    /// Uses [`JsonEmitter`] backed by `struson` for serialization.
    ///
    /// # Panics
    ///
    /// Does not panic for any well-formed `ProblemJson` instance. The
    /// internal `.expect()` calls would only trigger on a bug in
    /// `docspec-json` (the key/value sequence is statically valid JSON and
    /// the `Vec<u8>` writer is infallible).
    #[inline]
    #[must_use]
    pub fn to_json_bytes(&self) -> Vec<u8> {
        // Reason: emission of a fixed 4-field object into Vec<u8> cannot fail
        // in practice — Vec writes are infallible and the key/value sequence
        // is statically valid JSON. Any error here would indicate a bug in
        // docspec-json itself, not runtime input.
        #[allow(clippy::expect_used)]
        {
            let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
            emitter
                .object(|builder| {
                    builder.key("type").value(self.type_uri)?;
                    builder.key("title").value(self.title)?;
                    builder.key("status").value(u32::from(self.status))?;
                    builder.key("detail").value(self.detail.as_ref())?;
                    Ok(())
                })
                .expect("ProblemJson object emission is infallible");
            emitter.finish().expect("ProblemJson finish is infallible")
        }
    }
}

/// HTTP-layer errors returned by the conversion API.
///
/// Each variant maps to a specific HTTP status code and is serialized as
/// an RFC 7807 Problem JSON body via [`IntoResponse`].
#[derive(Debug)]
#[non_exhaustive]
pub enum HttpError {
    /// The request body bytes are not valid UTF-8.
    ///
    /// → HTTP 400 Bad Request.
    BodyNotUtf8,
    /// The request body was empty (`Content-Length: 0` or no body).
    ///
    /// → HTTP 400 Bad Request.
    EmptyBody,
    /// An unexpected internal error occurred during conversion.
    ///
    /// The `source` error is captured for observability (sent to Sentry via
    /// [`sentry::capture_error`], logged at `warn!` in stderr) and drives the
    /// error chain visible to operators. It is **never** exposed to the HTTP
    /// client — the RFC 7807 response detail is always the generic
    /// "An unexpected error occurred during conversion" string.
    ///
    /// → HTTP 500 Internal Server Error.
    Internal {
        /// The underlying error that caused this internal failure.
        source: Box<dyn core::error::Error + Send + Sync + 'static>,
    },
    /// The HTTP method is not supported on this endpoint.
    ///
    /// → HTTP 405 Method Not Allowed (response includes an `Allow` header).
    MethodNotAllowed {
        /// Comma-separated list of allowed methods for this endpoint.
        allowed: &'static str,
    },
    /// The `Accept` header excludes all formats this server produces.
    ///
    /// → HTTP 406 Not Acceptable.
    NotAcceptable,
    /// No route matches the requested method + path.
    ///
    /// → HTTP 404 Not Found.
    NotFound {
        /// The HTTP method of the unmatched request (e.g. `"GET"`).
        method: String,
        /// The path of the unmatched request (e.g. `"/unknown"`).
        path: String,
    },
    /// Document conversion failed due to invalid or malformed input.
    ///
    /// → HTTP 422 Unprocessable Entity.
    Unprocessable {
        /// Explanation of what made the input invalid.
        detail: String,
    },
    /// The `Content-Type` header is not one of the supported types: `text/markdown`, `text/html`, or `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
    ///
    /// → HTTP 415 Unsupported Media Type.
    UnsupportedMediaType {
        /// The content-type that was received, if any.
        received: Option<String>,
    },
}

/// Constructs a `PostHog` `$exception` event for an HTTP error.
///
/// The `sentry_event_id` property correlates this event with the corresponding
/// Sentry event when both backends are active. Each call uses a fresh `distinct_id`
/// (per-event UUID with `$is_anonymous = true`) because the sync `IntoResponse`
/// context does not have access to the request ID.
#[cfg(feature = "posthog")]
fn build_posthog_error_event(detail: &str, sentry_event_id: Option<String>) -> posthog_rs::Event {
    let distinct_id = uuid::Uuid::new_v4().to_string();
    let mut event = posthog_rs::Event::new("$exception", &distinct_id);
    drop(event.insert_prop("$exception_message", detail));
    drop(event.insert_prop("$is_anonymous", true));
    if let Some(sentry_id) = sentry_event_id {
        drop(event.insert_prop("sentry_event_id", sentry_id));
    }
    event
}

#[cfg(feature = "sentry")]
#[inline]
fn capture_sentry(
    detail: &str,
    source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
) -> sentry::types::Uuid {
    source.map_or_else(
        || sentry::capture_message(detail, sentry::Level::Error),
        sentry::capture_error,
    )
}

#[cfg(any(feature = "sentry", feature = "posthog"))]
fn capture_error_telemetry(
    status: StatusCode,
    detail: &str,
    source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
) {
    if status != StatusCode::INTERNAL_SERVER_ERROR && status != StatusCode::UNPROCESSABLE_ENTITY {
        return;
    }

    #[cfg(not(feature = "sentry"))]
    let _ = source;

    #[cfg(all(feature = "sentry", not(feature = "posthog")))]
    {
        let _ = capture_sentry(detail, source);
    }
    #[cfg(all(feature = "sentry", feature = "posthog"))]
    let sentry_id_string: Option<String> = Some(capture_sentry(detail, source).to_string());
    #[cfg(all(not(feature = "sentry"), feature = "posthog"))]
    let sentry_id_string: Option<String> = None;
    #[cfg(feature = "posthog")]
    {
        let client_arc: Option<std::sync::Arc<posthog_rs::Client>> =
            match crate::telemetry::posthog_client_slot().read() {
                Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
                Err(_poisoned) => {
                    tracing::warn!("posthog slot poisoned; skipping error capture");
                    None
                }
            };
        if let Some(client) = client_arc {
            let event = build_posthog_error_event(detail, sentry_id_string);
            client.capture(event);
        }
    }
}

impl IntoResponse for HttpError {
    /// Convert this error into an HTTP response with an RFC 7807 Problem JSON body.
    ///
    /// Sets `Content-Type: application/problem+json; charset=utf-8` and a JSON body
    /// with exactly four fields: `type`, `title`, `status`, `detail`.
    /// [`HttpError::MethodNotAllowed`] additionally sets the `Allow` response header.
    #[inline]
    fn into_response(self) -> Response {
        #[cfg(any(feature = "sentry", feature = "posthog"))]
        self.capture_to_telemetry();

        let (status, title, detail, allow): (
            StatusCode,
            &'static str,
            Cow<'static, str>,
            Option<&'static str>,
        ) = match self {
            Self::EmptyBody => (
                StatusCode::BAD_REQUEST,
                "Bad Request",
                Cow::Borrowed("Request body is empty"),
                None,
            ),
            Self::BodyNotUtf8 => (
                StatusCode::BAD_REQUEST,
                "Bad Request",
                Cow::Borrowed("Request body is not valid UTF-8"),
                None,
            ),
            Self::NotFound { method, path } => (
                StatusCode::NOT_FOUND,
                "Not Found",
                Cow::Owned(format!("No route matches {method} {path}")),
                None,
            ),
            Self::MethodNotAllowed { allowed } => (
                StatusCode::METHOD_NOT_ALLOWED,
                "Method Not Allowed",
                Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
                Some(allowed),
            ),
            Self::NotAcceptable => (
                StatusCode::NOT_ACCEPTABLE,
                "Not Acceptable",
                Cow::Borrowed(
                    "Accept header must include application/vnd.docspec.blocknote+json, \
                     application/vnd.blocknote+json, application/vnd.oxa+json, \
                     application/vnd.pandoc.native, text/html, application/*, or */*",
                ),
                None,
            ),
            Self::UnsupportedMediaType { received: None } => (
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
                "Unsupported Media Type",
                Cow::Borrowed("Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
                None,
            ),
            Self::UnsupportedMediaType {
                received: Some(content_type),
            } => (
                StatusCode::UNSUPPORTED_MEDIA_TYPE,
                "Unsupported Media Type",
                Cow::Owned(format!(
                    "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got {content_type}"
                )),
                None,
            ),
            Self::Unprocessable { detail } => (
                StatusCode::UNPROCESSABLE_ENTITY,
                "Unprocessable Entity",
                Cow::Owned(detail),
                None,
            ),
            Self::Internal { .. } => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Internal Server Error",
                Cow::Borrowed("An unexpected error occurred during conversion"),
                None,
            ),
        };

        let body = ProblemJson {
            detail,
            status: status.as_u16(),
            title,
            type_uri: "about:blank",
        }
        .to_json_bytes();

        let mut response = (status, body).into_response();
        response.headers_mut().insert(
            CONTENT_TYPE,
            HeaderValue::from_static("application/problem+json; charset=utf-8"),
        );
        if let Some(allowed) = allow {
            response
                .headers_mut()
                .insert(ALLOW, HeaderValue::from_static(allowed));
        }
        response
    }
}

impl HttpError {
    /// Returns a stable, low-cardinality string identifying the error class.
    /// Safe to use as a Prometheus label value — never contains per-request data.
    #[inline]
    #[must_use]
    pub fn error_class(&self) -> &'static str {
        match self {
            Self::BodyNotUtf8 => "body_not_utf8",
            Self::EmptyBody => "empty_body",
            Self::Internal { .. } => "internal",
            Self::MethodNotAllowed { .. } => "method_not_allowed",
            Self::NotAcceptable => "not_acceptable",
            Self::NotFound { .. } => "not_found",
            Self::Unprocessable { .. } => "unprocessable",
            Self::UnsupportedMediaType { .. } => "unsupported_media_type",
        }
    }

    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
    #[inline]
    #[must_use]
    pub fn result_class(&self) -> &'static str {
        use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
        match self {
            Self::BodyNotUtf8
            | Self::EmptyBody
            | Self::MethodNotAllowed { .. }
            | Self::NotAcceptable
            | Self::NotFound { .. }
            | Self::Unprocessable { .. }
            | Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
            Self::Internal { .. } => RESULT_SERVER_ERROR,
        }
    }

    /// Wrap an unexpected internal error as [`HttpError::Internal`].
    ///
    /// The source is preserved end-to-end for observability — it flows to
    /// Sentry via [`sentry::capture_error`] (walking the [`core::error::Error::source`]
    /// chain) and is logged at `warn!` level via [`core::fmt::Debug`] formatting.
    #[inline]
    #[must_use]
    pub fn internal<E>(source: E) -> Self
    where
        E: core::error::Error + Send + Sync + 'static,
    {
        Self::Internal {
            source: Box::new(source),
        }
    }

    /// Capture this error to configured telemetry backends (Sentry, `PostHog`)
    /// when the variant is a 500 or 422.
    #[cfg(any(feature = "sentry", feature = "posthog"))]
    pub(crate) fn capture_to_telemetry(&self) {
        match self {
            Self::Internal { source } => {
                capture_error_telemetry(
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "An unexpected error occurred during conversion",
                    Some(source.as_ref()),
                );
            }
            Self::Unprocessable { detail } => {
                capture_error_telemetry(StatusCode::UNPROCESSABLE_ENTITY, detail.as_str(), None);
            }
            Self::BodyNotUtf8
            | Self::EmptyBody
            | Self::MethodNotAllowed { .. }
            | Self::NotAcceptable
            | Self::NotFound { .. }
            | Self::UnsupportedMediaType { .. } => {}
        }
    }
}

#[cfg(test)]
mod tests {
    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
    // and slice indexing are standard testing patterns that express expected outcomes.
    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]

    use axum::{
        http::{
            header::{ALLOW, CONTENT_TYPE},
            StatusCode,
        },
        response::IntoResponse as _,
    };

    use super::*;

    async fn body_bytes(error: HttpError) -> Vec<u8> {
        axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
            .await
            .unwrap()
            .to_vec()
    }

    fn dummy_error() -> std::io::Error {
        std::io::Error::other("dummy internal error")
    }

    #[test]
    fn all_variants_have_correct_status_codes() {
        assert_eq!(
            HttpError::EmptyBody.into_response().status(),
            StatusCode::BAD_REQUEST
        );
        assert_eq!(
            HttpError::BodyNotUtf8.into_response().status(),
            StatusCode::BAD_REQUEST
        );
        assert_eq!(
            HttpError::NotFound {
                method: "GET".to_owned(),
                path: "/foo".to_owned()
            }
            .into_response()
            .status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            HttpError::MethodNotAllowed { allowed: "GET" }
                .into_response()
                .status(),
            StatusCode::METHOD_NOT_ALLOWED
        );
        assert_eq!(
            HttpError::NotAcceptable.into_response().status(),
            StatusCode::NOT_ACCEPTABLE
        );
        assert_eq!(
            HttpError::UnsupportedMediaType { received: None }
                .into_response()
                .status(),
            StatusCode::UNSUPPORTED_MEDIA_TYPE
        );
        assert_eq!(
            HttpError::Unprocessable {
                detail: "bad".to_owned()
            }
            .into_response()
            .status(),
            StatusCode::UNPROCESSABLE_ENTITY
        );
        assert_eq!(
            HttpError::internal(dummy_error()).into_response().status(),
            StatusCode::INTERNAL_SERVER_ERROR
        );
    }

    #[test]
    fn method_not_allowed_has_allow_header() {
        let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
        let allow_val = response.headers().get(ALLOW).unwrap();
        assert_eq!(allow_val, "GET");
    }

    #[test]
    fn content_type_is_problem_json() {
        let response = HttpError::internal(dummy_error()).into_response();
        let content_type = response.headers().get(CONTENT_TYPE).unwrap();
        assert_eq!(content_type, "application/problem+json; charset=utf-8");
    }

    #[test]
    fn no_allow_header_on_non_405_variants() {
        let response = HttpError::internal(dummy_error()).into_response();
        assert!(response.headers().get(ALLOW).is_none());
    }

    #[cfg(feature = "sentry")]
    #[test]
    fn internal_error_is_captured_by_sentry_with_source_chain() {
        let events = sentry::test::with_captured_events(|| {
            let _response = HttpError::internal(dummy_error()).into_response();
        });
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].level, sentry::Level::Error);
        assert!(
            events[0].message.is_none(),
            "capture_error must not populate top-level message"
        );
        let first_exception = events[0]
            .exception
            .values
            .first()
            .expect("exception chain must be populated by capture_error");
        assert_eq!(
            first_exception.value.as_deref(),
            Some("dummy internal error"),
            "exception value must equal the Display of the source error"
        );
    }

    #[cfg(feature = "sentry")]
    #[test]
    fn unprocessable_error_is_captured_by_sentry() {
        let events = sentry::test::with_captured_events(|| {
            let _response = HttpError::Unprocessable {
                detail: "bad input".to_owned(),
            }
            .into_response();
        });
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].level, sentry::Level::Error);
        assert_eq!(events[0].message.as_deref(), Some("bad input"));
    }

    #[cfg(feature = "sentry")]
    #[test]
    fn client_errors_are_not_captured_by_sentry() {
        let events = sentry::test::with_captured_events(|| {
            drop(HttpError::EmptyBody.into_response());
            drop(HttpError::BodyNotUtf8.into_response());
            drop(
                HttpError::NotFound {
                    method: "GET".to_owned(),
                    path: "/x".to_owned(),
                }
                .into_response(),
            );
            drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
            drop(HttpError::NotAcceptable.into_response());
            drop(HttpError::UnsupportedMediaType { received: None }.into_response());
        });
        assert_eq!(events.len(), 0, "4xx errors must not be captured");
    }

    #[tokio::test]
    async fn serializes_with_four_fields() {
        let bytes = body_bytes(HttpError::internal(dummy_error())).await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            json,
            serde_json::json!({
                "type": "about:blank",
                "title": "Internal Server Error",
                "status": 500,
                "detail": "An unexpected error occurred during conversion",
            })
        );
    }

    #[tokio::test]
    async fn no_instance_key_in_output() {
        let bytes = body_bytes(HttpError::EmptyBody).await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(
            json.get("instance").is_none(),
            "unexpected 'instance' key in output"
        );
    }

    #[tokio::test]
    async fn not_found_problem_body_is_exact() {
        let bytes = body_bytes(HttpError::NotFound {
            method: "GET".to_owned(),
            path: "/api/v99".to_owned(),
        })
        .await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            json,
            serde_json::json!({
                "type": "about:blank",
                "title": "Not Found",
                "status": 404,
                "detail": "No route matches GET /api/v99",
            })
        );
    }

    #[tokio::test]
    async fn internal_detail_is_fixed() {
        let bytes = body_bytes(HttpError::internal(dummy_error())).await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            json["detail"].as_str().unwrap(),
            "An unexpected error occurred during conversion"
        );
    }

    #[tokio::test]
    async fn unsupported_media_type_with_received_problem_body_is_exact() {
        let bytes = body_bytes(HttpError::UnsupportedMediaType {
            received: Some("application/json".to_owned()),
        })
        .await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            json,
            serde_json::json!({
                "type": "about:blank",
                "title": "Unsupported Media Type",
                "status": 415,
                "detail": "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got application/json",
            })
        );
    }

    #[tokio::test]
    async fn unprocessable_problem_body_is_exact() {
        let message = "heading level jumped from 1 to 3".to_owned();
        let bytes = body_bytes(HttpError::Unprocessable {
            detail: message.clone(),
        })
        .await;
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            json,
            serde_json::json!({
                "type": "about:blank",
                "title": "Unprocessable Entity",
                "status": 422,
                "detail": message,
            })
        );
    }

    #[tokio::test]
    async fn control_char_in_detail_is_escaped() {
        let bytes = body_bytes(HttpError::Unprocessable {
            detail: "bad\x01input".to_owned(),
        })
        .await;
        assert_eq!(
            bytes.as_slice(),
            br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
        );
    }

    #[test]
    fn body_not_utf8_error_class_returns_body_not_utf8() {
        assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
    }

    #[test]
    fn empty_body_error_class_returns_empty_body() {
        assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
    }

    #[test]
    fn internal_error_class_returns_internal() {
        assert_eq!(HttpError::internal(dummy_error()).error_class(), "internal");
    }

    #[test]
    fn method_not_allowed_error_class_returns_method_not_allowed() {
        assert_eq!(
            HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
            "method_not_allowed"
        );
    }

    #[test]
    fn not_acceptable_error_class_returns_not_acceptable() {
        assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
    }

    #[test]
    fn not_found_error_class_returns_not_found() {
        assert_eq!(
            HttpError::NotFound {
                method: "GET".to_owned(),
                path: "/foo".to_owned()
            }
            .error_class(),
            "not_found"
        );
    }

    #[test]
    fn unprocessable_error_class_returns_unprocessable() {
        assert_eq!(
            HttpError::Unprocessable {
                detail: "bad".to_owned()
            }
            .error_class(),
            "unprocessable"
        );
    }

    #[test]
    fn unsupported_media_type_error_class_returns_unsupported_media_type() {
        assert_eq!(
            HttpError::UnsupportedMediaType { received: None }.error_class(),
            "unsupported_media_type"
        );
    }

    #[test]
    fn body_not_utf8_result_class_returns_client_error() {
        assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
    }

    #[test]
    fn empty_body_result_class_returns_client_error() {
        assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
    }

    #[test]
    fn internal_result_class_returns_server_error() {
        assert_eq!(
            HttpError::internal(dummy_error()).result_class(),
            "server_error"
        );
    }

    #[test]
    fn method_not_allowed_result_class_returns_client_error() {
        assert_eq!(
            HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
            "client_error"
        );
    }

    #[test]
    fn not_acceptable_result_class_returns_client_error() {
        assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
    }

    #[test]
    fn not_found_result_class_returns_client_error() {
        assert_eq!(
            HttpError::NotFound {
                method: "GET".to_owned(),
                path: "/foo".to_owned()
            }
            .result_class(),
            "client_error"
        );
    }

    #[test]
    fn unprocessable_result_class_returns_client_error() {
        assert_eq!(
            HttpError::Unprocessable {
                detail: "bad".to_owned()
            }
            .result_class(),
            "client_error"
        );
    }

    #[test]
    fn unsupported_media_type_result_class_returns_client_error() {
        assert_eq!(
            HttpError::UnsupportedMediaType { received: None }.result_class(),
            "client_error"
        );
    }

    #[cfg(feature = "posthog")]
    mod posthog_tests {
        #![allow(clippy::expect_used, clippy::unwrap_used)]
        use super::super::HttpError;
        use super::dummy_error;
        use axum::response::IntoResponse as _;

        #[cfg(all(feature = "sentry", feature = "posthog"))]
        #[test]
        fn internal_error_into_response_does_not_panic_with_both_features() {
            let response = HttpError::internal(dummy_error()).into_response();
            assert_eq!(
                response.status(),
                axum::http::StatusCode::INTERNAL_SERVER_ERROR
            );
        }

        #[cfg(all(feature = "posthog", not(feature = "sentry")))]
        #[test]
        fn internal_error_into_response_does_not_panic_with_posthog_only() {
            let response = HttpError::internal(dummy_error()).into_response();
            assert_eq!(
                response.status(),
                axum::http::StatusCode::INTERNAL_SERVER_ERROR
            );
        }

        #[test]
        fn client_error_returns_correct_status_and_posthog_block_is_bypassed() {
            let response = HttpError::NotFound {
                method: "GET".to_owned(),
                path: "/foo".to_owned(),
            }
            .into_response();
            assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
        }

        #[test]
        fn into_response_outside_tokio_runtime_does_not_panic() {
            let response = HttpError::internal(dummy_error()).into_response();
            assert_eq!(
                response.status(),
                axum::http::StatusCode::INTERNAL_SERVER_ERROR
            );
        }
    }
}
