use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

use crate::storage::StorageError;

#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("invalid input: {0}")]
    InvalidInput(String),
    #[error("unauthenticated")]
    Unauthenticated,
    #[error("forbidden")]
    Forbidden,
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("payload too large: {0}")]
    PayloadTooLarge(String),
    #[error("unsupported media type: {0}")]
    UnsupportedMediaType(String),
    /// 503 — a feature is currently unavailable, distinct from a 5xx
    /// internal error. Used when admin actions require the crawler
    /// daemon but it's been disabled (`CRAWLER_DAEMON=false`).
    #[error("service unavailable: {0}")]
    ServiceUnavailable(String),
    /// 429 with an optional `Retry-After` header value (in seconds).
    #[error("too many requests")]
    TooManyRequests {
        retry_after_secs: Option<u64>,
    },
    /// Semantic per-field validation failure. `details` is rendered into the
    /// envelope so the client can highlight the bad field(s).
    #[error("validation failed")]
    ValidationFailed {
        message: String,
        details: serde_json::Value,
    },
    /// 501 — the wire shape is accepted but the feature isn't built yet.
    /// Carries a `&'static str` snake_case code so clients can detect the
    /// specific pending feature without parsing the message. Generic; kept
    /// for future reservations (the `?text=` page-tag reservation that first
    /// used it now performs real OCR search).
    #[error("not implemented: {code}")]
    NotImplemented {
        code: &'static str,
        message: &'static str,
    },
    #[error(transparent)]
    Database(#[from] sqlx::Error),
    #[error(transparent)]
    Storage(#[from] StorageError),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

pub type AppResult<T> = Result<T, AppError>;

impl AppError {
    /// Stable, snake_case code that clients pattern-match on. Every top-level
    /// variant is matched explicitly — adding a new variant without giving it
    /// a code is a compile error, on purpose.
    pub fn code(&self) -> &'static str {
        match self {
            AppError::NotFound => "not_found",
            AppError::InvalidInput(_) => "invalid_input",
            AppError::Unauthenticated => "unauthenticated",
            AppError::Forbidden => "forbidden",
            AppError::Conflict(_) => "conflict",
            AppError::PayloadTooLarge(_) => "payload_too_large",
            AppError::UnsupportedMediaType(_) => "unsupported_media_type",
            AppError::ServiceUnavailable(_) => "service_unavailable",
            AppError::TooManyRequests { .. } => "too_many_requests",
            AppError::ValidationFailed { .. } => "validation_failed",
            AppError::NotImplemented { code, .. } => code,
            AppError::Database(sqlx::Error::RowNotFound) => "not_found",
            AppError::Database(_) => "internal_error",
            AppError::Storage(StorageError::NotFound) => "not_found",
            AppError::Storage(StorageError::BadKey) => "bad_file_key",
            AppError::Storage(StorageError::Io(_)) => "internal_error",
            AppError::Other(_) => "internal_error",
        }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let code = self.code();
        let (status, message, details) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string(), None),
            AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone(), None),
            AppError::Unauthenticated => {
                (StatusCode::UNAUTHORIZED, "unauthenticated".to_string(), None)
            }
            AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string(), None),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone(), None),
            AppError::PayloadTooLarge(msg) => {
                (StatusCode::PAYLOAD_TOO_LARGE, msg.clone(), None)
            }
            AppError::UnsupportedMediaType(msg) => {
                (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone(), None)
            }
            AppError::ServiceUnavailable(msg) => {
                (StatusCode::SERVICE_UNAVAILABLE, msg.clone(), None)
            }
            AppError::TooManyRequests { retry_after_secs } => {
                // Emit `Retry-After: N` (RFC 6585 §4) so a well-behaved
                // client can back off correctly. Done by building the
                // response by hand below — the `(status, headers,
                // body)` tuple shape doesn't fit the standard
                // `(status, body)` IntoResponse path for the other
                // variants.
                let body = json!({
                    "error": {
                        "code": code,
                        "message": "too many requests; slow down",
                    }
                });
                let mut resp = (StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response();
                if let Some(secs) = retry_after_secs {
                    // `HeaderValue: From<u64>` skips both the
                    // intermediate `String` allocation and the
                    // fallible-by-shape `from_str` path.
                    resp.headers_mut().insert(
                        axum::http::header::RETRY_AFTER,
                        axum::http::HeaderValue::from(*secs),
                    );
                }
                return resp;
            }
            AppError::ValidationFailed { message, details } => (
                StatusCode::UNPROCESSABLE_ENTITY,
                message.clone(),
                Some(details.clone()),
            ),
            AppError::NotImplemented { message, .. } => (
                StatusCode::NOT_IMPLEMENTED,
                (*message).to_string(),
                None,
            ),
            AppError::Database(sqlx::Error::RowNotFound) => {
                (StatusCode::NOT_FOUND, "not found".to_string(), None)
            }
            AppError::Storage(StorageError::NotFound) => {
                (StatusCode::NOT_FOUND, "not found".to_string(), None)
            }
            AppError::Storage(StorageError::BadKey) => (
                StatusCode::BAD_REQUEST,
                "invalid file key".to_string(),
                None,
            ),
            AppError::Database(_) | AppError::Storage(_) | AppError::Other(_) => {
                tracing::error!(error = ?self, "internal error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                    None,
                )
            }
        };
        let body = match details {
            Some(d) => json!({ "error": { "code": code, "message": message, "details": d } }),
            None => json!({ "error": { "code": code, "message": message } }),
        };
        (status, Json(body)).into_response()
    }
}

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

    #[test]
    fn codes_are_stable() {
        assert_eq!(AppError::NotFound.code(), "not_found");
        assert_eq!(AppError::InvalidInput("x".into()).code(), "invalid_input");
        assert_eq!(AppError::Unauthenticated.code(), "unauthenticated");
        assert_eq!(AppError::Forbidden.code(), "forbidden");
        assert_eq!(AppError::Conflict("x".into()).code(), "conflict");
        assert_eq!(AppError::PayloadTooLarge("x".into()).code(), "payload_too_large");
        assert_eq!(
            AppError::UnsupportedMediaType("x".into()).code(),
            "unsupported_media_type"
        );
        assert_eq!(
            AppError::ValidationFailed {
                message: "x".into(),
                details: json!({}),
            }
            .code(),
            "validation_failed"
        );
        assert_eq!(AppError::Storage(StorageError::BadKey).code(), "bad_file_key");
        assert_eq!(AppError::Storage(StorageError::NotFound).code(), "not_found");
        assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), "not_found");
        assert_eq!(AppError::Other(anyhow::anyhow!("oops")).code(), "internal_error");
        // NotImplemented carries the code through so each pending
        // feature gets its own stable identifier on the wire.
        assert_eq!(
            AppError::NotImplemented {
                code: "text_search_not_yet_supported",
                message: "x"
            }
            .code(),
            "text_search_not_yet_supported"
        );
    }
}
