//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
//!
//! Login mints an opaque session token, stores its SHA-256, and returns
//! the raw token in the JSON body. Clients must send it as
//! `Authorization: Bearer …` on subsequent requests; the same token is
//! used for the session and admin-API authentication paths (there is no
//! separate "API token" concept yet).
//!
//! Cookie auth was removed in the audit 2026-06-11 C-1 fix — same-origin
//! user-route HTML defeated the previous `SameSite=Lax` cookie. The
//! dashboard already uses Bearer; non-browser clients always have.
//!
//! Logout deletes the session row regardless of whether the supplied
//! token matched anything (idempotent). `me` returns the row that the
//! middleware already attached to the request extensions.

use axum::body::Body;
use axum::extract::{Extension, Request, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::middleware::from_fn_with_state;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::Router;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use picloud_shared::{AdminUserId, InstanceRole};
use serde::{Deserialize, Serialize};
use serde_json::json;

use picloud_shared::Principal;

use crate::auth::{generate_session_token, hash_token, verify_password};
use crate::auth_middleware::{require_authenticated, AuthState};
use crate::login_rate_limit::LoginRateOutcome;

pub fn auth_router(state: AuthState) -> Router {
    // /login + /logout are unguarded (login is how you get in; logout
    // is idempotent). /me is guarded — by definition it needs to know
    // who you are, so the middleware must run first.
    let guarded = Router::new()
        .route("/auth/me", get(me))
        .route_layer(from_fn_with_state(state.clone(), require_authenticated));

    Router::new()
        .route("/auth/login", post(login))
        .route("/auth/logout", post(logout))
        .merge(guarded)
        .with_state(state)
}

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

#[derive(Debug, Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

#[derive(Debug, Serialize)]
pub struct LoginResponse {
    pub user: AdminUserDto,
    pub token: String,
    pub expires_at: DateTime<Utc>,
}

#[derive(Debug, Serialize)]
pub struct AdminUserDto {
    pub id: AdminUserId,
    pub username: String,
    pub instance_role: InstanceRole,
    pub email: Option<String>,
}

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

async fn login(
    State(state): State<AuthState>,
    headers: HeaderMap,
    Json(input): Json<LoginRequest>,
) -> Response {
    // Audit 2026-06-11 H-B1 — rate limit BEFORE any DB read or
    // Argon2 verify so an attacker can't even queue. Keying is
    // (real client IP, username) + (username), so a single hot user and
    // a credential-stuffing flurry both saturate. The IP is the last
    // X-Forwarded-For hop (Caddy-appended); see `extract_remote_ip`.
    let remote_ip = extract_remote_ip(&headers);
    if let LoginRateOutcome::Denied { retry_after } = state
        .login_rate_limiter
        .try_consume(&remote_ip, &input.username)
    {
        return rate_limited(retry_after);
    }

    // Always perform a verify, even on missing/inactive users, to flatten
    // timing and prevent username enumeration. The dummy hash is the
    // shared `TIMING_FLAT_DUMMY_HASH` constant from `auth.rs` so the
    // single PHC value lives in exactly one place (v1.1.9 F4 dedup).
    let creds = match state
        .users
        .get_credentials_by_username(&input.username)
        .await
    {
        Ok(c) => c,
        Err(err) => {
            tracing::error!(?err, "admin_users credentials lookup failed");
            return internal_error();
        }
    };

    // username from creds is discarded — the re-fetch below carries the
    // canonical row used in the response DTO.
    let (stored_hash, user_id, is_active) = match creds {
        Some(c) => (c.password_hash, Some(c.id), c.is_active),
        None => (crate::auth::TIMING_FLAT_DUMMY_HASH.to_string(), None, false),
    };

    // Audit 2026-06-11 H-B1 — global Argon2 concurrency cap. Acquired
    // AFTER the bucket check so attackers can't queue; held only across
    // the verify, not the surrounding DB I/O. acquire() awaits, but the
    // bucket already gated so the queue is bounded.
    let Ok(permit) = state.argon2_login_semaphore.clone().acquire_owned().await else {
        // Semaphore is closed (process shutting down). Fail closed.
        return internal_error();
    };

    // F-P-002: Argon2id verify off the async worker.
    let password = input.password.clone();
    let password_ok =
        match tokio::task::spawn_blocking(move || verify_password(&stored_hash, &password)).await {
            Ok(b) => b,
            Err(err) => {
                tracing::error!(?err, "verify_password spawn_blocking join failed");
                drop(permit);
                return internal_error();
            }
        };
    drop(permit);
    if !password_ok || user_id.is_none() || !is_active {
        return invalid_credentials();
    }
    let user_id = user_id.unwrap();

    // Re-fetch the full row so the login response carries the same
    // shape /me does (instance_role, email). The credentials struct
    // intentionally omits email; one extra query per login is fine.
    let user_row = match state.users.get(user_id).await {
        Ok(Some(row)) => row,
        Ok(None) => return invalid_credentials(),
        Err(err) => {
            tracing::error!(?err, "admin_users lookup after login failed");
            return internal_error();
        }
    };

    let token = generate_session_token();
    let now = Utc::now();
    // C1: an admin session dies at the EARLIER of its sliding window and its
    // absolute cap. At create the sliding window is shorter, so `expires_at`
    // starts as `now + ttl`; the absolute cap `now + absolute_ttl` is stored so
    // the sliding `touch` can clamp to it as the session ages.
    let expires_at =
        now + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
    let absolute_expires_at = now
        + ChronoDuration::from_std(state.absolute_ttl).unwrap_or_else(|_| ChronoDuration::days(30));

    if let Err(err) = state
        .sessions
        .create(user_id, &token.hash, expires_at, absolute_expires_at)
        .await
    {
        tracing::error!(?err, "admin_sessions insert failed");
        return internal_error();
    }
    if let Err(err) = state.users.touch_last_login(user_id).await {
        // Non-fatal — log and continue. Login itself succeeded.
        tracing::warn!(?err, "failed to touch admin last_login_at");
    }

    (
        StatusCode::OK,
        no_store_headers(),
        Json(LoginResponse {
            user: AdminUserDto {
                id: user_row.id,
                username: user_row.username,
                instance_role: user_row.instance_role,
                email: user_row.email,
            },
            token: token.raw,
            expires_at,
        }),
    )
        .into_response()
}

/// `Cache-Control: no-store` for a response carrying a raw credential (a session
/// token, an API key). Prevents a browser / proxy / CDN cache from retaining the
/// secret where a later client could read it back. (C2)
pub(crate) fn no_store_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
    headers
}

