//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
//! `require_admin`; every authenticated admin can call all of these.
//! Role/permission walls land later (see blueprint §11.4 — no
//! privilege levels in this cut).
//!
//! "Last active admin" protection lives at the service layer (not just
//! the DB) so it can produce a clean 422 with a human-readable message
//! rather than a SQL constraint violation. Deactivating a user also
//! wipes their sessions; deleting cascades through the FK.

use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::api_key_repo::ApiKeyRepository;
use crate::auth::hash_password;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};

/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
/// a strict ASCII subset so the lookup column stays predictable, and
/// password has a minimum length but no complexity rules (complexity
/// rules push users to predictable patterns).
const USERNAME_MIN: usize = 2;
const USERNAME_MAX: usize = 32;
const PASSWORD_MIN: usize = 8;

#[derive(Clone)]
pub struct AdminsState {
    pub users: Arc<dyn AdminUserRepository>,
    pub sessions: Arc<dyn AdminSessionRepository>,
    /// Phase 3.5 deactivation symmetry — flipping `is_active = false`
    /// also expires every active API key for that user so cookie and
    /// bearer credentials become inert at the same moment.
    pub keys: Arc<dyn ApiKeyRepository>,
    /// Capability gate: every endpoint here requires
    /// `InstanceManageUsers` (owner / admin).
    pub authz: Arc<dyn AuthzRepo>,
    /// Audit 2026-06-11 (PrincipalCache revocation-lag) — evicted on
    /// password change / deactivation so a just-revoked session or
    /// API key stops authenticating immediately instead of lingering
    /// for the cache TTL.
    pub principal_cache: Arc<crate::auth_middleware::PrincipalCache>,
}

pub fn admins_router(state: AdminsState) -> Router {
    Router::new()
        .route("/admins", get(list_admins).post(create_admin))
        .route(
            "/admins/{id}",
            get(get_admin).patch(patch_admin).delete(delete_admin),
        )
        .with_state(state)
}

// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct AdminDto {
    pub id: AdminUserId,
    pub username: String,
    pub is_active: bool,
    pub instance_role: InstanceRole,
    pub email: Option<String>,
    pub created_at: DateTime<Utc>,
    pub last_login_at: Option<DateTime<Utc>>,
}

impl From<AdminUserRow> for AdminDto {
    fn from(r: AdminUserRow) -> Self {
        Self {
            id: r.id,
            username: r.username,
            is_active: r.is_active,
            instance_role: r.instance_role,
            email: r.email,
            created_at: r.created_at,
            last_login_at: r.last_login_at,
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct CreateAdminRequest {
    pub username: String,
    pub password: String,
    /// Defaults to `Admin` when absent — minting an owner via the API
    /// is a deliberate step. The env-var bootstrap path is the only
    /// channel that defaults to `Owner`.
    #[serde(default = "default_create_role")]
    pub instance_role: InstanceRole,
    /// Optional contact email. Blank/whitespace is normalized to None.
    #[serde(default)]
    pub email: Option<String>,
}

const fn default_create_role() -> InstanceRole {
    InstanceRole::Admin
}

#[derive(Debug, Deserialize, Default)]
pub struct PatchAdminRequest {
    pub username: Option<String>,
    pub password: Option<String>,
    pub is_active: Option<bool>,
    pub instance_role: Option<InstanceRole>,
    /// JSON Merge Patch (RFC 7396) semantics for email:
    ///   absent      → don't change
    ///   null        → clear (set DB column to NULL)
    ///   "<string>"  → set to that string
    /// `Option<Option<T>>` is the idiomatic Rust shape for that
    /// tri-state; the custom deserializer below distinguishes the
    /// "missing" case from the "present-and-null" case that serde
    /// would otherwise collapse together.
    #[allow(clippy::option_option)]
    #[serde(default, deserialize_with = "deserialize_present_optional")]
    pub email: Option<Option<String>>,
}

#[allow(clippy::option_option)]
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
    T: serde::Deserialize<'de>,
    D: serde::Deserializer<'de>,
{
    Ok(Some(Option::<T>::deserialize(deserializer)?))
}

// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------

async fn list_admins(
    State(state): State<AdminsState>,
    Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::InstanceManageUsers,
    )
    .await?;
    let rows = state.users.list().await?;
    Ok(Json(rows.into_iter().map(Into::into).collect()))
}

async fn get_admin(
    State(state): State<AdminsState>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, AdminApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::InstanceManageUsers,
    )
    .await?;
    state
        .users
        .get(id)
        .await?
        .map(AdminDto::from)
        .map(Json)
        .ok_or(AdminApiError::NotFound(id))
}

async fn create_admin(
    State(state): State<AdminsState>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<CreateAdminRequest>,
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::InstanceManageUsers,
    )
    .await?;
    // Minting an owner via the API requires the caller to ALSO be an
    // owner — admin cannot self-elevate (or elevate someone else)
    // beyond their own ceiling. Owner-creation by env-var bootstrap
    // bypasses this path.
    if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
    {
        return Err(AdminApiError::CannotEscalate);
    }
    let username = input.username.trim();
    validate_username(username)?;
    validate_password(&input.password)?;
    let email = normalize_email(input.email.as_deref())?;
    let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
    let row = state
        .users
        .create(username, &hash, input.instance_role, email.as_deref())
        .await?;
    Ok((StatusCode::CREATED, Json(row.into())))
}

