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

use crate::api::admin::i18n::{t, tf1, tf3, Lang};
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, Query};
use axum::http::{HeaderMap, HeaderValue};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::sync::Arc;
use totp_rs::Secret;

// ---------- pagination ----------

const PAGE_SIZE_DEFAULT: i64 = 100;
/// Allowed page sizes shown in the "Per page" selector. Anything else
/// sent by the client is rejected on the server so we don't end up
/// loading 1M-row pages by accident.
const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500];
const USER_PAGE_SIZE_KEY: &str = "users.page_size";

#[derive(Debug, Deserialize, Default)]
pub struct UsersListParams {
    #[serde(default)]
    pub page: Option<i64>,
    #[serde(default)]
    pub q: Option<String>,
}

impl UsersListParams {
    /// 1-indexed page number with sensible bounds.
    fn page_or_first(&self) -> i64 {
        self.page.unwrap_or(1).max(1)
    }

    /// Trimmed search query (empty when no filter is active).
    fn query(&self) -> &str {
        match &self.q {
            Some(s) => s.trim(),
            None => "",
        }
    }
}

/// Load the user's saved page size, clamped to one of `PAGE_SIZE_OPTIONS`.
async fn load_user_page_size(state: &Arc<AppState>, user_id: i64) -> i64 {
    let raw = state
        .db
        .user_pref_get(user_id, USER_PAGE_SIZE_KEY)
        .await
        .ok()
        .flatten()
        .and_then(|s| s.parse::<i64>().ok());
    match raw {
        Some(v) if PAGE_SIZE_OPTIONS.contains(&v) => v,
        _ => PAGE_SIZE_DEFAULT,
    }
}

// ---------- column visibility prefs ----------

/// Per-user column visibility for the users table. Stored as a
/// comma-separated list of *hidden* column ids in the `user_prefs` table
/// under key `USER_COLS_HIDE_KEY`. Default (no row) = everything visible.
/// `username` and the actions menu aren't listed because they're not
/// user-toggleable.
#[derive(Clone, Copy, Debug)]
pub struct UserColumns {
    pub display_name: bool,
    pub email: bool,
    pub status: bool,
    pub admin: bool,
    pub totp: bool,
    pub last_seen: bool,
}

impl Default for UserColumns {
    fn default() -> Self {
        Self {
            display_name: true,
            email: true,
            status: true,
            admin: true,
            totp: true,
            last_seen: true,
        }
    }
}

const USER_COLS_HIDE_KEY: &str = "users.cols_hide";

impl UserColumns {
    fn from_hidden_csv(raw: &str) -> Self {
        let hidden: HashSet<&str> = raw.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
        Self {
            display_name: !hidden.contains("display_name"),
            email: !hidden.contains("email"),
            status: !hidden.contains("status"),
            admin: !hidden.contains("admin"),
            totp: !hidden.contains("totp"),
            last_seen: !hidden.contains("last_seen"),
        }
    }

    fn to_hidden_csv(self) -> String {
        let mut hidden: Vec<&str> = Vec::new();
        if !self.display_name {
            hidden.push("display_name");
        }
        if !self.email {
            hidden.push("email");
        }
        if !self.status {
            hidden.push("status");
        }
        if !self.admin {
            hidden.push("admin");
        }
        if !self.totp {
            hidden.push("totp");
        }
        if !self.last_seen {
            hidden.push("last_seen");
        }
        hidden.join(",")
    }
}

/// Load the user's saved column visibility. Falls back to "all visible"
/// on any DB error so a transient hiccup doesn't blank the page.
async fn load_user_columns(state: &Arc<AppState>, user_id: i64) -> UserColumns {
    match state.db.user_pref_get(user_id, USER_COLS_HIDE_KEY).await {
        Ok(Some(raw)) => UserColumns::from_hidden_csv(&raw),
        _ => UserColumns::default(),
    }
}

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

pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    Ok(Html(
        render_full_page(&state, lang, admin.user_id, pg.page_or_first(), pg.query()).await?,
    ))
}

/// Fragment endpoint for the `#users-region` block: re-renders just the
/// table + pagination footer. Used by the pagination buttons and the
/// search input so the page header (and the open "Columns" popover)
/// stays as-is.
pub async fn table_fragment(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    Ok(Html(
        render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?,
    ))
}

#[derive(Debug, Deserialize)]
pub struct ColumnsForm {
    pub col: String,
    pub visible: String,
    /// Search query in effect when the user toggled — preserved so the
    /// re-rendered table still shows the filtered set.
    #[serde(default)]
    pub q: Option<String>,
}

