//! Authentication & authorization: argon2id password hashing and the type-driven
//! axum extractors that gate handlers. Identity is read from the session (set at
//! login); these extractors do not touch the database.

use std::marker::PhantomData;
use std::sync::OnceLock;

use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use domain::{Capability, Email, Role, UserId};
use tower_sessions::Session;

const SESSION_USER_ID: &str = "user_id";
const SESSION_EMAIL: &str = "email";
const SESSION_ROLE: &str = "role";

/// Hash a plaintext password as an argon2id PHC string.
pub fn hash_password(plaintext: &str) -> Result<String, argon2::password_hash::Error> {
    let salt = SaltString::generate(&mut OsRng);

    Ok(Argon2::default()
        .hash_password(plaintext.as_bytes(), &salt)?
        .to_string())
}

/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a
/// wrong password OR a malformed/unparseable hash (never errors out).
pub fn verify_password(plaintext: &str, phc: &str) -> bool {
    let Ok(parsed) = PasswordHash::new(phc) else {
        return false;
    };

    Argon2::default()
        .verify_password(plaintext.as_bytes(), &parsed)
        .is_ok()
}

/// Spend a verify's worth of time against a fixed dummy hash. Call this on the
/// "user not found" login path to blunt user-enumeration via response timing.
pub fn verify_dummy(plaintext: &str) {
    static DUMMY: OnceLock<String> = OnceLock::new();
    let hash =
        DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy"));

    let _ = verify_password(plaintext, hash);
}

/// Record the authenticated identity into the session (call after a successful
/// password check). Cycles the session id first to prevent session fixation.
pub async fn establish_session(
    session: &Session,
    id: UserId,
    email: &Email,
    role: Role,
) -> Result<(), tower_sessions::session::Error> {
    session.cycle_id().await?;
    session.insert(SESSION_USER_ID, id.to_uuid()).await?;
    session.insert(SESSION_EMAIL, email.as_str()).await?;
    session.insert(SESSION_ROLE, role.as_str()).await?;

    Ok(())
}

/// Rejection for the auth extractors.
#[derive(Debug, Clone, Copy, thiserror::Error)]
pub enum AuthError {
    #[error("authentication required")]
    Unauthenticated,
    #[error("insufficient permissions")]
    Forbidden,
    /// The session store itself failed (e.g. the database is unreachable) — distinct
    /// from "no session", so an outage surfaces as 500 rather than a misleading 401.
    #[error("session store unavailable")]
    Internal,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        match self {
            AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
            AuthError::Forbidden => StatusCode::FORBIDDEN,
            AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
        }
        .into_response()
    }
}

/// The authenticated user, reconstructed from the session. Extracting this proves
/// the request carries a valid session (else `401`).
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: UserId,
    pub email: Email,
    pub role: Role,
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // A failed extraction here means the SessionManagerLayer is missing from the
        // stack — a wiring bug, not an auth failure: surface it as 500.
        let session = Session::from_request_parts(parts, state)
            .await
            .map_err(|_| AuthError::Internal)?;

        // For each key: a store error (DB down) is `Internal` (500); an absent key is
        // `Unauthenticated` (401) — these must not be conflated.
        let id: uuid::Uuid = session
            .get(SESSION_USER_ID)
            .await
            .map_err(|_| AuthError::Internal)?
            .ok_or(AuthError::Unauthenticated)?;

        let email: String = session
            .get(SESSION_EMAIL)
            .await
            .map_err(|_| AuthError::Internal)?
            .ok_or(AuthError::Unauthenticated)?;

        let role_str: String = session
            .get(SESSION_ROLE)
            .await
            .map_err(|_| AuthError::Internal)?
            .ok_or(AuthError::Unauthenticated)?;

        let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?;

        Ok(AuthUser {
            id: UserId::from_uuid(id),
            email: Email::from_db(email),
            role,
        })
    }
}

/// A zero-sized type naming a required [`Capability`]. Implementors are used as the
/// type parameter of [`Authorized`].
pub trait CapabilityMarker {
    const CAP: Capability;
}

/// Require `ManageUsers`.
pub struct ManageUsers;

impl CapabilityMarker for ManageUsers {
    const CAP: Capability = Capability::ManageUsers;
}

/// Require `EditCatalogue`.
pub struct EditCatalogue;

impl CapabilityMarker for EditCatalogue {
    const CAP: Capability = Capability::EditCatalogue;
}

/// Require `PublishObjects`.
pub struct PublishObjects;

impl CapabilityMarker for PublishObjects {
    const CAP: Capability = Capability::PublishObjects;
}

/// Require `ViewInternal`.
pub struct ViewInternal;

impl CapabilityMarker for ViewInternal {
    const CAP: Capability = Capability::ViewInternal;
}

/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized<C>`
/// cannot run without the request's role allowing `C` (else `403`).
#[derive(Debug, Clone)]
pub struct Authorized<C: CapabilityMarker> {
    pub user: AuthUser,
    _capability: PhantomData<C>,
}

impl<S, C> FromRequestParts<S> for Authorized<C>
where
    S: Send + Sync,
    C: CapabilityMarker,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let user = AuthUser::from_request_parts(parts, state).await?;

        if user.role.allows(C::CAP) {
            Ok(Authorized {
                user,
                _capability: PhantomData,
            })
        } else {
            Err(AuthError::Forbidden)
        }
    }
}

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

    #[test]
    fn hash_then_verify_round_trips() {
        let hash = hash_password("correct horse battery staple").unwrap();
        assert!(hash.starts_with("$argon2id$"));
        assert!(verify_password("correct horse battery staple", &hash));
    }

    #[test]
    fn verify_rejects_wrong_password() {
        let hash = hash_password("right").unwrap();
        assert!(!verify_password("wrong", &hash));
    }

    #[test]
    fn verify_rejects_malformed_hash() {
        assert!(!verify_password("anything", "not-a-phc-string"));
    }

    #[test]
    fn verify_dummy_does_not_panic() {
        verify_dummy("any input");
        verify_dummy("called again"); // exercises the already-initialized OnceLock path
    }

    #[test]
    fn capability_markers_map_to_domain_capabilities() {
        assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
        assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue);
        assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
        assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal);
    }
}