async fn patch_admin(
    State(state): State<AdminsState>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<AdminUserId>,
    Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, AdminApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::InstanceManageUsers,
    )
    .await?;
    // Verify the target exists upfront — keeps the error path uniform
    // for "rename a missing user" etc.
    let current = state
        .users
        .get(id)
        .await?
        .ok_or(AdminApiError::NotFound(id))?;

    let mut latest: Option<AdminUserRow> = None;

    if let Some(raw_username) = input.username.as_deref() {
        let new_username = raw_username.trim();
        validate_username(new_username)?;
        latest = Some(state.users.update_username(id, new_username).await?);
    }

    if let Some(new_password) = input.password.as_deref() {
        validate_password(new_password)?;
        let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
        latest = Some(state.users.update_password_hash(id, &hash).await?);
        // Audit 2026-06-11 H-E1 — a password change invalidates both
        // credential surfaces for the target user (sessions + API keys),
        // matching the CLI `reset-password` flow and the deactivation
        // path below. A self-change therefore logs the caller out, and
        // the dashboard handles the subsequent 401 by redirecting to the
        // login screen. The previous behavior kept all sessions live,
        // which meant a hijacked session that triggered a password
        // change retained its grip after the rotation.
        if let Err(err) = state.sessions.delete_for_user(id).await {
            tracing::error!(?err, "failed to delete sessions on password change");
        }
        match state.keys.expire_all_for_user(id).await {
            Ok(n) => {
                if n > 0 {
                    tracing::info!(user_id = %id, expired = n, "expired api keys on password change");
                }
            }
            Err(err) => {
                tracing::error!(?err, "failed to expire api keys on password change");
            }
        }
        // Audit 2026-06-11 (PrincipalCache revocation-lag) — the DB
        // writes above are best-effort by design (a blip must not undo
        // the rotation), so evicting the in-process cache is what makes
        // the rotation take effect on the very next request rather than
        // after the cache TTL.
        state.principal_cache.evict_user(id);
    }

    if let Some(email_patch) = input.email.as_ref() {
        // email_patch is Some(None) → clear, Some(Some(s)) → set.
        let normalized = normalize_email(email_patch.as_deref())?;
        latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
    }

    if let Some(new_role) = input.instance_role {
        // Self-elevation guard: only an owner can promote anyone TO
        // owner. An admin cannot turn themselves (or anyone else)
        // into one.
        if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
            return Err(AdminApiError::CannotEscalate);
        }
        // Last-active-owner guard: a transition off of `Owner` cannot
        // leave the install with zero owners. The check is on the
        // source role (current.instance_role) so demoting an
        // already-non-owner is always fine.
        if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
            let remaining = state.users.count_other_active_owners(id).await?;
            if remaining == 0 {
                return Err(AdminApiError::LastActiveOwner);
            }
        }
        latest = Some(state.users.update_instance_role(id, new_role).await?);
    }

    if let Some(new_active) = input.is_active {
        // Last-active-admin guard: only when transitioning to inactive.
        if !new_active {
            let remaining = state.users.count_active_excluding(id).await?;
            if remaining == 0 {
                return Err(AdminApiError::LastActiveAdmin);
            }
            // ALSO: if the target is currently the last active owner,
            // deactivating them leaves no owner. Belt-and-suspenders to
            // the role guard above (which only triggers on an explicit
            // role transition).
            let target_role = latest
                .as_ref()
                .map_or(current.instance_role, |r| r.instance_role);
            if target_role == InstanceRole::Owner {
                let remaining_owners = state.users.count_other_active_owners(id).await?;
                if remaining_owners == 0 {
                    return Err(AdminApiError::LastActiveOwner);
                }
            }
        }
        latest = Some(state.users.set_active(id, new_active).await?);
        // Deactivation invalidates BOTH credential surfaces — sessions
        // (cookie / session bearer) and API keys. Both writes are
        // logged on failure but do not undo the deactivation; the
        // alternative (leaving the user active when one cascade fails)
        // is worse than slightly stale credential rows on a DB blip.
        if !new_active {
            if let Err(err) = state.sessions.delete_for_user(id).await {
                tracing::error!(?err, "failed to delete sessions for deactivated admin");
            }
            match state.keys.expire_all_for_user(id).await {
                Ok(n) => {
                    if n > 0 {
                        tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
                    }
                }
                Err(err) => {
                    tracing::error!(?err, "failed to expire api keys for deactivated admin");
                }
            }
            // Audit 2026-06-11 (PrincipalCache revocation-lag) — evict
            // cached principals so a deactivated admin's session / API
            // keys stop authenticating immediately instead of lingering
            // for the cache TTL window.
            state.principal_cache.evict_user(id);
        }
    }

    let row = match latest {
        Some(r) => r,
        None => state
            .users
            .get(id)
            .await?
            .ok_or(AdminApiError::NotFound(id))?,
    };
    Ok(Json(row.into()))
}