/// Toggle a single column's visibility, persist the new prefs, and
/// return the updated table. The "Columns" popover in the page header
/// drives this — see `usersColumnToggle` in admin_ui/index.html.
pub async fn set_columns(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Form(form): Form<ColumnsForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let mut cols = load_user_columns(&state, admin.user_id).await;
    let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true");
    match form.col.as_str() {
        "display_name" => cols.display_name = visible,
        "email" => cols.email = visible,
        "status" => cols.status = visible,
        "admin" => cols.admin = visible,
        "totp" => cols.totp = visible,
        "last_seen" => cols.last_seen = visible,
        // Silently ignore unknown column ids — older clients shouldn't
        // be able to write garbage into the prefs row.
        _ => {}
    }
    state
        .db
        .user_pref_set(admin.user_id, USER_COLS_HIDE_KEY, &cols.to_hidden_csv())
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let q = form.q.as_deref().unwrap_or("").trim();
    Ok(Html(
        render_table(&state, lang, cols, q, 1, page_size).await?,
    ))
}

#[derive(Debug, Deserialize)]
pub struct PageSizeForm {
    pub size: i64,
    #[serde(default)]
    pub q: Option<String>,
}

/// Persist the page-size choice and re-render the table at page 1.
/// Changing the size shifts which rows belong to which page, so jumping
/// back to page 1 is the predictable outcome.
pub async fn set_page_size(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Form(form): Form<PageSizeForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    if !PAGE_SIZE_OPTIONS.contains(&form.size) {
        return Err(ApiError::BadRequest("invalid page size".into()));
    }
    state
        .db
        .user_pref_set(
            admin.user_id,
            USER_PAGE_SIZE_KEY,
            &form.size.to_string(),
        )
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let q = form.q.as_deref().unwrap_or("").trim();
    Ok(Html(
        render_table(&state, lang, cols, q, 1, form.size).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 new_form(
    admin: AuthedUser,
    lang: Lang,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    Ok(Html(render_new_user_page(lang, "", "", "", false, None)))
}

pub async fn create(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Form(form): Form<CreateForm>,
) -> Result<Response, ApiError> {
    require_admin(&admin)?;
    let is_admin_checked = form.is_admin.as_deref() == Some("on");
    if form.username.trim().is_empty() {
        return Ok(Html(render_new_user_page(
            lang,
            &form.username,
            &form.display_name,
            &form.email,
            is_admin_checked,
            Some(t(lang, "users.username_required")),
        ))
        .into_response());
    }
    if form.password.len() < 4 {
        return Ok(Html(render_new_user_page(
            lang,
            &form.username,
            &form.display_name,
            &form.email,
            is_admin_checked,
            Some(t(lang, "users.password_min")),
        ))
        .into_response());
    }
    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: is_admin_checked,
        })
        .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;
    }
    // Success: navigate back to the users list with a success notice.
    // HX-Push-Url updates the address bar hash so a reload lands on the
    // list page rather than the now-stale form page. Start the list on
    // page 1 so the freshly-created user is visible (the list is sorted
    // by username, but page 1 is the natural landing spot regardless).
    let notice = notice_html("ok", &tf1(lang, "users.created", &form.username));
    let mut body = notice;
    body.push_str(&render_full_page(&state, lang, admin.user_id, 1, "").await?);
    let mut headers = HeaderMap::new();
    headers.insert("HX-Push-Url", HeaderValue::from_static("#users"));
    Ok((headers, Html(body)).into_response())
}

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,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
    Form(form): Form<UpdateInfoForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    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, lang, cols, q, page, page_size, "ok", t(lang, "users.profile_updated")).await
}

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

pub async fn reset_password(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
    Form(form): Form<PasswordResetForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    // 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, lang, cols, q, page, page_size, "error", t(lang, "users.oidc_password_disabled")).await;
    }
    if form.password.len() < 4 {
        return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.password_min")).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,
        lang,
        cols,
        q,
        page,
        page_size,
        if ok { "ok" } else { "error" },
        if ok { t(lang, "users.password_updated") } else { t(lang, "users.user_not_found") },
    )
    .await
}

