//! Users page — list / create / set-password / toggle-admin / toggle-status
//! / TOTP enroll-unenroll / delete.

use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::users::hash_password;
use crate::database::{NewUser, UserRow};
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
use totp_rs::Secret;

const PAGE_SIZE: i64 = 50;

// ---------- index page ----------

pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    Ok(Html(render_full_page(&state).await?))
}

// ---------- create ----------

#[derive(Debug, Deserialize)]
pub struct CreateForm {
    pub username: String,
    #[serde(default)]
    pub display_name: String,
    #[serde(default)]
    pub email: String,
    pub password: String,
    /// Checkbox: "on" if checked, absent otherwise.
    #[serde(default)]
    pub is_admin: Option<String>,
}

pub async fn create(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    if form.username.trim().is_empty() {
        return notice_then_table(&state, "error", "Username required").await;
    }
    if form.password.len() < 4 {
        return notice_then_table(
            &state,
            "error",
            "Password must be at least 4 characters",
        )
        .await;
    }
    let hash = hash_password(form.password)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let id = state
        .db
        .user_insert(NewUser {
            username: form.username.trim(),
            password_hash: &hash,
            display_name: form.display_name.trim(),
            is_admin: form.is_admin.as_deref() == Some("on"),
        })
        .await
        .map_err(|e| ApiError::Internal(format!("user_insert: {}", e)))?;
    if !form.email.trim().is_empty() {
        let _ = set_email_inline(&state, id, form.email.trim()).await;
    }
    notice_then_table(&state, "ok", &format!("Created user '{}'.", form.username)).await
}

async fn set_email_inline(
    state: &Arc<AppState>,
    user_id: i64,
    email: &str,
) -> Result<(), String> {
    state
        .db
        .raw_update_user_email(user_id, email)
        .await
        .map_err(|e| e.to_string())
}

// ---------- per-row actions ----------

#[derive(Debug, Deserialize)]
pub struct UpdateInfoForm {
    #[serde(default)]
    pub display_name: String,
    #[serde(default)]
    pub email: String,
}

pub async fn update_info(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
    Form(form): Form<UpdateInfoForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    state
        .db
        .user_set_display_name(id, form.display_name.trim())
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    state
        .db
        .raw_update_user_email(id, form.email.trim())
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(&state, "ok", "Profile updated.").await
}

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

pub async fn reset_password(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
    Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    // Server-side guard: even though the UI hides the form for OIDC
    // accounts, refuse to set a local password on them. Letting a local
    // password slip in would silently re-enable password sign-in and
    // bypass any MFA the IdP enforces.
    let target = state
        .db
        .user_find_by_id(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or(ApiError::NotFound)?;
    if target.is_oidc_linked() {
        return notice_then_table(
            &state,
            "error",
            "This account is linked to OIDC — set the password at the identity provider instead.",
        )
        .await;
    }
    if form.password.len() < 4 {
        return notice_then_table(
            &state,
            "error",
            "Password must be at least 4 characters",
        )
        .await;
    }
    let hash = hash_password(form.password)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let ok = state
        .db
        .user_set_password(id, &hash)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        if ok { "ok" } else { "error" },
        if ok { "Password updated." } else { "User not found." },
    )
    .await
}

