//! Forms auth layer — sessions, login, setup, middleware.
//!
//! Three states:
//!
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
//!   middleware passes every request through — there's no one to gate
//!   against. The UI's `/api/me` returns `setup_required: true` and the
//!   front-end pushes the operator into the first-run flow.
//! * **Logged in**. The session cookie maps to an in-memory session
//!   record with an idle expiry; `/api/me` returns the username.
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
//!   allowlist below) to `401 Unauthorized`; the front-end intercepts
//!   that and shows `/login`.
//!
//! Allowlist for unauthenticated access *after* the admin is set up:
//!
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
//!   script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
//!   liveness/readiness probes, the Prometheus scrape) — these are
//!   read-only or PXE-essential and breaking them locks out booting
//!   machines that have no way to authenticate;
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
//!   Queued Deployment — the iPXE client can't send a session cookie).
//!
//! Everything else inside `/api/*` requires a valid session.

use crate::state::AppState;
use axum::{
    body::Body,
    extract::{Request, State},
    http::{header, HeaderValue, StatusCode},
    middleware::Next,
    response::{IntoResponse, Response},
    Json,
};
use openpxe_core::AdminPublic;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;

/// Idle session lifetime. Sliding — every authenticated request resets
/// the expiry. 24h is the Sonarr default and matches what most operators
/// expect for an on-prem admin console.
const SESSION_TTL: Duration = Duration::from_hours(24);

/// Name of the cookie we set/read. Distinct from a generic `session=`
/// to avoid collisions with anything else sharing the host.
pub const SESSION_COOKIE: &str = "openpxe_session";

#[derive(Debug, Clone)]
struct Session {
    username: String,
    expires_at: Instant,
}

/// In-memory session table. Cheap to clone (Arc-shared) and contention
/// is rare — operators sign in once per browser session.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
    inner: Arc<RwLock<HashMap<String, Session>>>,
}

impl SessionStore {
    /// Mint a fresh session for `username` and return the opaque cookie
    /// value. UUID v4 gives us 122 random bits — comfortably more than
    /// the 64-128 bits typical for session IDs.
    #[must_use]
    pub fn create(&self, username: &str) -> String {
        let id = Uuid::new_v4().simple().to_string();
        let session = Session {
            username: username.to_string(),
            expires_at: Instant::now() + SESSION_TTL,
        };
        self.inner.write().insert(id.clone(), session);
        id
    }

    /// Resolve a cookie value to the owning username, refreshing the
    /// idle timer. Returns `None` for missing / expired sessions and
    /// proactively evicts the expired entry so the map doesn't grow
    /// unbounded across long-lived deployments.
    pub fn touch(&self, id: &str) -> Option<String> {
        let mut g = self.inner.write();
        let s = g.get_mut(id)?;
        if s.expires_at <= Instant::now() {
            g.remove(id);
            return None;
        }
        s.expires_at = Instant::now() + SESSION_TTL;
        Some(s.username.clone())
    }

    /// Invalidate one session (the user's `/api/logout`).
    pub fn revoke(&self, id: &str) {
        self.inner.write().remove(id);
    }

    /// Invalidate every session — used after a credentials rotation so
    /// stale cookies for the old password can't keep operating.
    pub fn revoke_all(&self) {
        self.inner.write().clear();
    }

    /// Periodic / opportunistic GC. Not currently scheduled (we evict
    /// on touch), but exposed for a future janitor task.
    pub fn gc(&self) {
        let now = Instant::now();
        self.inner.write().retain(|_, s| s.expires_at > now);
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.inner.read().len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

// ── Cookie helpers ────────────────────────────────────────────────────────

fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
    // Same flags FleetDM and Sonarr ship by default:
    //   - HttpOnly: blocks JS access (XSS containment)
    //   - SameSite=Lax: allows top-level GET navigations from the IdP
    //     to land authenticated when SSO arrives, but blocks
    //     cross-site POST CSRF;
    //   - Path=/: the cookie applies to the whole app;
    //   - no Secure flag yet — many operators host on plain http://
    //     LAN IPs (Unraid templates default to that); we'll add Secure
    //     opportunistically when we add a TLS terminator option.
    // SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
    // never overflow i64, but clippy's `cast_possible_wrap` lint wants
    // us to be explicit. `cast_signed` is the documented form.
    let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
    format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
}

/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
    cookie_attrs(session, None)
}

fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
    // `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
    for part in raw.split(';') {
        let part = part.trim();
        if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
            return Some(v.to_string());
        }
    }
    None
}

// ── Middleware ────────────────────────────────────────────────────────────