pub async fn toggle_admin(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    if id == admin.user_id {
        return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_revoke_self")).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, lang, cols, q, page, page_size).await?))
}

pub async fn toggle_status(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    if id == admin.user_id {
        return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_disable_self")).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, lang, cols, q, page, page_size).await?))
}

pub async fn delete(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    if id == admin.user_id {
        return notice_then_table(&state, lang, cols, q, page, page_size, "error", t(lang, "users.cant_delete_self")).await;
    }
    let ok = state
        .db
        .user_delete(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        lang,
        cols,
        q,
        page,
        page_size,
        if ok { "ok" } else { "error" },
        if ok { t(lang, "users.user_deleted") } else { t(lang, "common.already_gone") },
    )
    .await
}

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

pub async fn totp_enroll(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    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">{enrolled_for}</div>
  <div class="space-y-1">
    <div><span class="text-slate-400">{secret_label}</span> <code class="text-emerald-200">{secret}</code></div>
    <div><span class="text-slate-400">{url_label}</span> <code class="text-emerald-200 break-all">{otpauth}</code></div>
    <div class="text-xs text-slate-400 pt-1">
      {hint}
    </div>
  </div>
</div>"##,
        enrolled_for = html_escape(&tf1(lang, "users.totp_enrolled_for", &user.username)),
        secret_label = t(lang, "users.secret_b32"),
        url_label = t(lang, "users.otpauth_url"),
        secret = html_escape(&secret_b32),
        otpauth = html_escape(&otpauth),
        hint = t(lang, "users.otpauth_hint"),
    );
    html.push_str(&render_table(&state, lang, cols, q, page, page_size).await?);
    Ok(Html(html))
}

pub async fn totp_unenroll(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(id): Path<i64>,
    Query(pg): Query<UsersListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_user_columns(&state, admin.user_id).await;
    let page_size = load_user_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    let removed = state
        .db
        .totp_unenroll(id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        lang,
        cols,
        q,
        page,
        page_size,
        if removed { "ok" } else { "error" },
        if removed { t(lang, "users.totp_removed") } else { t(lang, "users.no_totp") },
    )
    .await
}

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

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

async fn render_full_page(
    state: &Arc<AppState>,
    lang: Lang,
    user_id: i64,
    page: i64,
    q: &str,
) -> Result<String, ApiError> {
    let cols = load_user_columns(state, user_id).await;
    let page_size = load_user_page_size(state, user_id).await;
    let table = render_table(state, lang, cols, q, page, page_size).await?;
    let columns_popover = render_columns_popover(lang, cols);
    Ok(format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between gap-3 flex-wrap">
    <h2 class="text-lg font-semibold">{heading}</h2>
    <div class="flex items-center gap-2 flex-wrap">
      <input
        id="users-search"
        type="search"
        name="q"
        value="{q_value}"
        placeholder="{search_placeholder}"
        autocomplete="off"
        class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm w-56 focus:outline-none focus:border-sky-600"
        hx-get="/admin/pages/users/table-fragment"
        hx-trigger="input changed delay:200ms, search"
        hx-target="#users-region"
        hx-swap="innerHTML"
      />
      {columns_popover}
      <button
        class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-sm font-medium text-white"
        hx-get="/admin/pages/users/new"
        hx-target="#main"
        hx-swap="innerHTML"
        hx-push-url="#users/new"
      >{add_user}</button>
    </div>
  </header>

  <section id="users-region">
    {table}
  </section>
</div>"##,
        heading = t(lang, "users.heading"),
        add_user = t(lang, "users.add_user"),
        columns_popover = columns_popover,
        search_placeholder = t(lang, "users.search_placeholder"),
        q_value = html_escape(q),
        table = table,
    ))
}

fn render_columns_popover(lang: Lang, cols: UserColumns) -> String {
    // Each row maps a column id (sent to /admin/pages/users/columns) to
    // its current visibility + i18n label. The id is what the JS toggle
    // in index.html POSTs; the label is the column header text.
    let items: [(&str, bool, &str); 6] = [
        ("display_name", cols.display_name, t(lang, "users.col_display_name")),
        ("email", cols.email, t(lang, "users.col_email")),
        ("status", cols.status, t(lang, "users.col_status")),
        ("admin", cols.admin, t(lang, "users.col_admin")),
        ("totp", cols.totp, t(lang, "users.col_totp")),
        ("last_seen", cols.last_seen, t(lang, "users.col_last_seen")),
    ];
    let mut checkboxes = String::new();
    for (id, visible, label) in items {
        let checked = if visible { " checked" } else { "" };
        let _ = write!(
            checkboxes,
            r##"<label class="flex items-center gap-2 px-2 py-1 text-xs hover:bg-slate-800 rounded cursor-pointer">
          <input type="checkbox" data-col="{id}"{checked} onchange="usersColumnToggle(this)">
          <span class="text-slate-300">{label}</span>
        </label>"##,
            id = id,
            checked = checked,
            label = html_escape(label),
        );
    }
    format!(
        r##"<details class="relative">
      <summary class="cursor-pointer list-none select-none bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300">
        {columns_label} ▾
      </summary>
      <div class="absolute right-0 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-1 space-y-0.5">
        {checkboxes}
      </div>
    </details>"##,
        columns_label = t(lang, "users.columns"),
        checkboxes = checkboxes,
    )
}

