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

#[derive(Debug)]
pub enum AppError {
    BadRequest(String),
    Unauthorized(String),
    Forbidden(String),
    /// Uploads are temporarily locked (event closed / gallery released). Distinct from
    /// `Forbidden` so the client can tell this REVERSIBLE 403 apart from a permanent one
    /// (banned user, quota): the queued blob is kept and retried if the host reopens,
    /// instead of being purged like a genuinely-terminal rejection.
    UploadsLocked(String),
    /// The gallery has been RELEASED — the keepsake was snapshotted, so a late upload could
    /// never appear in it. Mechanically this is still reversible (a host reopen clears
    /// `export_released_at` and bumps the epoch), which is why the blob must still be kept.
    ///
    /// Distinct from `UploadsLocked` because the two differ in *expectation*, and the client's
    /// retry policy has to differ with them. A closed event is a pause the host means to undo;
    /// a released gallery is the end of the event, and nobody reopens it. Under one shared code
    /// the queue kept auto-retrying a released event forever — re-streaming a multi-megabyte
    /// photo over cellular on every budget refill, for a request whose answer will not change,
    /// while telling the guest to tap a camera button that 403s. `gallery_released` lets the
    /// client park the item visibly and wait for an actual `event-opened` instead of guessing.
    GalleryReleased(String),
    /// The uploader is banned. A 403 like `Forbidden`, but tagged `user_banned` so the client
    /// keeps the queued blob instead of purging it.
    ///
    /// A ban is reversible — `unban_user` exists, and the host's own confirm copy promises the
    /// photos come back — but the client classified the generic `forbidden` code as permanent,
    /// deleted the blob from IndexedDB, and moved the row to `blocked`, which has no retry
    /// button. So an unban could restore everything except the photos that were in flight when
    /// the ban landed, and a ban issued by mistake destroyed them with no way back.
    UserBanned(String),
    NotFound(String),
    Conflict(String),
    /// Second field: optional retry-after seconds to include in the response.
    TooManyRequests(String, Option<u64>),
    /// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so
    /// the client can treat it as *terminal* (413, no retry) instead of backing off and
    /// retrying a permanently-failing upload forever.
    QuotaExceeded(String),
    /// The server is temporarily unable to serve this request — currently only pool
    /// saturation. Distinct from `Internal` because it is TRANSIENT and the client should be
    /// told so: a 500 reads as "this request is broken", while a 503 + Retry-After reads as
    /// "come back shortly", which is what the upload queue's retry classifier needs to make
    /// the right call. Second field: optional retry-after seconds.
    ServiceUnavailable(String, Option<u64>),
    Internal(anyhow::Error),
}