async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
    // Pull token without requiring a valid session (logout is idempotent).
    let token = extract_token_for_logout(&req);
    if let Some(raw) = token {
        let hash = hash_token(&raw);
        if let Err(err) = state.sessions.delete(&hash).await {
            tracing::error!(?err, "admin_sessions delete failed");
        }
        // Audit 2026-06-11 (PrincipalCache revocation-lag) — drop just
        // this token from the cache so the logged-out session can't keep
        // authenticating for the cache TTL. Other sessions for the same
        // user are left untouched.
        state.principal_cache.evict_token(&hash);
    }
    StatusCode::NO_CONTENT.into_response()
}

async fn me(
    State(state): State<AuthState>,
    Extension(principal): Extension<Principal>,
) -> Response {
    // /me consumes the resolved Principal directly; we re-fetch the
    // user row only to surface a fresh username (it can change via
    // PATCH while a session/key is still valid).
    match state.users.get(principal.user_id).await {
        Ok(Some(row)) => Json(AdminUserDto {
            id: row.id,
            username: row.username,
            instance_role: row.instance_role,
            email: row.email,
        })
        .into_response(),
        Ok(None) => invalid_credentials(),
        Err(err) => {
            tracing::error!(?err, "admin_users lookup for /me failed");
            internal_error()
        }
    }
}

// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------

fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
    // Duplicated from auth_middleware::extract_token because logout runs
    // before the auth middleware (logout is idempotent and must accept
    // already-expired tokens). Cookie auth was dropped in the audit
    // 2026-06-11 C-1 fix — Bearer only.
    let value = req.headers().get(header::AUTHORIZATION)?;
    let s = value.to_str().ok()?;
    let token = s.strip_prefix("Bearer ")?.trim();
    if token.is_empty() {
        return None;
    }
    Some(token.to_string())
}

/// Pull the originating client IP from the request for rate-limiting.
///
/// Audit 2026-06-11 (H-B1 follow-up) — Caddy is the single trusted
/// proxy in front of picloud and *appends* the immediate peer to
/// `X-Forwarded-For`, so the **last** entry is the address Caddy
/// actually saw. Every earlier entry is client-supplied: an attacker
/// can send `X-Forwarded-For: <random>` and Caddy forwards
/// `<random>, <real-client>`. Taking the first entry (the previous
/// behavior) therefore let an attacker rotate the per-IP bucket key on
/// every request and evade the per-IP limit. Take the last entry so the
/// qualifier reflects the real client. Returns `"unknown"` when the
/// header is absent/malformed so the per-username bucket still gates.
///
/// (This assumes exactly one trusted proxy hop, which is the documented
/// single-node topology. A multi-proxy deployment would need a
/// configured trusted-hop count; tracked for the cluster work.)
fn extract_remote_ip(headers: &HeaderMap) -> String {
    if let Some(xff) = headers.get("x-forwarded-for") {
        if let Ok(s) = xff.to_str() {
            if let Some(last) = s.split(',').next_back() {
                let trimmed = last.trim();
                if !trimmed.is_empty() {
                    return trimmed.to_string();
                }
            }
        }
    }
    "unknown".to_string()
}

fn rate_limited(retry_after: std::time::Duration) -> Response {
    let mut secs = retry_after.as_secs();
    if secs == 0 {
        secs = 1;
    }
    let mut hdrs = HeaderMap::new();
    if let Ok(v) = HeaderValue::from_str(&secs.to_string()) {
        hdrs.insert("retry-after", v);
    }
    (
        StatusCode::TOO_MANY_REQUESTS,
        hdrs,
        Json(json!({
            "error": "too many login attempts; try again later",
        })),
    )
        .into_response()
}

fn invalid_credentials() -> Response {
    (
        StatusCode::UNAUTHORIZED,
        Json(json!({ "error": "invalid credentials" })),
    )
        .into_response()
}

fn internal_error() -> Response {
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(json!({ "error": "internal error" })),
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use super::extract_remote_ip;
    use axum::http::{HeaderMap, HeaderValue};

    fn xff(value: &str) -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert("x-forwarded-for", HeaderValue::from_str(value).unwrap());
        h
    }

    #[test]
    fn takes_last_xff_entry_caddy_appended_peer() {
        // Audit 2026-06-11 (H-B1 follow-up) — the real client is the
        // last hop Caddy appended; earlier entries are client-spoofable.
        assert_eq!(extract_remote_ip(&xff("203.0.113.9")), "203.0.113.9");
        assert_eq!(
            extract_remote_ip(&xff("9.9.9.9, 203.0.113.9")),
            "203.0.113.9"
        );
    }

    #[test]
    fn spoofed_leading_entry_does_not_change_the_key() {
        // Attacker prepends junk; Caddy still appends the real peer last,
        // so the rate-limit key is stable regardless of the spoof.
        let a = extract_remote_ip(&xff("evil-1, 203.0.113.9"));
        let b = extract_remote_ip(&xff("evil-2, 203.0.113.9"));
        assert_eq!(a, b);
        assert_eq!(a, "203.0.113.9");
    }

    #[test]
    fn missing_or_empty_header_is_unknown() {
        assert_eq!(extract_remote_ip(&HeaderMap::new()), "unknown");
        assert_eq!(extract_remote_ip(&xff("   ")), "unknown");
    }
}