/// Standalone "new user" page rendered into `#main`. Preserves form
/// values and shows an error notice when re-rendered after a validation
/// failure.
fn render_new_user_page(
    lang: Lang,
    username: &str,
    display_name: &str,
    email: &str,
    is_admin_checked: bool,
    error_msg: Option<&str>,
) -> String {
    let error = error_msg.map(|m| notice_html("error", m)).unwrap_or_default();
    let admin_attr = if is_admin_checked { " checked" } else { "" };
    format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">{heading}</h2>
    <button
      class="text-sm text-slate-400 hover:text-slate-200"
      hx-get="/admin/pages/users"
      hx-target="#main"
      hx-swap="innerHTML"
      hx-push-url="#users"
    >{back}</button>
  </header>

  {error}

  <section class="rounded-md border border-slate-800 bg-slate-900 p-4 max-w-2xl">
    <form
      class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
      hx-post="/admin/pages/users/create"
      hx-target="#main"
      hx-swap="innerHTML"
    >
      <label class="flex flex-col gap-1 sm:col-span-2">
        <span class="text-xs uppercase tracking-wide text-slate-500">{username_label}</span>
        <input name="username" value="{username_val}" required autofocus class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      </label>
      <label class="flex flex-col gap-1">
        <span class="text-xs uppercase tracking-wide text-slate-500">{display_name_label}</span>
        <input name="display_name" value="{display_name_val}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      </label>
      <label class="flex flex-col gap-1">
        <span class="text-xs uppercase tracking-wide text-slate-500">{email_label}</span>
        <input name="email" type="email" value="{email_val}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      </label>
      <label class="flex flex-col gap-1 sm:col-span-2">
        <span class="text-xs uppercase tracking-wide text-slate-500">{password_label}</span>
        <input name="password" type="password" required minlength="4" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
      </label>
      <label class="flex items-center gap-2 text-slate-300 sm:col-span-2">
        <input type="checkbox" name="is_admin"{admin_attr}> {admin_label}
      </label>
      <div class="sm:col-span-2 flex justify-end gap-2 pt-1">
        <button
          type="button"
          class="bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded px-3 py-1.5 text-slate-300"
          hx-get="/admin/pages/users"
          hx-target="#main"
          hx-swap="innerHTML"
          hx-push-url="#users"
        >{cancel}</button>
        <button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
      </div>
    </form>
  </section>
</div>"##,
        heading = t(lang, "users.new_user_heading"),
        back = t(lang, "common.back"),
        error = error,
        username_label = t(lang, "users.username"),
        display_name_label = t(lang, "users.display_name"),
        email_label = t(lang, "users.email_optional"),
        password_label = t(lang, "users.password"),
        admin_label = t(lang, "users.admin_label"),
        cancel = t(lang, "common.cancel"),
        create = t(lang, "common.create"),
        username_val = html_escape(username),
        display_name_val = html_escape(display_name),
        email_val = html_escape(email),
        admin_attr = admin_attr,
    )
}