/// Return `true` if `path` is on the allowlist and should bypass the
/// session check. The middleware applies this rule only when the admin
/// account is configured; before then everything is open.
fn is_public_path(path: &str) -> bool {
    // Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
    // metrics. All read-only / PXE-essential.
    if !path.starts_with("/api/") {
        return true;
    }
    // Auth surface and iPXE long-poll endpoints (no cookie available).
    // The SAML SP endpoints are pre-auth by nature — the operator hasn't a
    // session yet when they start (or arrive from) the IdP. `/api/sso`
    // (the config GET/PUT, no trailing slash) stays gated.
    matches!(
        path,
        "/api/setup"
            | "/api/login"
            | "/api/logout"
            | "/api/me"
            | "/api/sso/login"
            | "/api/sso/acs"
            | "/api/sso/metadata"
    ) || path.starts_with("/api/queue/join")
        || path.starts_with("/api/queue/poll/")
}

/// Axum middleware: gate `/api/*` behind a valid session, with the
/// allowlist above. `State<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
    State(state): State<AppState>,
    req: Request<Body>,
    next: Next,
) -> Response {
    // Bypass entirely while unconfigured. The /api/setup endpoint is
    // the only one that can flip this back to "configured", and it
    // refuses to run a second time. Tests + fresh installs ride this
    // path.
    if !state.admin.is_configured() {
        return next.run(req).await;
    }
    let path = req.uri().path();
    if is_public_path(path) {
        return next.run(req).await;
    }
    // Authenticated path. The cookie must be present, map to a live
    // session, and the TTL refresh happens as a side-effect.
    let token = parse_cookie(req.headers());
    if let Some(t) = token {
        if state.sessions.touch(&t).is_some() {
            return next.run(req).await;
        }
    }
    (
        StatusCode::UNAUTHORIZED,
        Json(json!({ "error": "authentication required" })),
    )
        .into_response()
}

// ── Handlers ──────────────────────────────────────────────────────────────

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

/// First-run setup. Refuses to run once an admin already exists — that
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
    if state.admin.is_configured() {
        return (
            StatusCode::CONFLICT,
            Json(json!({ "error": "admin account already configured" })),
        )
            .into_response();
    }
    match state.admin.bootstrap(&body.username, &body.password) {
        Ok(pub_) => {
            let session = state.sessions.create(&pub_.username);
            login_response(StatusCode::CREATED, &pub_, &session)
        }
        Err(openpxe_core::Error::Invalid(msg)) => {
            (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({ "error": format!("{e}") })),
        )
            .into_response(),
    }
}

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

pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
    // Brief, deliberately vague — "invalid credentials" rather than
    // "no such user" / "wrong password". Same anti-enumeration posture
    // as Sonarr/Radarr.
    let pub_ = match state.admin.verify(&body.username, &body.password) {
        Ok(Some(u)) => u,
        Ok(None) => {
            return (
                StatusCode::UNAUTHORIZED,
                Json(json!({ "error": "invalid username or password" })),
            )
                .into_response();
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({ "error": format!("{e}") })),
            )
                .into_response();
        }
    };
    let session = state.sessions.create(&pub_.username);
    login_response(StatusCode::OK, &pub_, &session)
}

pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
    if let Some(t) = parse_cookie(&headers) {
        state.sessions.revoke(&t);
    }
    // Stomp the cookie unconditionally — even if the request didn't
    // carry one, the browser shouldn't keep a stale value.
    let mut resp = StatusCode::NO_CONTENT.into_response();
    resp.headers_mut().insert(
        header::SET_COOKIE,
        HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
    );
    resp
}

/// Status surface for the front-end shell. Returns four cases:
///
/// * `setup_required: true` — no admin yet; show first-run page.
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
    // v0.5.0: include branding bootstrap so the pre-auth login/setup
    // screens can render the FleetDM-style full-width custom logo (and
    // cache-bust it) without an extra round trip. `/api/me` is public,
    // and the logo asset is public, so this leaks nothing sensitive.
    let has_custom_logo = state.branding.has_any_web_logo();
    let logo_rev = state.branding.logo_rev();
    if !state.admin.is_configured() {
        return (
            StatusCode::OK,
            Json(json!({
                "setup_required": true,
                "authenticated": false,
                "has_custom_logo": has_custom_logo,
                "logo_rev": logo_rev,
            })),
        )
            .into_response();
    }
    let token = parse_cookie(&headers);
    let username = token.as_deref().and_then(|t| state.sessions.touch(t));
    match username {
        Some(u) => (
            StatusCode::OK,
            Json(json!({
                "setup_required": false,
                "authenticated": true,
                "user": state.admin.snapshot(),
                "session_user": u,
                "has_custom_logo": has_custom_logo,
                "logo_rev": logo_rev,
            })),
        )
            .into_response(),
        None => (
            StatusCode::OK,
            Json(json!({
                "setup_required": false,
                "authenticated": false,
                "has_custom_logo": has_custom_logo,
                "logo_rev": logo_rev,
            })),
        )
            .into_response(),
    }
}