impl AppError {
    fn status_and_code(&self) -> (StatusCode, &str) {
        match self {
            Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
            Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
            Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
            Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
            Self::GalleryReleased(_) => (StatusCode::FORBIDDEN, "gallery_released"),
            Self::UserBanned(_) => (StatusCode::FORBIDDEN, "user_banned"),
            Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
            Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
            Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
            Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
            Self::ServiceUnavailable(..) => {
                (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
            }
            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
        }
    }

    fn message(&self) -> String {
        match self {
            Self::BadRequest(msg)
            | Self::Unauthorized(msg)
            | Self::Forbidden(msg)
            | Self::UploadsLocked(msg)
            | Self::GalleryReleased(msg)
            | Self::UserBanned(msg)
            | Self::NotFound(msg)
            | Self::Conflict(msg) => msg.clone(),
            Self::TooManyRequests(msg, _) => msg.clone(),
            Self::ServiceUnavailable(msg, _) => msg.clone(),
            Self::QuotaExceeded(msg) => msg.clone(),
            Self::Internal(err) => {
                tracing::error!("internal error: {err:#}");
                "Ein interner Fehler ist aufgetreten.".to_string()
            }
        }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code) = self.status_and_code();
        // BOTH retry-carrying variants must be matched here. `message()` would fail to
        // compile on a missing arm; this one would not — it would silently drop the header and
        // the `retry_after_secs` body field, which is exactly the sort of omission that only
        // shows up under the load the 503 exists for.
        let retry_after_secs = match &self {
            Self::TooManyRequests(_, secs) | Self::ServiceUnavailable(_, secs) => *secs,
            _ => None,
        };
        let message = self.message();

        // Log every 4xx. Until now they were invisible at ANY log level: tower_http's
        // `ServerErrorsAsFailures` classifier counts a 4xx as a *success*, so it goes to
        // `DefaultOnResponse` at DEBUG, and production runs at `info`. The consequence is that a
        // misconfigured limit leaves no trace at all — if guests spend the evening hitting 429s
        // on `upload_rate_per_hour`, or 413s on the storage quota, `docker compose logs` after
        // the event contains nothing about it and the cause is unknowable.
        //
        // WARN rather than INFO because every variant here is a request that did not do what
        // the guest asked. 5xx is excluded: `Internal` already logs with its full source chain
        // in `message()` above, and the pool-exhaustion 503 logs at construction — logging again
        // here would double every server-side failure.
        //
        // No request context is available: `into_response` receives only the error, so there is
        // no path, method or user id to attach. Status + code + message is what can honestly be
        // reported from here, and it is enough to see the SHAPE of a bad evening. Raising
        // `tower_http` to DEBUG instead was considered and rejected — see the note in main.rs.
        //
        // `detail = ?message`, NOT `%message`. Two reasons, both learned the hard way:
        //
        //  * `message` is tracing's own reserved field for an event's format literal, so `%message`
        //    printed unlabelled and would collide under a JSON layer.
        //  * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate
        //    attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and
        //    multipart/parse errors that echo their input. With Display formatting, a value
        //    carrying a newline plus a plausible log prefix lets two unauthenticated requests
        //    forge lines in the only forensic record an unattended event has.
        //    `validate_display_name` now rejects control characters, so the name route is closed
        //    at the source as well — but that is ONE input, and this line formats every 4xx
        //    message in the app. Escaping here is what makes the guarantee general; do not
        //    "simplify" it to `%message` on the grounds that names are already validated.
        //
        // 401 and 404 are logged at DEBUG rather than WARN. They carry no operator signal (an
        // expired session, a mistyped URL) and they are the cheapest lines for a scanner to
        // generate — at ~260 bytes each against the 30 MB the json-file driver retains
        // (docker-compose.yml), a sustained flood could otherwise roll the whole window in
        // minutes and destroy the post-event forensics this logging exists to provide.
        if status.is_client_error() {
            let noisy = status == StatusCode::UNAUTHORIZED || status == StatusCode::NOT_FOUND;
            if noisy {
                tracing::debug!(status = status.as_u16(), code, detail = ?message, "request rejected");
            } else {
                tracing::warn!(status = status.as_u16(), code, detail = ?message, "request rejected");
            }
        }

        let mut body = serde_json::json!({
            "error": code,
            "message": message,
            "status": status.as_u16(),
        });
        if let Some(secs) = retry_after_secs {
            body["retry_after_secs"] = secs.into();
        }

        let mut resp = (status, axum::Json(body)).into_response();
        if let Some(secs) = retry_after_secs
            && let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
        {
            resp.headers_mut()
                .insert(axum::http::header::RETRY_AFTER, val);
        }
        resp
    }
}