async fn render_table(
    state: &Arc<AppState>,
    lang: Lang,
    cols: UserColumns,
    q: &str,
    page: i64,
    page_size: i64,
) -> Result<String, ApiError> {
    // Probe the filtered total once so we can clamp the requested page
    // before we do the offset query. Saves us from a confusing empty
    // page when the caller asks for page 5 of a 1-page dataset (e.g.
    // after a delete, or because the search now matches fewer rows).
    let (total, _) = state
        .db
        .users_list_filtered(q, 0, 0)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let total_pages = if total == 0 {
        1
    } else {
        (total + page_size - 1) / page_size
    };
    let page = page.clamp(1, total_pages);
    let offset = (page - 1) * page_size;
    let (_, users) = state
        .db
        .users_list_filtered(q, offset, 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.
    let _ = write!(
        s,
        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">{c_username}</th>"##,
        c_username = t(lang, "users.col_username"),
    );
    if cols.display_name {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_display_name"));
    }
    if cols.email {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_email"));
    }
    if cols.status {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_status"));
    }
    if cols.admin {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_admin"));
    }
    if cols.totp {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_totp"));
    }
    if cols.last_seen {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "users.col_last_seen"));
    }
    let _ = write!(
        s,
        r##"
      <th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-800">"##,
        c_actions = t(lang, "common.actions"),
    );
    if users.is_empty() {
        // colspan = visible columns + 1 (actions).
        let visible = 1
            + cols.display_name as i64
            + cols.email as i64
            + cols.status as i64
            + cols.admin as i64
            + cols.totp as i64
            + cols.last_seen as i64
            + 1;
        let empty_msg = if q.is_empty() {
            t(lang, "users.no_users")
        } else {
            t(lang, "users.no_match")
        };
        let _ = write!(
            s,
            r##"<tr><td colspan="{visible}" class="px-3 py-6 text-center text-slate-500">{empty}</td></tr>"##,
            visible = visible,
            empty = empty_msg,
        );
    } else {
        for u in &users {
            render_user_row(
                &mut s,
                lang,
                cols,
                q,
                page,
                u,
                *totp.get(&u.id).unwrap_or(&false),
                last_seen.get(&u.id).map(String::as_str),
            );
        }
    }
    s.push_str("  </tbody>\n</table></div>");
    s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages));
    Ok(s)
}

/// Pagination footer shown beneath the table: "Showing 1–100 of 245",
/// the per-page selector, and prev/next buttons. Always rendered (even
/// for empty/single-page lists) so the admin can change the page size.
fn render_pagination_footer(
    lang: Lang,
    q: &str,
    page: i64,
    page_size: i64,
    total: i64,
    total_pages: i64,
) -> String {
    let from = if total == 0 { 0 } else { (page - 1) * page_size + 1 };
    let to = ((page * page_size).min(total)).max(0);
    let showing = tf3(
        lang,
        "users.showing_range",
        &from.to_string(),
        &to.to_string(),
        &total.to_string(),
    );
    let mut size_options = String::new();
    for &n in PAGE_SIZE_OPTIONS {
        let selected = if n == page_size { " selected" } else { "" };
        let _ = write!(
            size_options,
            r##"<option value="{n}"{selected}>{n}</option>"##,
            n = n,
            selected = selected,
        );
    }
    // Encode the search query into the prev/next URLs so the filter
    // sticks across pagination clicks. An empty `q` is fine to leave
    // out — the param is `#[serde(default)]` on the receiving end.
    let q_param = if q.is_empty() {
        String::new()
    } else {
        format!("&q={}", url_encode(q))
    };
    let prev_disabled = page <= 1;
    let next_disabled = page >= total_pages;
    let prev_attrs = if prev_disabled {
        " disabled".to_string()
    } else {
        format!(
            r##" hx-get="/admin/pages/users/table-fragment?page={p}{q_param}" hx-target="#users-region" hx-swap="innerHTML""##,
            p = page - 1,
            q_param = q_param,
        )
    };
    let next_attrs = if next_disabled {
        " disabled".to_string()
    } else {
        format!(
            r##" hx-get="/admin/pages/users/table-fragment?page={p}{q_param}" hx-target="#users-region" hx-swap="innerHTML""##,
            p = page + 1,
            q_param = q_param,
        )
    };
    let btn_base = "px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700 \
                    disabled:opacity-40 disabled:hover:bg-slate-800 disabled:cursor-not-allowed";
    format!(
        r##"<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400 pt-3 px-1">
  <div class="flex items-center gap-3">
    <span>{showing}</span>
    <label class="flex items-center gap-2">
      <span>{per_page_label}:</span>
      <select onchange="usersPageSize(this.value)" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs">
        {size_options}
      </select>
    </label>
  </div>
  <div class="flex items-center gap-2">
    <button class="{btn}"{prev_attrs}>← {prev}</button>
    <span class="text-slate-500 tabular-nums">{page} / {total_pages}</span>
    <button class="{btn}"{next_attrs}>{next} →</button>
  </div>
</div>"##,
        showing = html_escape(&showing),
        per_page_label = t(lang, "users.per_page"),
        size_options = size_options,
        prev = t(lang, "common.prev"),
        next = t(lang, "common.next"),
        btn = btn_base,
        prev_attrs = prev_attrs,
        next_attrs = next_attrs,
        page = page,
        total_pages = total_pages,
    )
}

