//! Authentication endpoints — register, login, logout, current-user, and
//! bot API token management. Session cookies are HttpOnly + SameSite=Lax
//! and rotate on login (a fresh session row is created; old sessions
//! expire naturally rather than being explicitly invalidated, so other
//! devices keep their existing logins).

use std::sync::OnceLock;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, get, patch, post};
use axum::{Json, Router};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::app::AppState;
use crate::auth::extractor::{ClientIp, CurrentUser, SESSION_COOKIE_NAME};
use crate::auth::password::{hash_password_async, verify_password_async};
use crate::auth::token::{generate_token, hash_token};
use crate::config::AuthConfig;
use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
use crate::domain::{ApiToken, User, UserPreferences};
use crate::error::{AppError, AppResult};
use crate::repo;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/auth/config", get(auth_config))
        .route("/auth/register", post(register))
        .route("/auth/login", post(login))
        .route("/auth/logout", post(logout))
        .route("/auth/me", get(me))
        .route("/auth/me/password", patch(change_password))
        .route(
            "/auth/me/preferences",
            get(get_preferences).patch(update_preferences),
        )
        .route("/auth/tokens", post(create_token))
        .route("/auth/tokens/:id", delete(delete_token))
}

/// Public, unauthenticated. Exposes anonymous-relevant auth policy so
/// the frontend can render its login / register affordances correctly
/// without a probe request that would conflate "disabled" with
/// "rate-limited". `self_register_enabled` is the *effective* value
/// (`allow_self_register && !private_mode`), so a private-mode
/// instance reports `false` even if the raw flag is on.
#[derive(Debug, Serialize)]
pub struct AuthConfigResponse {
    pub self_register_enabled: bool,
    pub private_mode: bool,
}

async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
    Json(AuthConfigResponse {
        self_register_enabled: state.auth.allow_self_register && !state.auth.private_mode,
        private_mode: state.auth.private_mode,
    })
}

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

#[derive(Debug, Serialize)]
pub struct AuthResponse {
    pub user: User,
}

#[derive(Debug, Deserialize)]
pub struct CreateTokenInput {
    pub name: String,
    /// Optional lifetime in days. Omit (or `null`) for a non-expiring
    /// token (the historical behaviour). When set, must be 1..=3650.
    #[serde(default)]
    pub expires_in_days: Option<i64>,
}

/// Upper bound on a requested token lifetime (~10 years). A token that needs
/// to outlive this should be rotated, not minted once forever.
const MAX_TOKEN_EXPIRY_DAYS: i64 = 3650;

#[derive(Debug, Deserialize)]
pub struct ChangePassword {
    pub current_password: String,
    pub new_password: String,
}

#[derive(Debug, Deserialize)]
pub struct UpdatePreferences {
    pub reader_mode: Option<String>,
    pub reader_page_gap: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CreatedTokenResponse {
    #[serde(flatten)]
    pub token: ApiToken,
    /// Raw bearer token — returned exactly once at creation.
    pub bearer: String,
}

async fn register(
    State(state): State<AppState>,
    ClientIp(client_ip): ClientIp,
    jar: CookieJar,
    Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
    // Rate limit before the disabled check so an operator who flips
    // the toggle can't be probed for the toggle state via timing —
    // disabled and enabled paths both consume a token, and disabled
    // returns 403 instead of running argon2.
    check_auth_rate_limit(&state, "register", client_ip)?;
    // Private mode force-blocks self-registration regardless of
    // ALLOW_SELF_REGISTER — operators of locked-down instances mint
    // accounts via `POST /admin/users` instead.
    if !state.auth.allow_self_register || state.auth.private_mode {
        return Err(AppError::Forbidden);
    }
    let username = input.username.trim();
    validate_username(username)?;
    validate_password(&input.password)?;

    let pwhash = hash_password_async(input.password.clone()).await?;
    let user = repo::user::create(&state.db, username, &pwhash).await?;
    let jar = start_session(&state, &user, jar).await?;
    Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
}

async fn login(
    State(state): State<AppState>,
    ClientIp(client_ip): ClientIp,
    jar: CookieJar,
    Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> {
    check_auth_rate_limit(&state, "login", client_ip)?;
    let username = input.username.trim();
    if username.is_empty() || input.password.is_empty() {
        return Err(AppError::InvalidInput(
            "username and password are required".into(),
        ));
    }
    // Bound the password before argon2 runs — guards BOTH the real-verify and
    // the dummy-hash timing-equaliser branch below, so a giant password can't
    // make every login attempt a CPU-DoS.
    reject_oversized_password(&input.password)?;

    let user = repo::user::find_by_username(&state.db, username).await?;
    let Some(user) = user else {
        // No such user. Run argon2 against a stable dummy hash so the
        // response time matches the wrong-password branch — otherwise
        // an attacker can enumerate usernames by timing the no-user
        // 401 against the wrong-password 401.
        let _ = verify_password_async(input.password.clone(), dummy_password_hash().to_string())
            .await;
        return Err(AppError::Unauthenticated);
    };
    if !verify_password_async(input.password.clone(), user.password_hash.clone()).await {
        return Err(AppError::Unauthenticated);
    }

    let jar = start_session(&state, &user, jar).await?;
    Ok((StatusCode::OK, jar, Json(AuthResponse { user })))
}

/// Lazily-computed argon2 hash used to equalise login response time
/// across the "no such user" and "wrong password" branches. Computing
/// it once (on the first login of the process) is enough — the hash is
/// never compared against a real password, only used to force argon2
/// to do the same amount of work it would for a real verify.
fn dummy_password_hash() -> &'static str {
    static DUMMY: OnceLock<String> = OnceLock::new();
    DUMMY
        .get_or_init(|| {
            crate::auth::password::hash_password("login-timing-equaliser")
                .expect("hash_password on a fixed input cannot fail")
        })
        .as_str()
}

async fn logout(
    State(state): State<AppState>,
    jar: CookieJar,
) -> AppResult<impl IntoResponse> {
    if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) {
        let hash = hash_token(cookie.value());
        repo::session::delete_by_token_hash(&state.db, &hash).await?;
    }
    let jar = jar.add(build_expired_cookie(&state.auth));
    Ok((StatusCode::NO_CONTENT, jar))
}

