//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login
//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted
//! Bearer token; the browser carries it on every subsequent request to
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
//! accepts both `Authorization: Bearer …` and the cookie.

use crate::api::auth::mint_token;
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
use crate::api::state::AppState;
use crate::api::users::verify_password;
use axum::extract::{Extension, Form};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Debug, Deserialize)]
pub struct LoginForm {
    pub username: String,
    pub password: String,
    /// 6-digit TOTP code, present on the second leg when the first leg
    /// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
    /// to match the rest of the dashboard's form conventions, so we rename
    /// the wire field rather than renaming the input.
    #[serde(default, rename = "tfaCode")]
    pub tfa_code: String,
    /// Echo of the TOTP nonce the first-leg response set on the form.
    #[serde(default)]
    pub secret: String,
}

pub async fn login(
    Extension(state): Extension<Arc<AppState>>,
    Form(form): Form<LoginForm>,
) -> Response {
    // First leg: password verify. Same DB call paths as `/api/login` —
    // we re-use the existing helpers so the dashboard can't accidentally
    // diverge from the API's auth contract.
    let user = match state.db.user_find_by_username(&form.username).await {
        Ok(Some(u)) => u,
        Ok(None) => return error_fragment("Bad credentials"),
        Err(e) => return error_fragment(&format!("internal: {}", e)),
    };
    let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
        Ok(b) => b,
        Err(e) => return error_fragment(&format!("internal: {}", e)),
    };
    if !pw_ok {
        return error_fragment("Bad credentials");
    }
    if user.status == 0 {
        return error_fragment("Account disabled");
    }
    if !user.is_admin {
        // Only admins can use the dashboard. Non-admin users still get
        // tokens via `/api/login` for the desktop client; they just don't
        // see the management surface.
        return error_fragment("Admin access required");
    }
    // Optional second leg: TOTP. If the user has a secret enrolled and the
    // form didn't carry a code, return a fragment that asks for one.
    let totp_secret = state
        .db
        .totp_get_secret(user.id)
        .await
        .ok()
        .flatten();
    if let Some(secret_b32) = totp_secret {
        if form.tfa_code.is_empty() {
            // Shape used by the JS in login.html to switch to the second
            // leg: it watches for the special marker via HX-Trigger and
            // reveals the #tfa-section.
            let frag = format!(
                r#"<span data-tfa-required="1" class="text-amber-300">Enter your 6-digit authenticator code.</span>
<script>
  document.getElementById('tfa-section').classList.remove('hidden');
  document.getElementById('tfaCode').focus();
</script>"#
            );
            // We don't need a session yet — caller will resubmit with the
            // same username/password plus the code. (No nonce involved on
            // the dashboard path: the password is already in scope, so
            // tfa_check / tfa_code are folded into one form.)
            let _ = secret_b32;
            return Html(frag).into_response();
        }
        // Verify the supplied code.
        let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
            Ok(b) => b,
            Err(_) => return error_fragment("Internal TOTP error"),
        };
        if !ok {
            return error_fragment("Bad TOTP code");
        }
    }

    // Mint + persist a token, set the cookie.
    let token = mint_token();
    let sha = sha256_token(&token);
    if let Err(e) = state
        .db
        .token_insert(
            user.id,
            &sha,
            "",
            "",
            r#"{"source":"admin-ui"}"#,
            state.cfg.session_ttl_secs,
        )
        .await
    {
        return error_fragment(&format!("internal: {}", e));
    }
    let cookie = format!(
        "{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
        name = SESSION_COOKIE,
        token = token,
        ttl = state.cfg.session_ttl_secs,
    );
    let mut headers = HeaderMap::new();
    if let Ok(v) = HeaderValue::from_str(&cookie) {
        headers.insert(SET_COOKIE, v);
    }
    // 200 with empty body; the form's hx-on::after-request redirects on
    // success.
    (StatusCode::OK, headers, "").into_response()
}

pub async fn logout(
    Extension(state): Extension<Arc<AppState>>,
    headers: HeaderMap,
) -> Response {
    // Best-effort: pull the token out of the cookie, drop the row.
    if let Some(tok) = cookie_token(&headers) {
        let sha = sha256_token(&tok);
        let _ = state.db.token_delete(&sha).await;
    }
    let mut out = HeaderMap::new();
    let clear = format!(
        "{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0",
        name = SESSION_COOKIE
    );
    if let Ok(v) = HeaderValue::from_str(&clear) {
        out.insert(SET_COOKIE, v);
    }
    (StatusCode::OK, out, "").into_response()
}

fn cookie_token(headers: &HeaderMap) -> Option<String> {
    let s = headers.get(COOKIE)?.to_str().ok()?;
    for pair in s.split(';') {
        if let Some((name, value)) = pair.trim().split_once('=') {
            if name.trim() == SESSION_COOKIE {
                let v = value.trim();
                if !v.is_empty() {
                    return Some(v.to_string());
                }
            }
        }
    }
    None
}

fn error_fragment(msg: &str) -> Response {
    let html = format!("<span>{}</span>", html_escape(msg));
    (StatusCode::UNAUTHORIZED, Html(html)).into_response()
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}