impl From<anyhow::Error> for AppError {
    fn from(err: anyhow::Error) -> Self {
        Self::Internal(err)
    }
}

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            // Pool saturation is load, not a bug. Reporting it as a 500 was actively harmful:
            // the frontend's upload-queue classifier treats 5xx as transient and retries, so
            // the retries piled straight back into the saturated pool with no Retry-After to
            // pace them. A 503 says the same thing honestly and carries the backoff.
            //
            // `PoolClosed` stays `Internal` — it only happens during shutdown, where a 503
            // would invite a retry against a server that is going away.
            sqlx::Error::PoolTimedOut => {
                tracing::warn!("database pool exhausted; shedding a request with 503");
                Self::ServiceUnavailable(
                    "Server ist gerade ausgelastet. Bitte versuche es in ein paar Sekunden erneut."
                        .into(),
                    Some(POOL_TIMEOUT_RETRY_AFTER_SECS),
                )
            }
            other => Self::Internal(other.into()),
        }
    }
}

/// Retry-After for a shed request. Short: pool saturation clears in seconds once the queue
/// drains, and a long value would make a brief spike feel like an outage.
const POOL_TIMEOUT_RETRY_AFTER_SECS: u64 = 3;

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

    /// `into_response` extracts `retry_after_secs` by MATCHING ON VARIANTS, so unlike
    /// `message()` a missing arm is not a compile error — it silently drops the header. Pin the
    /// behaviour for both retry-carrying variants.
    #[test]
    fn both_retry_carrying_variants_emit_retry_after() {
        for err in [
            AppError::TooManyRequests("slow down".into(), Some(42)),
            AppError::ServiceUnavailable("busy".into(), Some(3)),
        ] {
            let expected = match &err {
                AppError::TooManyRequests(_, Some(s))
                | AppError::ServiceUnavailable(_, Some(s)) => s.to_string(),
                _ => unreachable!(),
            };
            let resp = err.into_response();
            assert_eq!(
                resp.headers()
                    .get(axum::http::header::RETRY_AFTER)
                    .and_then(|v| v.to_str().ok()),
                Some(expected.as_str()),
                "a shed/throttled client must be told when to come back"
            );
        }
    }

    /// 4xx must be logged and 5xx must not be logged HERE — `Internal` logs its source chain in
    /// `message()` and the pool-exhaustion 503 logs at construction, so a second line in
    /// `into_response` would double every server-side failure in the post-event logs.
    ///
    /// The guard is `status.is_client_error()`, so this pins the classification rather than the
    /// logging itself (which needs a subscriber to observe).
    #[test]
    fn only_client_errors_are_in_the_logged_band() {
        for err in [
            AppError::BadRequest("x".into()),
            AppError::Unauthorized("x".into()),
            AppError::Forbidden("x".into()),
            AppError::UploadsLocked("x".into()),
            AppError::NotFound("x".into()),
            AppError::Conflict("x".into()),
            AppError::TooManyRequests("x".into(), Some(1)),
            AppError::QuotaExceeded("x".into()),
        ] {
            let (status, _) = err.status_and_code();
            assert!(
                status.is_client_error(),
                "{status} should be in the 4xx band this logs"
            );
        }

        for err in [
            AppError::ServiceUnavailable("x".into(), Some(3)),
            AppError::Internal(anyhow::anyhow!("boom")),
        ] {
            let (status, _) = err.status_and_code();
            assert!(
                !status.is_client_error(),
                "{status} logs elsewhere; logging it here would double it"
            );
        }
    }

    /// Pool saturation is load, not a bug. A 500 makes the frontend's retry classifier pile
    /// straight back into the saturated pool with no backoff to pace it.
    #[test]
    fn pool_exhaustion_sheds_with_503_but_shutdown_does_not() {
        let shed: AppError = sqlx::Error::PoolTimedOut.into();
        assert_eq!(
            shed.status_and_code(),
            (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
        );

        // PoolClosed only happens during shutdown; a 503 there would invite a retry against a
        // server that is going away.
        let closing: AppError = sqlx::Error::PoolClosed.into();
        assert_eq!(
            closing.status_and_code(),
            (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
        );

        // Everything else must keep its existing mapping.
        let missing: AppError = sqlx::Error::RowNotFound.into();
        assert_eq!(
            missing.status_and_code(),
            (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
        );
    }
}