#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
    pub current_password: String,
    #[serde(default)]
    pub new_username: Option<String>,
    #[serde(default)]
    pub new_password: Option<String>,
}

/// Rotate the admin's username and/or password. Auth middleware has
/// already proved the caller owns a session; we additionally require
/// the *current* password to prove "person at the keyboard right now".
/// On success we issue a fresh session cookie keyed to the (possibly
/// new) username and revoke every prior session so a stolen cookie
/// from before the rotation stops working.
pub async fn api_update_credentials(
    State(state): State<AppState>,
    Json(body): Json<UpdateCredentialsBody>,
) -> Response {
    if !state.admin.is_configured() {
        return (
            StatusCode::CONFLICT,
            Json(json!({ "error": "no admin configured" })),
        )
            .into_response();
    }
    let result = state.admin.update_credentials(
        &body.current_password,
        body.new_username.as_deref(),
        body.new_password.as_deref(),
    );
    match result {
        Ok(pub_) => {
            state.sessions.revoke_all();
            let session = state.sessions.create(&pub_.username);
            login_response(StatusCode::OK, &pub_, &session)
        }
        Err(openpxe_core::Error::Invalid(msg)) => {
            (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(json!({ "error": format!("{e}") })),
        )
            .into_response(),
    }
}

#[derive(Debug, Serialize)]
struct LoginPayload<'a> {
    user: &'a AdminPublic,
    authenticated: bool,
}

fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
    let body = Json(LoginPayload {
        user,
        authenticated: true,
    });
    let mut resp = (status, body).into_response();
    resp.headers_mut().insert(
        header::SET_COOKIE,
        HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
    );
    resp
}

// ── Tests ─────────────────────────────────────────────────────────────────

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

    #[test]
    fn session_create_touch_revoke() {
        let s = SessionStore::default();
        assert!(s.is_empty());
        let t = s.create("admin");
        assert_eq!(s.len(), 1);
        assert_eq!(s.touch(&t).as_deref(), Some("admin"));
        s.revoke(&t);
        assert!(s.is_empty());
        // Stale token doesn't error, just returns None.
        assert!(s.touch(&t).is_none());
    }

    #[test]
    fn session_revoke_all_clears() {
        let s = SessionStore::default();
        let _ = s.create("a");
        let _ = s.create("b");
        assert_eq!(s.len(), 2);
        s.revoke_all();
        assert!(s.is_empty());
    }

    #[test]
    fn public_path_allowlist() {
        // PXE + chrome paths bypass auth.
        for p in [
            "/",
            "/assets/app.js",
            "/boot.ipxe",
            "/boot/fake.ipxe",
            "/iso/fake.iso",
            "/ipxe/snponly.efi",
            "/healthz",
            "/readyz",
            "/metrics",
            // v0.4.6: iPXE fetches this for `console --picture` before
            // it can possibly have a session cookie.
            "/branding/pxe-logo",
        ] {
            assert!(is_public_path(p), "expected {p} to be public");
        }
        // Auth surface itself is public.
        for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
            assert!(is_public_path(p), "expected {p} to be public");
        }
        // v0.5.1: SAML SP endpoints are pre-auth (no session yet).
        for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
            assert!(is_public_path(p), "expected {p} to be public");
        }
        // iPXE long-poll endpoints are public (no cookie available).
        assert!(is_public_path("/api/queue/join"));
        assert!(is_public_path("/api/queue/poll/abc"));
        // Everything else under /api/* must auth.
        for p in [
            "/api/isos",
            "/api/isos/x/category",
            "/api/storage/disk",
            "/api/branding/logo",
            "/api/sso",
            "/api/hosts",
        ] {
            assert!(!is_public_path(p), "expected {p} to require auth");
        }
    }

    #[test]
    fn cookie_parse_picks_session_value() {
        let mut h = axum::http::HeaderMap::new();
        h.insert(
            header::COOKIE,
            HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
        );
        assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
        // Different name → None.
        let mut h2 = axum::http::HeaderMap::new();
        h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
        assert!(parse_cookie(&h2).is_none());
        // No cookie header at all → None.
        assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
    }
}