async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
    Ok(Json(AuthResponse { user }))
}

/// `PATCH /api/v1/auth/me/password` — change the current user's password.
///
/// Verifies `current_password` against the stored argon2 hash (401 on
/// mismatch, matching the login contract). Validates `new_password`
/// against the same min-length rule used at registration. On success,
/// inside a single transaction:
///   - UPDATE users.password_hash with the new argon2 hash
///   - DELETE all existing sessions for this user (signs out other
///     devices; the stolen-cookie attack surface is closed)
///   - INSERT a fresh session and return it as a new cookie so the
///     caller stays logged in
///
/// Bot tokens (`api_tokens`) are left alone: the user opted into them
/// explicitly and can revoke individually via DELETE /auth/tokens/{id}.
async fn change_password(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    ClientIp(client_ip): ClientIp,
    jar: CookieJar,
    Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> {
    check_auth_rate_limit(&state, "change_password", client_ip)?;
    // Cap current_password before verify_password runs argon2 (same DoS
    // vector as login). new_password is bounded by validate_password below.
    reject_oversized_password(&input.current_password)?;
    if !verify_password_async(input.current_password.clone(), user.password_hash.clone()).await {
        return Err(AppError::Unauthenticated);
    }
    validate_password(&input.new_password)?;

    let new_hash = hash_password_async(input.new_password.clone()).await?;

    let mut tx = state.db.begin().await?;
    sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
        .bind(&new_hash)
        .bind(user.id)
        .execute(&mut *tx)
        .await?;
    repo::session::delete_all_for_user(&mut *tx, user.id).await?;
    let (raw, hash) = generate_token();
    let expires_at = Utc::now() + Duration::days(state.auth.session_ttl_days);
    repo::session::create(&mut *tx, user.id, &hash, expires_at).await?;
    tx.commit().await?;

    let jar = jar.add(build_session_cookie(raw, &state.auth));
    Ok((StatusCode::NO_CONTENT, jar))
}

/// `GET /api/v1/auth/me/preferences` — return the caller's stored reader
/// preferences, or the in-memory defaults if they have never customised them.
async fn get_preferences(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
) -> AppResult<Json<UserPreferences>> {
    let prefs = repo::user_preferences::find(&state.db, user.id)
        .await?
        .unwrap_or_else(|| UserPreferences::defaults_for(user.id));
    Ok(Json(prefs))
}

/// `PATCH /api/v1/auth/me/preferences` — partial update. Unspecified fields
/// keep their current values. Each field is validated against its allowed-
/// value list; invalid values return 400 `invalid_input`.
async fn update_preferences(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Json(input): Json<UpdatePreferences>,
) -> AppResult<Json<UserPreferences>> {
    let current = repo::user_preferences::find(&state.db, user.id)
        .await?
        .unwrap_or_else(|| UserPreferences::defaults_for(user.id));

    let reader_mode = match input.reader_mode {
        Some(m) => {
            if !READER_MODES.contains(&m.as_str()) {
                return Err(AppError::InvalidInput(format!(
                    "reader_mode must be one of {READER_MODES:?}"
                )));
            }
            m
        }
        None => current.reader_mode,
    };
    let reader_page_gap = match input.reader_page_gap {
        Some(g) => {
            if !READER_GAPS.contains(&g.as_str()) {
                return Err(AppError::InvalidInput(format!(
                    "reader_page_gap must be one of {READER_GAPS:?}"
                )));
            }
            g
        }
        None => current.reader_page_gap,
    };

    let saved =
        repo::user_preferences::upsert(&state.db, user.id, &reader_mode, &reader_page_gap).await?;
    Ok(Json(saved))
}

async fn create_token(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Json(input): Json<CreateTokenInput>,
) -> AppResult<impl IntoResponse> {
    let name = input.name.trim();
    // Both arms use `ValidationFailed` (422 with field details) to
    // match the structured-error shape `attach_tag` returns for the
    // same kind of free-form-identifier validation. The other
    // /auth/* handlers in this file use `InvalidInput` (400); the
    // divergence is pre-existing and would warrant a project-wide
    // pass to flip them all if the client side wants uniform per-
    // field error rendering.
    if name.is_empty() {
        return Err(AppError::ValidationFailed {
            message: "token name is required".into(),
            details: serde_json::json!({ "name": "required" }),
        });
    }
    if name.chars().count() > 64 {
        return Err(AppError::ValidationFailed {
            message: "token name too long".into(),
            details: serde_json::json!({ "name": "max 64 characters" }),
        });
    }
    let expires_at = match input.expires_in_days {
        None => None,
        Some(days) if (1..=MAX_TOKEN_EXPIRY_DAYS).contains(&days) => {
            Some(Utc::now() + Duration::days(days))
        }
        Some(_) => {
            return Err(AppError::ValidationFailed {
                message: "token expiry out of range".into(),
                details: serde_json::json!({
                    "expires_in_days": format!("must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}")
                }),
            });
        }
    };
    let (raw, hash) = generate_token();
    let token = repo::api_token::create(&state.db, user.id, name, &hash, expires_at).await?;
    Ok((
        StatusCode::CREATED,
        Json(CreatedTokenResponse { token, bearer: raw }),
    ))
}

async fn delete_token(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
    match repo::api_token::find_owner(&state.db, id).await? {
        None => Err(AppError::NotFound),
        Some(owner) if owner != user.id => Err(AppError::Forbidden),
        Some(_) => {
            repo::api_token::delete(&state.db, id).await?;
            Ok(StatusCode::NO_CONTENT)
        }
    }
}

async fn start_session(
    state: &AppState,
    user: &User,
    jar: CookieJar,
) -> AppResult<CookieJar> {
    let (raw, hash) = generate_token();
    let expires_at = Utc::now() + Duration::days(state.auth.session_ttl_days);
    repo::session::create(&state.db, user.id, &hash, expires_at).await?;
    Ok(jar.add(build_session_cookie(raw, &state.auth)))
}

// CSRF posture: `SameSite=Lax` is the project's primary CSRF defense.
// Browsers refuse to attach this cookie to cross-site POST / PATCH /
// DELETE requests, which covers every state-changing endpoint (auth
// mutations, uploads, bookmarks, collections, admin user management,
// etc. — all JSON over POST/PATCH/DELETE). Lax DOES still attach the
// cookie on top-level cross-site GETs, so this defense breaks the
// instant anyone adds a state-changing GET. If you reach for one,
// switch to `SameSite=Strict` here AND add an explicit CSRF-token
// check on the new endpoint. The Bearer-token branch in the
// extractor is unaffected (bots authenticate with the token header,
// not the cookie) and admin routes reject Bearer entirely — see
// `auth::extractor::RequireAdmin`.
fn build_session_cookie(raw: String, cfg: &AuthConfig) -> Cookie<'static> {
    let mut builder = Cookie::build((SESSION_COOKIE_NAME, raw))
        .http_only(true)
        .secure(cfg.cookie_secure)
        .same_site(SameSite::Lax)
        .path("/")
        .max_age(time::Duration::days(cfg.session_ttl_days));
    if let Some(domain) = &cfg.cookie_domain {
        builder = builder.domain(domain.clone());
    }
    builder.build()
}

fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
    let mut builder = Cookie::build((SESSION_COOKIE_NAME, ""))
        .http_only(true)
        .secure(cfg.cookie_secure)
        .same_site(SameSite::Lax)
        .path("/")
        .max_age(time::Duration::seconds(0));
    if let Some(domain) = &cfg.cookie_domain {
        builder = builder.domain(domain.clone());
    }
    builder.build()
}

/// Consume one token from the shared auth rate limiter. Called at the
/// start of `register`, `login`, and `change_password` so credential
/// stuffing / spraying / username-probe loops are throttled by the
/// configured budget (default 5/sec with a 10-request burst).
///
/// All three endpoints share one bucket — they all expose the same
/// argon2-verify-or-create work and the same enumeration channels, so
/// any one of them in a tight loop should trip the limit. `endpoint`
/// is included in the rate-limit-hit log line so operators can tell
/// which endpoint is being probed.
fn check_auth_rate_limit(
    state: &AppState,
    endpoint: &'static str,
    client_ip: Option<std::net::IpAddr>,
) -> AppResult<()> {
    use crate::auth::rate_limit::AcquireResult;
    match state.auth_limiter.try_acquire(client_ip) {
        AcquireResult::Allowed => Ok(()),
        AcquireResult::Denied { retry_after_secs } => {
            tracing::warn!(
                endpoint,
                retry_after_secs,
                "auth rate limit hit; returning 429"
            );
            Err(AppError::TooManyRequests {
                retry_after_secs: Some(retry_after_secs),
            })
        }
    }
}