fn render_user_row(
    s: &mut String,
    lang: Lang,
    cols: UserColumns,
    q: &str,
    page: i64,
    u: &UserRow,
    has_totp: bool,
    last_seen: Option<&str>,
) {
    let q_param = if q.is_empty() {
        String::new()
    } else {
        format!("&q={}", url_encode(q))
    };
    let status_badge = match u.status {
        1 => format!(
            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">{}</span>"#,
            t(lang, "users.status_active")
        ),
        0 => format!(
            r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">{}</span>"#,
            t(lang, "users.status_disabled")
        ),
        -1 => format!(
            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">{}</span>"#,
            t(lang, "users.status_unverified")
        ),
        _ => String::new(),
    };
    let admin_badge = if u.is_admin {
        format!(
            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">{}</span>"#,
            t(lang, "users.admin_label"),
        )
    } else {
        String::new()
    };
    // 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>"#.to_string()
    } else if has_totp {
        format!(
            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">{}</span>"#,
            t(lang, "users.totp_enrolled")
        )
    } else {
        String::new()
    };
    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 {
        format!(
            r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
          {msg}
        </div>"##,
            msg = t(lang, "users.oidc_disabled"),
        )
    } else {
        format!(
            r##"<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset?page={page}{q_param}" hx-target="#users-region" hx-swap="innerHTML">
          <input name="password" type="password" required minlength="4" placeholder="{ph}" 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,
            page = page,
            q_param = q_param,
            ph = t(lang, "users.new_password"),
            set = t(lang, "users.password_set"),
        )
    };
    let (last_seen_rel, last_seen_abs) = match last_seen {
        Some(ts) => (relative_ts(ts), html_escape(ts)),
        None => (t(lang, "common.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?page={page}{q_param}" hx-target="#users-region" hx-swap="innerHTML"
          hx-confirm="{confirm}">
          {label}
        </button>"##,
            id = u.id,
            page = page,
            q_param = q_param,
            confirm = html_escape_attr(&tf1(lang, "users.confirm_disable_totp", &u.username)),
            label = t(lang, "users.disable_totp"),
        )
    } 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>"##,
        username = html_escape(&u.username),
    );
    if cols.display_name {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-300">{}</td>"##,
            html_escape(&u.display_name),
        );
    }
    if cols.email {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400">{}</td>"##,
            html_escape(&u.email),
        );
    }
    if cols.status {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2">{}</td>"##,
            status_badge,
        );
    }
    if cols.admin {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2">{}</td>"##,
            admin_badge,
        );
    }
    if cols.totp {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2">{}</td>"##,
            totp_badge,
        );
    }
    if cols.last_seen {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400 whitespace-nowrap" title="{abs}">{rel}</td>"##,
            abs = last_seen_abs,
            rel = last_seen_rel,
        );
    }
    let _ = write!(
        s,
        r##"
  <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?page={page}{q_param}" hx-target="#users-region" hx-swap="innerHTML">
          <input name="display_name" value="{display_name}" placeholder="{ph_dn}" 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="{ph_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?page={page}{q_param}" 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?page={page}{q_param}" 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?page={page}{q_param}"
          hx-confirm="{confirm_delete}"
          hx-target="#users-region" hx-swap="innerHTML">
          {delete_user}
        </button>
      </div>
    </details>
  </td>
</tr>"##,
        id = u.id,
        page = page,
        q_param = q_param,
        display_name = html_escape(&u.display_name),
        email = html_escape(&u.email),
        ph_dn = t(lang, "users.display_name"),
        ph_email = t(lang, "users.col_email"),
        save_profile = t(lang, "users.save_profile"),
        admin_label = if u.is_admin {
            t(lang, "users.revoke_admin")
        } else {
            t(lang, "users.grant_admin")
        },
        status_label = if u.status == 1 {
            t(lang, "users.disable_user")
        } else {
            t(lang, "users.enable_user")
        },
        totp_button = totp_button,
        password_form = password_form,
        confirm_delete = html_escape_attr(&tf1(lang, "users.confirm_delete", &u.username)),
        delete_user = t(lang, "users.delete_user"),
    );
}

fn html_escape_attr(s: &str) -> String {
    html_escape(s)
}

/// 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()))
    }
}