pub async fn toggle_admin(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    if id == admin.user_id {
        return notice_then_table(
            &state,
            "error",
            "You can't revoke your own admin flag here. Edit another admin's row instead.",
        )
        .await;
    }
    let user = state
        .db
        .user_find_by_id(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or(ApiError::NotFound)?;
    state
        .db
        .user_set_admin(id, !user.is_admin)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    Ok(Html(render_table(&state).await?))
}

pub async fn toggle_status(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    if id == admin.user_id {
        return notice_then_table(
            &state,
            "error",
            "You can't disable your own account from here.",
        )
        .await;
    }
    let user = state
        .db
        .user_find_by_id(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or(ApiError::NotFound)?;
    let new_status: i64 = if user.status == 1 { 0 } else { 1 };
    state
        .db
        .user_set_status(id, new_status)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    Ok(Html(render_table(&state).await?))
}

pub async fn delete(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    if id == admin.user_id {
        return notice_then_table(
            &state,
            "error",
            "You can't delete the account you're signed in with.",
        )
        .await;
    }
    let ok = state
        .db
        .user_delete(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        if ok { "ok" } else { "error" },
        if ok { "User deleted." } else { "Already gone." },
    )
    .await
}

// ---------- TOTP ----------

pub async fn totp_enroll(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let user = state
        .db
        .user_find_by_id(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or(ApiError::NotFound)?;
    let raw = sodiumoxide::randombytes::randombytes(20);
    let secret_b32 = Secret::Raw(raw).to_encoded().to_string();
    state
        .db
        .totp_enroll(id, &secret_b32)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let issuer = "RustDesk";
    let otpauth = 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),
    );
    let mut html = format!(
        r##"<div class="rounded border border-emerald-700/50 bg-emerald-900/30 p-4 mb-4 text-sm">
  <div class="font-semibold text-emerald-300 mb-1">TOTP enrolled for {user}</div>
  <div class="space-y-1">
    <div><span class="text-slate-400">Secret (base32):</span> <code class="text-emerald-200">{secret}</code></div>
    <div><span class="text-slate-400">otpauth URL:</span> <code class="text-emerald-200 break-all">{otpauth}</code></div>
    <div class="text-xs text-slate-400 pt-1">
      Show this once to the user (or scan the URL as a QR code) — it isn't displayed again.
    </div>
  </div>
</div>"##,
        user = html_escape(&user.username),
        secret = html_escape(&secret_b32),
        otpauth = html_escape(&otpauth),
    );
    html.push_str(&render_table(&state).await?);
    Ok(Html(html))
}

pub async fn totp_unenroll(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let removed = state
        .db
        .totp_unenroll(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        if removed { "ok" } else { "error" },
        if removed { "TOTP removed." } else { "User had no TOTP." },
    )
    .await
}

// ---------- rendering helpers ----------

async fn notice_then_table(
    state: &Arc<AppState>,
    kind: &str,
    msg: &str,
) -> Result<Html<String>, ApiError> {
    let mut html = notice_html(kind, msg);
    html.push_str(&render_table(state).await?);
    Ok(Html(html))
}

async fn render_full_page(state: &Arc<AppState>) -> Result<String, ApiError> {
    let table = render_table(state).await?;
    Ok(format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">Users</h2>
  </header>

  <section class="rounded-md border border-slate-800 bg-slate-900 p-4">
    <h3 class="text-sm font-semibold text-slate-300 mb-3">Create user</h3>
    <form
      class="grid grid-cols-1 sm:grid-cols-6 gap-3 text-sm"
      hx-post="/admin/pages/users/create"
      hx-target="#users-region"
      hx-swap="innerHTML"
      hx-on::after-request="if (event.detail.successful) this.reset()"
    >
      <input name="username" placeholder="username" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      <input name="display_name" placeholder="display name" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      <input name="email" type="email" placeholder="email (optional)" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 col-span-2"/>
      <input name="password" type="password" placeholder="password" required class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      <label class="flex items-center gap-2 text-slate-400 px-2"><input type="checkbox" name="is_admin"> admin</label>
      <button type="submit" class="sm:col-span-6 bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">Create</button>
    </form>
  </section>

  <section id="users-region">
    {table}
  </section>
</div>"##
    ))
}

async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
    let (_total, users) = state
        .db
        .users_list_all(0, PAGE_SIZE)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    // One small query per row for the TOTP-enrolled flag — N is small.
    let mut totp = std::collections::HashMap::new();
    for u in &users {
        if let Ok(b) = state.db.user_has_totp(u.id).await {
            totp.insert(u.id, b);
        }
    }
    // Single GROUP BY query for the whole "last seen" column, derived
    // from MAX(tokens.last_used_at) per user.
    let last_seen = state
        .db
        .users_last_seen_map()
        .await
        .unwrap_or_default();
    let mut s = String::new();
    // No `overflow-hidden` on the table wrapper: the per-row action menu is
    // an absolutely-positioned `<details>` popover inside a <td>, and the
    // wrapper's clipping was hiding the bottom half of the menu.
    s.push_str(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
  <thead class="text-xs uppercase text-slate-500 bg-slate-950">
    <tr>
      <th class="text-left font-medium px-3 py-2">Username</th>
      <th class="text-left font-medium px-3 py-2">Display name</th>
      <th class="text-left font-medium px-3 py-2">Email</th>
      <th class="text-left font-medium px-3 py-2">Status</th>
      <th class="text-left font-medium px-3 py-2">Admin</th>
      <th class="text-left font-medium px-3 py-2">TOTP</th>
      <th class="text-left font-medium px-3 py-2">Last seen</th>
      <th class="text-right font-medium px-3 py-2 w-1">Actions</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-800">"##,
    );
    for u in &users {
        render_user_row(
            &mut s,
            u,
            *totp.get(&u.id).unwrap_or(&false),
            last_seen.get(&u.id).map(String::as_str),
        );
    }
    s.push_str("  </tbody>\n</table></div>");
    Ok(s)
}

fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) {
    let status_badge = match u.status {
        1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#,
        0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#,
        -1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-amber-900/50 border border-amber-700/50 text-amber-300 text-xs">unverified</span>"#,
        _ => "",
    };
    let admin_badge = if u.is_admin {
        r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-sky-900/50 border border-sky-700/50 text-sky-300 text-xs">admin</span>"#
    } else {
        ""
    };
    // The TOTP column doubles as an "auth path" indicator: OIDC-linked
    // users get an "OIDC" badge (their MFA lives at the IdP — local
    // TOTP is moot), and OIDC takes precedence over local TOTP if both
    // somehow exist.
    let totp_badge = if u.is_oidc_linked() {
        r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-cyan-900/50 border border-cyan-700/50 text-cyan-300 text-xs">OIDC</span>"#
    } else if has_totp {
        r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"#
    } else {
        ""
    };
    let oidc_linked = u.is_oidc_linked();
    // OIDC-linked users sign in via the IdP — adding a local password
    // would let them bypass the IdP (and any MFA enforced there). Show
    // a note instead of the password-reset form for these accounts.
    let password_form = if oidc_linked {
        r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
          Linked to OIDC — password sign-in is disabled.
        </div>"##
            .to_string()
    } else {
        format!(
            r##"<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
          <input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
          <button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
        </form>"##,
            id = u.id,
        )
    };
    let (last_seen_rel, last_seen_abs) = match last_seen {
        Some(ts) => (relative_ts(ts), html_escape(ts)),
        None => ("never".to_string(), String::new()),
    };
    // TOTP enrollment is self-service (the user does it on their
    // profile page so they can scan the QR + verify a code before
    // we store the secret). Admin-side action is reset/disable only,
    // and only relevant when the user has it enrolled.
    let totp_button = if has_totp {
        format!(
            r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/users/{id}/totp-unenroll" hx-target="#users-region" hx-swap="innerHTML"
          hx-confirm="Disable TOTP for {username}? They'll be able to sign in without a 6-digit code until they re-enroll.">
          Disable TOTP
        </button>"##,
            id = u.id,
            username = html_escape(&u.username),
        )
    } else {
        String::new()
    };
    let _ = write!(
        s,
        r##"<tr class="hover:bg-slate-800/40">
  <td class="px-3 py-2 font-medium text-slate-200">{username}</td>
  <td class="px-3 py-2 text-slate-300">{display_name}</td>
  <td class="px-3 py-2 text-slate-400">{email}</td>
  <td class="px-3 py-2">{status}</td>
  <td class="px-3 py-2">{admin}</td>
  <td class="px-3 py-2">{totp}</td>
  <td class="px-3 py-2 text-slate-400 whitespace-nowrap" title="{last_seen_abs}">{last_seen_rel}</td>
  <td class="px-3 py-2">
    <details class="text-right relative">
      <summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
      <div class="absolute right-2 mt-1 z-10 w-64 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
        <form class="space-y-1" hx-post="/admin/pages/users/{id}/update-info" hx-target="#users-region" hx-swap="innerHTML">
          <input name="display_name" value="{display_name}" placeholder="display name" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
          <input name="email" type="email" value="{email}" placeholder="email" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
          <button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Save profile</button>
        </form>
        {password_form}
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML">
          {admin_label}
        </button>
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML">
          {status_label}
        </button>
        {totp_button}
        <button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded"
          hx-post="/admin/pages/users/{id}/delete"
          hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares."
          hx-target="#users-region" hx-swap="innerHTML">
          Delete user
        </button>
      </div>
    </details>
  </td>
</tr>"##,
        id = u.id,
        username = html_escape(&u.username),
        display_name = html_escape(&u.display_name),
        email = html_escape(&u.email),
        status = status_badge,
        admin = admin_badge,
        totp = totp_badge,
        admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" },
        status_label = if u.status == 1 { "Disable user" } else { "Enable user" },
        totp_button = totp_button,
        last_seen_rel = last_seen_rel,
        last_seen_abs = last_seen_abs,
        password_form = password_form,
    );
}

/// Format a SQLite `current_timestamp` string ("YYYY-MM-DD HH:MM:SS",
/// always UTC) as a relative time-ago label. Renders short forms — "5m
/// ago", "3h ago", "2d ago" — for the at-a-glance column; the absolute
/// timestamp goes into the cell's `title=` for hover.
fn relative_ts(ts: &str) -> String {
    let parsed = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S")
        .map(|t| t.and_utc())
        .ok();
    let Some(t) = parsed else {
        return ts.to_string();
    };
    let now = chrono::Utc::now();
    let secs = (now - t).num_seconds();
    if secs < 0 {
        return "just now".to_string();
    }
    if secs < 60 {
        return "just now".to_string();
    }
    let mins = secs / 60;
    if mins < 60 {
        return format!("{}m ago", mins);
    }
    let hours = mins / 60;
    if hours < 24 {
        return format!("{}h ago", hours);
    }
    let days = hours / 24;
    if days < 30 {
        return format!("{}d ago", days);
    }
    let months = days / 30;
    if months < 12 {
        return format!("{}mo ago", months);
    }
    format!("{}y ago", months / 12)
}

fn notice_html(kind: &str, msg: &str) -> String {
    let (border, bg, text) = match kind {
        "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
        _ => ("rose-700/50", "rose-900/30", "rose-300"),
    };
    format!(
        r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
        border = border,
        bg = bg,
        text = text,
        msg = html_escape(msg),
    )
}

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

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);
            }
            _ => {
                let _ = write!(out, "%{:02X}", b);
            }
        }
    }
    out
}

fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
    if u.is_admin {
        Ok(())
    } else {
        Err(ApiError::Forbidden("admin required".into()))
    }
}
