//! Auth extractors.
//!
//! Three extractors are available, in increasing strictness:
//!
//! - [`CurrentUser`] — accepts either a session cookie or an
//!   `Authorization: Bearer <token>` header. Used by ordinary
//!   authenticated endpoints where bot tokens are first-class clients.
//! - [`CurrentSessionUser`] — accepts only the session cookie. Used as
//!   the substrate for admin extraction so bot tokens cannot authenticate
//!   as the admin (see [`RequireAdmin`]).
//! - [`RequireAdmin`] — composes over [`CurrentSessionUser`] and
//!   additionally requires `user.is_admin`. Returns 403 for
//!   authenticated-but-not-admin, 401 otherwise.
//!
//! All lookups go by `sha256(raw_token)` — the raw value is never stored
//! in the database.

use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum_extra::extract::cookie::CookieJar;
use axum_extra::headers::authorization::Bearer;
use axum_extra::headers::Authorization;
use axum_extra::TypedHeader;

use crate::app::AppState;
use crate::auth::token::hash_token;
use crate::domain::User;
use crate::error::AppError;
use crate::repo;

pub const SESSION_COOKIE_NAME: &str = "mangalord_session";

pub struct CurrentUser(pub User);

#[async_trait]
impl FromRequestParts<AppState> for CurrentUser {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let jar = CookieJar::from_headers(&parts.headers);
        if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) {
            let hash = hash_token(cookie.value());
            if let Some(session) = repo::session::find_active(&state.db, &hash).await? {
                if let Some(user) = repo::user::find_by_id(&state.db, session.user_id).await? {
                    return Ok(CurrentUser(user));
                }
            }
        }

        if let Ok(TypedHeader(Authorization(bearer))) =
            TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
        {
            let hash = hash_token(bearer.token());
            if let Some(token) = repo::api_token::find_active(&state.db, &hash).await? {
                if let Some(user) = repo::user::find_by_id(&state.db, token.user_id).await? {
                    // Fire-and-forget would be ideal but the test harness needs
                    // a deterministic write so the touched timestamp shows up
                    // when the test inspects state. Synchronous is fine.
                    let _ = repo::api_token::touch_last_used(&state.db, token.id).await;
                    return Ok(CurrentUser(user));
                }
            }
        }

        Err(AppError::Unauthenticated)
    }
}

/// Client IP for per-IP auth rate limiting. Resolves to the first hop of
/// `X-Forwarded-For` **only** when [`crate::config::AuthConfig::trusted_proxy`]
/// is set (the backend is behind a proxy that overrides the header — the
/// compose deploy). Otherwise `None`, so the limiter uses its shared bucket.
/// Never fails: a missing or malformed header simply yields `None`.
pub struct ClientIp(pub Option<std::net::IpAddr>);

/// Parse the client IP from an `X-Forwarded-For` value: the left-most hop is
/// the original client (later hops are intermediary proxies). Factored out so
/// the parsing is unit-testable without constructing a request.
pub(crate) fn first_forwarded_ip(header: &str) -> Option<std::net::IpAddr> {
    header
        .split(',')
        .next()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .and_then(|s| s.parse::<std::net::IpAddr>().ok())
}

#[async_trait]
impl FromRequestParts<AppState> for ClientIp {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        if !state.auth.trusted_proxy {
            return Ok(ClientIp(None));
        }
        let ip = parts
            .headers
            .get("x-forwarded-for")
            .and_then(|v| v.to_str().ok())
            .and_then(first_forwarded_ip);
        Ok(ClientIp(ip))
    }
}

/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
/// here — this is the substrate for [`RequireAdmin`] and exists precisely
/// to keep admin authority out of bearer-token reach.
pub struct CurrentSessionUser(pub User);

#[async_trait]
impl FromRequestParts<AppState> for CurrentSessionUser {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let jar = CookieJar::from_headers(&parts.headers);
        let cookie = jar
            .get(SESSION_COOKIE_NAME)
            .ok_or(AppError::Unauthenticated)?;
        let hash = hash_token(cookie.value());
        let session = repo::session::find_active(&state.db, &hash)
            .await?
            .ok_or(AppError::Unauthenticated)?;
        let user = repo::user::find_by_id(&state.db, session.user_id)
            .await?
            .ok_or(AppError::Unauthenticated)?;
        Ok(CurrentSessionUser(user))
    }
}

/// Admin-only. Composes over [`CurrentSessionUser`] so bot tokens are
/// rejected at the auth step (401) rather than the role step (403).
/// The user row is re-read every request, so demotion takes effect on
/// the very next call without needing to purge sessions.
pub struct RequireAdmin(pub User);

#[async_trait]
impl FromRequestParts<AppState> for RequireAdmin {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let CurrentSessionUser(user) =
            CurrentSessionUser::from_request_parts(parts, state).await?;
        if !user.is_admin {
            return Err(AppError::Forbidden);
        }
        Ok(RequireAdmin(user))
    }
}

#[cfg(test)]
mod tests {
    use super::first_forwarded_ip;
    use std::net::IpAddr;

    #[test]
    fn parses_left_most_client_hop() {
        // The client is the first entry; later entries are intermediary proxies.
        assert_eq!(
            first_forwarded_ip("203.0.113.5, 10.0.0.1, 10.0.0.2"),
            Some("203.0.113.5".parse::<IpAddr>().unwrap())
        );
        assert_eq!(
            first_forwarded_ip("  198.51.100.9  "),
            Some("198.51.100.9".parse::<IpAddr>().unwrap())
        );
        assert_eq!(
            first_forwarded_ip("2001:db8::1, 10.0.0.1"),
            Some("2001:db8::1".parse::<IpAddr>().unwrap())
        );
    }

    #[test]
    fn rejects_empty_or_garbage() {
        assert_eq!(first_forwarded_ip(""), None);
        assert_eq!(first_forwarded_ip("   "), None);
        assert_eq!(first_forwarded_ip("not-an-ip"), None);
        assert_eq!(first_forwarded_ip(", 10.0.0.1"), None);
    }
}
