//! `POST /api/2fa/enroll` — admin-only TOTP enrollment.
//!
//! Generates a fresh 20-byte (160-bit) base32 secret, stores it for the
//! target user, and returns:
//!   - `secret_b32` — the literal secret to enter into an authenticator app.
//!   - `otpauth_url` — the standard `otpauth://totp/...` URL the same apps
//!     accept as a QR-code or pasted-string.
//!
//! There is no client-facing UI for this in the desktop app; operators run it
//! by curl after creating the user. M4's `--bootstrap-admin-username` admin
//! is the natural caller.

use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use totp_rs::Secret;

#[derive(Debug, Deserialize)]
pub struct EnrollBody {
    /// Either `user_id` or `username` is required. `user_id` wins if both
    /// are present.
    #[serde(default)]
    pub user_id: Option<i64>,
    #[serde(default)]
    pub username: Option<String>,
    /// Issuer name shown in the authenticator app. Defaults to "RustDesk".
    #[serde(default)]
    pub issuer: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UnenrollBody {
    #[serde(default)]
    pub user_id: Option<i64>,
    #[serde(default)]
    pub username: Option<String>,
}

pub async fn enroll(
    Extension(state): Extension<Arc<AppState>>,
    caller: AuthedUser,
    Json(body): Json<EnrollBody>,
) -> Result<Json<Value>, ApiError> {
    if !caller.is_admin {
        return Err(ApiError::Forbidden("admin required".into()));
    }
    let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;

    // 20 random bytes -> base32 (the standard size for SHA1 TOTP).
    let raw = sodiumoxide::randombytes::randombytes(20);
    let secret_b32 = Secret::Raw(raw.clone()).to_encoded().to_string();

    state
        .db
        .totp_enroll(user.id, &secret_b32)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;

    let issuer = body
        .issuer
        .as_deref()
        .filter(|s| !s.is_empty())
        .unwrap_or("RustDesk");
    // Build the otpauth:// URL manually rather than depend on totp-rs's
    // URL helpers (their API has shifted between minor versions). Format
    // per https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
    let otpauth_url = format!(
        "otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
        issuer = url_encode(issuer),
        account = url_encode(&user.username),
        secret = url_encode(&secret_b32),
    );

    Ok(Json(json!({
        "user_id":     user.id,
        "username":    user.username,
        "secret_b32":  secret_b32,
        "otpauth_url": otpauth_url,
    })))
}

pub async fn unenroll(
    Extension(state): Extension<Arc<AppState>>,
    caller: AuthedUser,
    Json(body): Json<UnenrollBody>,
) -> Result<Json<Value>, ApiError> {
    if !caller.is_admin {
        return Err(ApiError::Forbidden("admin required".into()));
    }
    let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;
    let removed = state
        .db
        .totp_unenroll(user.id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    Ok(Json(json!({ "removed": removed })))
}

/// Minimal percent-encoder for the otpauth URL fields. Encodes anything
/// outside the unreserved URL set (`A-Za-z0-9-_.~`) — keeps the URL short
/// and avoids pulling in `urlencoding` just for this single call site.
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
}

async fn resolve_target(
    state: &AppState,
    user_id: Option<i64>,
    username: Option<&str>,
) -> Result<crate::database::UserRow, ApiError> {
    if let Some(id) = user_id {
        return state
            .db
            .user_find_by_id(id)
            .await
            .map_err(|e| ApiError::Internal(e.to_string()))?
            .ok_or(ApiError::NotFound);
    }
    if let Some(name) = username.filter(|s| !s.is_empty()) {
        return state
            .db
            .user_find_by_username(name)
            .await
            .map_err(|e| ApiError::Internal(e.to_string()))?
            .ok_or(ApiError::NotFound);
    }
    Err(ApiError::BadRequest(
        "user_id or username required".into(),
    ))
}