// Exposed pub(crate) so the admin user-create handler can apply the
// same rules as self-registration. Keeping the lone canonical
// implementation here avoids the two paths drifting on min length /
// allowed character set.
pub(crate) fn validate_username(u: &str) -> AppResult<()> {
    if u.is_empty() {
        return Err(AppError::InvalidInput("username is required".into()));
    }
    if u.len() < 3 || u.len() > 32 {
        return Err(AppError::InvalidInput(
            "username must be 3-32 characters".into(),
        ));
    }
    if !u.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
        return Err(AppError::InvalidInput(
            "username may only contain letters, digits, _ and -".into(),
        ));
    }
    Ok(())
}

/// Upper bound on password length (bytes). argon2 has no inherent length
/// limit, so without a cap an attacker could submit a multi-megabyte password
/// and make every hash a CPU-heavy DoS. 1024 bytes is far longer than any real
/// passphrase. Measured in bytes (like the min check) since that's what argon2
/// actually processes.
pub(crate) const MAX_PASSWORD_BYTES: usize = 1024;

/// Reject an over-cap password *before* any argon2 work runs. The
/// verification paths (login, change-password) don't go through
/// [`validate_password`] — they hash/verify the raw input — so without this
/// an attacker could submit a multi-megabyte password and turn every
/// login/verify into a CPU-DoS. Length-only and independent of whether the
/// account exists, so it leaks nothing (no username enumeration).
pub(crate) fn reject_oversized_password(p: &str) -> AppResult<()> {
    if p.len() > MAX_PASSWORD_BYTES {
        return Err(AppError::InvalidInput(format!(
            "password must be at most {MAX_PASSWORD_BYTES} bytes"
        )));
    }
    Ok(())
}

pub(crate) fn validate_password(p: &str) -> AppResult<()> {
    if p.len() < 8 {
        return Err(AppError::InvalidInput(
            "password must be at least 8 characters".into(),
        ));
    }
    if p.len() > MAX_PASSWORD_BYTES {
        return Err(AppError::InvalidInput(
            format!("password must be at most {MAX_PASSWORD_BYTES} bytes"),
        ));
    }
    Ok(())
}

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

    #[test]
    fn validate_password_rejects_too_short() {
        assert!(validate_password("short").is_err());
        assert!(validate_password("1234567").is_err()); // 7 chars
    }

    #[test]
    fn validate_password_accepts_in_range() {
        assert!(validate_password("hunter2hunter2").is_ok());
        // Exactly at the cap is allowed.
        assert!(validate_password(&"a".repeat(MAX_PASSWORD_BYTES)).is_ok());
        // Minimum length boundary.
        assert!(validate_password("12345678").is_ok());
    }

    #[test]
    fn validate_password_rejects_over_cap() {
        // One byte past the cap is refused so a giant password can't turn each
        // login/register into an argon2 CPU-DoS.
        let too_long = "a".repeat(MAX_PASSWORD_BYTES + 1);
        let err = validate_password(&too_long).unwrap_err();
        assert!(matches!(err, AppError::InvalidInput(_)));
    }
}
