//! Cookie-based HTML auth pages (login, registration, email verification) for
//! all users. There is no role column — an "admin" is simply the user whose
//! email matches `settings.admin_email` (see [`guard::is_admin`]). On login,
//! admins are redirected to the admin dashboard and everyone else to the
//! storefront; both share the same `auth_token` cookie that the admin handlers
//! already validate. This is the unified replacement for the former
//! admin-only `/admin/login`. The JSON `/api/auth` flow in `auth.rs` is
//! separate and untouched.

use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;

use crate::{
    controllers::auth as auth_controller,
    controllers::cart,
    controllers::i18n::current_lang,
    mailers::auth::AuthMailer,
    models::users::{self, LoginParams, RegisterParams},
    shared::guard,
};

/// Where a freshly-authenticated `user` should land.
fn home_for(ctx: &AppContext, user: &users::Model) -> &'static str {
    if guard::is_admin(ctx, user) {
        "/admin/dashboard"
    } else {
        "/"
    }
}

fn login_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
    format::view(
        v,
        "auth/login.html",
        json!({
            "error": error,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

fn register_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
    format::view(
        v,
        "auth/register.html",
        json!({
            "error": error,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

#[debug_handler]
async fn login_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    if let Some(user) = guard::current_user(&ctx, &jar).await {
        return format::redirect(home_for(&ctx, &user));
    }
    login_view(&v, &jar, None)
}

#[debug_handler]
async fn login(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Form(params): Form<LoginParams>,
) -> Result<Response> {
    let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
        return login_view(&v, &jar, Some("invalid"));
    };

    if !user.verify_password(&params.password) {
        return login_view(&v, &jar, Some("invalid"));
    }

    // Registration requires email verification before the account can sign in.
    if user.email_verified_at.is_none() {
        return login_view(&v, &jar, Some("unverified"));
    }

    let jwt_secret = ctx.config.get_jwt_config()?;

    // If the user opted into 2FA, the password is only the first factor: don't
    // issue the real auth cookie yet. Hand out a short-lived, separate "pending"
    // cookie and send them to the code-entry page. Everyone without 2FA logs in
    // in a single step exactly as before.
    if user.totp_enabled() {
        let pending = user
            .generate_jwt(&jwt_secret.secret, auth_controller::TOTP_PENDING_TTL_SECS)
            .or_else(|_| unauthorized("unauthorized!"))?;
        return format::render()
            .cookies(&[auth_controller::totp_pending_cookie(
                &pending,
                auth_controller::TOTP_PENDING_TTL_SECS,
            )])?
            .redirect("/login/totp");
    }

    let token = user
        .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
        .or_else(|_| unauthorized("unauthorized!"))?;
    let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;

    format::render()
        .cookies(&[
            auth_controller::auth_cookie(&token, jwt_secret.expiration),
            cart_cookie,
        ])?
        .redirect(home_for(&ctx, &user))
}

/// Resolve the user behind a valid, unexpired `totp_pending` cookie. Returns
/// `None` (never errors) when the cookie is missing, malformed, or expired —
/// the caller bounces such requests back to `/login`.
async fn user_from_pending(ctx: &AppContext, jar: &CookieJar) -> Option<users::Model> {
    let cookie = jar.get(auth_controller::TOTP_PENDING_COOKIE)?;
    let jwt_config = ctx.config.get_jwt_config().ok()?;
    let claims = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
        .validate(cookie.value())
        .ok()?;
    let user = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await.ok()?;
    // Defend against a stale pending cookie outliving a 2FA disable.
    user.totp_enabled().then_some(user)
}

fn login_totp_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
    format::view(
        v,
        "auth/login_totp.html",
        json!({
            "error": error,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

#[debug_handler]
async fn login_totp_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    if user_from_pending(&ctx, &jar).await.is_none() {
        return format::redirect("/login");
    }
    login_totp_view(&v, &jar, None)
}

/// Second login factor. Accepts either a 6-digit authenticator code or one of
/// the one-time backup codes (auto-detected by length). On success the pending
/// cookie is cleared and the real `auth_token` is issued.
#[derive(Debug, serde::Deserialize)]
struct TotpLoginForm {
    code: String,
}

#[debug_handler]
async fn login_totp(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Form(form): Form<TotpLoginForm>,
) -> Result<Response> {
    let Some(user) = user_from_pending(&ctx, &jar).await else {
        return format::redirect("/login");
    };

    let code = form.code.trim();
    let via_totp = user.verify_totp_code(code);
    let via_backup = !via_totp && user.matches_backup_code(code);

    if !via_totp && !via_backup {
        return login_totp_view(&v, &jar, Some("invalid"));
    }

    // A used backup code must be burned so it can't be replayed.
    if via_backup {
        user.clone().into_active_model().consume_backup_code(&ctx.db, code).await?;
    }

    let jwt_secret = ctx.config.get_jwt_config()?;
    let token = user
        .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
        .or_else(|_| unauthorized("unauthorized!"))?;
    let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;

    format::render()
        .cookies(&[
            auth_controller::auth_cookie(&token, jwt_secret.expiration),
            auth_controller::clear_totp_pending_cookie(),
            cart_cookie,
        ])?
        .redirect(home_for(&ctx, &user))
}

#[debug_handler]
async fn register_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    if let Some(user) = guard::current_user(&ctx, &jar).await {
        return format::redirect(home_for(&ctx, &user));
    }
    register_view(&v, &jar, None)
}

/// Registration form. The name is no longer collected from the user — it is
/// derived from the email — and the password is entered twice to guard against
/// typos.
#[derive(Debug, serde::Deserialize)]
struct RegisterForm {
    email: String,
    password: String,
    password_confirm: String,
    #[serde(default)]
    account_type: Option<String>,
}

/// Derive a display name from an email address (its local part), falling back to
/// the full address when the local part is too short for the name validator.
fn name_from_email(email: &str) -> String {
    let local = email.split('@').next().unwrap_or("").trim();
    if local.chars().count() >= 2 {
        local.to_string()
    } else {
        email.trim().to_string()
    }
}

#[debug_handler]
async fn register(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Form(form): Form<RegisterForm>,
) -> Result<Response> {
    if form.password != form.password_confirm {
        return register_view(&v, &jar, Some("mismatch"));
    }
    if form.password.len() < 8 {
        return register_view(&v, &jar, Some("weak"));
    }

    let params = RegisterParams {
        name: name_from_email(&form.email),
        email: form.email,
        password: form.password,
        account_type: form.account_type,
    };

    let user = match users::Model::create_with_password(&ctx.db, &params).await {
        Ok(user) => user,
        Err(ModelError::EntityAlreadyExists {}) => {
            return register_view(&v, &jar, Some("exists"));
        }
        Err(err) => {
            // Most commonly a validation failure (name too short / invalid email).
            tracing::info!(
                message = err.to_string(),
                user_email = &params.email,
                "could not register user",
            );
            return register_view(&v, &jar, Some("invalid"));
        }
    };

    let user = user
        .into_active_model()
        .set_email_verification_sent(&ctx.db)
        .await?;
    cart::claim_guest_cart(&ctx, &jar, user.id).await?;

    // The account already exists; a failed email send shouldn't 500 the page —
    // log it and let the user fall back to resend-verification.
    if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
        tracing::error!(
            error = err.to_string(),
            user_email = &user.email,
            "failed to send verification email",
        );
    }

    format::view(
        &v,
        "auth/verify_sent.html",
        json!({
            "email": user.email,
            "logged_in_admin": false,
            "lang": current_lang(&jar),
        }),
    )
}

#[debug_handler]
async fn verify(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Path(token): Path<String>,
) -> Result<Response> {
    let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
        return verified_view(&v, &jar, false);
    };

    if user.email_verified_at.is_none() {
        let user_id = user.id;
        user.into_active_model().verified(&ctx.db).await?;
        cart::claim_guest_cart(&ctx, &jar, user_id).await?;
    }

    verified_view(&v, &jar, true)
}

fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
    format::view(
        v,
        "auth/verified.html",
        json!({
            "ok": ok,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

/// Resend the email-verification link. Throttled per account (see
/// [`users::Model::verification_resend_wait_secs`]) so it can't be used to spam
/// an inbox, and always returns the same neutral message so it can't be used to
/// probe which addresses are registered.
#[derive(Debug, serde::Deserialize)]
struct ResendVerificationForm {
    email: String,
}

fn resend_verification_view(v: &TeraView, jar: &CookieJar, done: bool) -> Result<Response> {
    format::view(
        v,
        "auth/resend_verification.html",
        json!({
            "done": done,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

#[debug_handler]
async fn resend_verification_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
) -> Result<Response> {
    resend_verification_view(&v, &jar, false)
}

#[debug_handler]
async fn resend_verification(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Form(form): Form<ResendVerificationForm>,
) -> Result<Response> {
    // Resend only for a real, still-unverified account that is past its cooldown.
    // Anything else (unknown email, already verified, too soon) silently does
    // nothing — the response is identical either way.
    if let Ok(user) = users::Model::find_by_email(&ctx.db, form.email.trim()).await {
        if user.email_verified_at.is_none() && user.verification_resend_wait_secs() == 0 {
            match user.into_active_model().set_email_verification_sent(&ctx.db).await {
                Ok(user) => {
                    if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
                        tracing::error!(error = %err, "failed to resend verification email");
                    }
                }
                Err(err) => tracing::error!(error = %err, "failed to refresh verification token"),
            }
        } else {
            tracing::info!("verification resend skipped (already verified or within cooldown)");
        }
    }
    resend_verification_view(&v, &jar, true)
}

/// Set-password form for accounts created during checkout (and any account that
/// has a valid reset token). Reuses the password-reset token machinery.
#[derive(Debug, serde::Deserialize)]
struct SetPasswordForm {
    token: String,
    password: String,
    password_confirm: String,
}

fn set_password_view(
    v: &TeraView,
    jar: &CookieJar,
    token: &str,
    valid: bool,
    error: Option<&str>,
) -> Result<Response> {
    format::view(
        v,
        "auth/set_password.html",
        json!({
            "token": token,
            "valid": valid,
            "error": error,
            "logged_in_admin": false,
            "lang": current_lang(jar),
        }),
    )
}

#[debug_handler]
async fn set_password_page(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Path(token): Path<String>,
) -> Result<Response> {
    let valid = users::Model::find_by_reset_token(&ctx.db, &token).await.is_ok();
    set_password_view(&v, &jar, &token, valid, None)
}

#[debug_handler]
async fn set_password(
    jar: CookieJar,
    ViewEngine(v): ViewEngine<TeraView>,
    State(ctx): State<AppContext>,
    Form(form): Form<SetPasswordForm>,
) -> Result<Response> {
    let Ok(user) = users::Model::find_by_reset_token(&ctx.db, &form.token).await else {
        return set_password_view(&v, &jar, &form.token, false, None);
    };
    if form.password != form.password_confirm {
        return set_password_view(&v, &jar, &form.token, true, Some("mismatch"));
    }
    if form.password.len() < 8 {
        return set_password_view(&v, &jar, &form.token, true, Some("weak"));
    }
    // Setting the password through an emailed link also proves email ownership,
    // so the account is marked verified here.
    let user = user.into_active_model().reset_password(&ctx.db, &form.password).await?;
    if user.email_verified_at.is_none() {
        user.into_active_model().verified(&ctx.db).await?;
    }
    format::redirect("/login")
}

#[debug_handler]
async fn logout() -> Result<Response> {
    format::render()
        .cookies(&[
            auth_controller::clear_auth_cookie(),
            cart::cleared_cart_cookie(),
        ])?
        .redirect("/login")
}

/// Backwards-compatible entry point: `/admin` sends admins to their dashboard
/// and everyone else to the unified login.
#[debug_handler]
async fn admin_entry(jar: CookieJar, State(ctx): State<AppContext>) -> Result<Response> {
    if let Some(user) = guard::current_user(&ctx, &jar).await {
        if guard::is_admin(&ctx, &user) {
            return format::redirect("/admin/dashboard");
        }
    }
    format::redirect("/login")
}

pub fn routes() -> Routes {
    Routes::new()
        .add("/login", get(login_page))
        .add("/login", post(login))
        .add("/login/totp", get(login_totp_page))
        .add("/login/totp", post(login_totp))
        .add("/register", get(register_page))
        .add("/register", post(register))
        .add("/verify/{token}", get(verify))
        .add("/resend-verification", get(resend_verification_page))
        .add("/resend-verification", post(resend_verification))
        .add("/set-password/{token}", get(set_password_page))
        .add("/set-password", post(set_password))
        .add("/logout", post(logout))
        .add("/admin", get(admin_entry))
}
