//! OIDC login entry points for the admin dashboard.
//!
//! Two unauthenticated GET endpoints used by `admin_ui/login.html`:
//!
//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so
//!   the login page can render a button per provider.
//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as
//!   admin-flow (via the sentinel below) and 302-redirects the browser to
//!   the IdP authorization URL. After the IdP redirects to
//!   `/oidc/callback`, the existing callback handler detects the sentinel
//!   and finishes by setting `rd_admin_session` + redirecting to `/admin/`
//!   (see api/oidc/callback.rs).
//!
//! We keep this module separate from the desktop-client OIDC flow so the
//! "device polls /api/oidc/auth-query" semantics stay untouched.

use crate::api::error::ApiError;
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
use crate::api::state::AppState;
use crate::database::OidcSessionInsert;
use axum::extract::{Extension, Path};
use axum::response::Redirect;
use axum::Json;
use serde_json::{json, Value};
use std::sync::Arc;

/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession
/// so the callback can tell admin-UI flows apart from desktop-client flows.
/// Real device UUIDs from the desktop client are hex-formatted GUIDs and
/// won't collide.
pub const ADMIN_SENTINEL: &str = "__admin_ui__";

pub async fn list_providers(
    Extension(state): Extension<Arc<AppState>>,
) -> Json<Value> {
    let mut out: Vec<Value> = Vec::new();
    if !state.cfg.public_base_url.is_empty() {
        if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
            for p in providers {
                out.push(json!({
                    "name": p.name,
                    "display_name": p.display_name.unwrap_or_else(|| p.name.clone()),
                    "icon_url": p.icon_url,
                }));
            }
        }
    }
    Json(json!(out))
}

pub async fn start_login(
    Extension(state): Extension<Arc<AppState>>,
    Path(provider_name): Path<String>,
) -> Result<Redirect, ApiError> {
    if state.cfg.public_base_url.is_empty() {
        return Err(ApiError::Internal(
            "OIDC requires --public-base-url to be set".into(),
        ));
    }
    let provider = require_provider(&state, &provider_name).await?;
    let disc = discovery::discover(&provider.issuer_url)
        .await
        .map_err(ApiError::Internal)?;

    let code = random_token();
    let csrf_state = random_token();
    let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
    state
        .db
        .oidc_session_create(&OidcSessionInsert {
            code: &code,
            provider: &provider.name,
            state: &csrf_state,
            client_id_str: ADMIN_SENTINEL,
            client_uuid: ADMIN_SENTINEL,
            device_info_json: r#"{"source":"admin-ui"}"#,
            expires_at,
        })
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;

    let url = format!(
        "{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}",
        auth = disc.authorization_endpoint,
        cid = url_encode(&provider.client_id),
        ru = url_encode(&provider.redirect_url),
        scope = url_encode(&provider.scopes),
        st = url_encode(&csrf_state),
    );
    Ok(Redirect::temporary(&url))
}

fn url_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(*b as char);
            }
            _ => {
                use std::fmt::Write;
                let _ = write!(out, "%{:02X}", b);
            }
        }
    }
    out
}