async fn delete_admin(
    State(state): State<AdminsState>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::InstanceManageUsers,
    )
    .await?;
    let target = state
        .users
        .get(id)
        .await?
        .ok_or(AdminApiError::NotFound(id))?;
    if target.is_active {
        let remaining = state.users.count_active_excluding(id).await?;
        if remaining == 0 {
            return Err(AdminApiError::LastActiveAdmin);
        }
        // Last-owner guard mirrors the role-transition guard in
        // patch_admin — deleting the only owner is just as bad as
        // demoting them.
        if target.instance_role == InstanceRole::Owner {
            let remaining_owners = state.users.count_other_active_owners(id).await?;
            if remaining_owners == 0 {
                return Err(AdminApiError::LastActiveOwner);
            }
        }
    }
    state.users.delete(id).await?;
    // Sessions + api_keys cascade via FK; no explicit delete needed.
    Ok(StatusCode::NO_CONTENT)
}

// ----------------------------------------------------------------------------
// Validation
// ----------------------------------------------------------------------------

fn validate_username(s: &str) -> Result<(), AdminApiError> {
    if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
        return Err(AdminApiError::InvalidUsername(format!(
            "username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
        )));
    }
    if !s
        .bytes()
        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
    {
        return Err(AdminApiError::InvalidUsername(
            "username may contain only lowercase letters, digits, dot, underscore, and hyphen"
                .to_string(),
        ));
    }
    Ok(())
}

fn validate_password(s: &str) -> Result<(), AdminApiError> {
    if s.chars().count() < PASSWORD_MIN {
        return Err(AdminApiError::InvalidPassword(format!(
            "password must be at least {PASSWORD_MIN} characters"
        )));
    }
    Ok(())
}

/// Trim and reject empty / pathological emails, returning the
/// canonical form (or None when the input was blank). The shape
/// check is intentionally loose — we mainly want to reject blanks
/// and obvious junk; real verification is a future concern.
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    if trimmed.len() > 254 || !trimmed.contains('@') {
        return Err(AdminApiError::InvalidEmail(
            "email must contain '@' and be at most 254 characters".to_string(),
        ));
    }
    Ok(Some(trimmed.to_string()))
}

// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------

#[derive(Debug, thiserror::Error)]
pub enum AdminApiError {
    #[error("admin user not found: {0}")]
    NotFound(AdminUserId),

    #[error("{0}")]
    InvalidUsername(String),

    #[error("{0}")]
    InvalidPassword(String),

    #[error("{0}")]
    InvalidEmail(String),

    #[error("cannot leave the system with zero active admins")]
    LastActiveAdmin,

    #[error("cannot leave the system with zero active owners")]
    LastActiveOwner,

    #[error("only an owner can grant the owner role")]
    CannotEscalate,

    #[error("forbidden")]
    Forbidden,

    #[error("authorization repo error: {0}")]
    AuthzRepo(String),

    #[error("failed to hash password: {0}")]
    Hash(String),

    #[error("repository error: {0}")]
    Repo(#[from] AdminUserRepositoryError),
}

impl From<AuthzDenied> for AdminApiError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
        }
    }
}

impl IntoResponse for AdminApiError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
            Self::Repo(
                AdminUserRepositoryError::DuplicateUsername(_)
                | AdminUserRepositoryError::DuplicateEmail(_),
            ) => (StatusCode::CONFLICT, self.to_string()),
            Self::InvalidUsername(_)
            | Self::InvalidPassword(_)
            | Self::InvalidEmail(_)
            | Self::LastActiveAdmin
            | Self::LastActiveOwner
            | Self::CannotEscalate
            | Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
                (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
            }
            Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "admin_users authz error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                )
            }
            Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, self.to_string())
            }
            Self::Repo(AdminUserRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "admin_users db error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                )
            }
            Self::Hash(_) => {
                tracing::error!(error = %self, "password hashing failed");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                )
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

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

    #[test]
    fn username_validation_accepts_valid() {
        for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
            assert!(validate_username(u).is_ok(), "should accept {u}");
        }
    }

    #[test]
    fn username_validation_rejects_invalid() {
        for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
            assert!(validate_username(u).is_err(), "should reject {u:?}");
        }
        let too_long = "x".repeat(33);
        assert!(validate_username(&too_long).is_err());
    }

    #[test]
    fn password_validation_enforces_min_length() {
        assert!(validate_password("1234567").is_err());
        assert!(validate_password("12345678").is_ok());
        assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
    }
}
