//! Devices page — list devices currently / recently registered, with
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.

use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::{DashboardDeviceRow, LoginEventRow, MetricsSampleRow, PerfEventRow};
use axum::extract::{Extension, Form, Path, Query};
use axum::response::Html;
use serde::Deserialize;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::sync::Arc;

// ---------- pagination / search prefs ----------

const PAGE_SIZE_DEFAULT: i64 = 100;
const PAGE_SIZE_OPTIONS: &[i64] = &[25, 50, 100, 500];
const DEVICE_PAGE_SIZE_KEY: &str = "devices.page_size";
const DEVICE_COLS_HIDE_KEY: &str = "devices.cols_hide";

/// Devices that have heartbeated within this many seconds are considered
/// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL),
/// so 45s allows up to two missed beats before we flip the dot to red.
const ONLINE_THRESHOLD_SECS: i64 = 45;

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

impl DevicesListParams {
    fn page_or_first(&self) -> i64 {
        self.page.unwrap_or(1).max(1)
    }

    fn query(&self) -> &str {
        match &self.q {
            Some(s) => s.trim(),
            None => "",
        }
    }
}

/// Per-user column visibility for the devices table. The peer-id cell
/// and actions menu aren't user-toggleable, so they're absent here.
#[derive(Clone, Copy, Debug)]
pub struct DeviceColumns {
    pub owner: bool,
    pub host: bool,
    pub serial: bool,
    pub user: bool,
    pub unattended_pwd: bool,
    pub os: bool,
    pub version: bool,
    pub last_heartbeat: bool,
    pub conns: bool,
    pub auth: bool,
}

impl Default for DeviceColumns {
    fn default() -> Self {
        Self {
            owner: true,
            host: true,
            serial: true,
            user: true,
            unattended_pwd: true,
            os: true,
            version: true,
            last_heartbeat: true,
            conns: true,
            auth: true,
        }
    }
}

impl DeviceColumns {
    fn from_hidden_csv(raw: &str) -> Self {
        let hidden: HashSet<&str> = raw
            .split(',')
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .collect();
        Self {
            owner: !hidden.contains("owner"),
            host: !hidden.contains("host"),
            serial: !hidden.contains("serial"),
            user: !hidden.contains("user"),
            unattended_pwd: !hidden.contains("unattended_pwd"),
            os: !hidden.contains("os"),
            version: !hidden.contains("version"),
            last_heartbeat: !hidden.contains("last_heartbeat"),
            conns: !hidden.contains("conns"),
            auth: !hidden.contains("auth"),
        }
    }

    fn to_hidden_csv(self) -> String {
        let mut hidden: Vec<&str> = Vec::new();
        if !self.owner { hidden.push("owner"); }
        if !self.host { hidden.push("host"); }
        if !self.serial { hidden.push("serial"); }
        if !self.user { hidden.push("user"); }
        if !self.unattended_pwd { hidden.push("unattended_pwd"); }
        if !self.os { hidden.push("os"); }
        if !self.version { hidden.push("version"); }
        if !self.last_heartbeat { hidden.push("last_heartbeat"); }
        if !self.conns { hidden.push("conns"); }
        if !self.auth { hidden.push("auth"); }
        hidden.join(",")
    }

    fn visible_count(self) -> i64 {
        self.owner as i64
            + self.host as i64
            + self.serial as i64
            + self.user as i64
            + self.unattended_pwd as i64
            + self.os as i64
            + self.version as i64
            + self.last_heartbeat as i64
            + self.conns as i64
            + self.auth as i64
    }
}

async fn load_device_columns(state: &Arc<AppState>, user_id: i64) -> DeviceColumns {
    match state.db.user_pref_get(user_id, DEVICE_COLS_HIDE_KEY).await {
        Ok(Some(raw)) => DeviceColumns::from_hidden_csv(&raw),
        _ => DeviceColumns::default(),
    }
}

async fn load_device_page_size(state: &Arc<AppState>, user_id: i64) -> i64 {
    let raw = state
        .db
        .user_pref_get(user_id, DEVICE_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,
    }
}

pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    let page = pg.page_or_first();
    let q = pg.query();
    let table = render_table(&state, lang, cols, q, page, page_size).await?;
    let columns_popover = render_columns_popover(lang, cols);
    Ok(Html(format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between gap-3 flex-wrap">
    <div>
      <h2 class="text-lg font-semibold">{heading}</h2>
      <p class="text-xs text-slate-500 mt-0.5">{tagline}</p>
    </div>
    <div class="flex items-center gap-2 flex-wrap">
      <input
        id="devices-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/devices/list-fragment"
        hx-trigger="input changed delay:200ms, search"
        hx-target="#devices-region"
        hx-swap="innerHTML"
      />
      {columns_popover}
    </div>
  </header>
  <section id="devices-region">
    {table}
  </section>
</div>"##,
        heading = t(lang, "devices.heading"),
        tagline = t(lang, "devices.tagline"),
        search_placeholder = t(lang, "devices.search_placeholder"),
        q_value = html_escape(q),
        columns_popover = columns_popover,
        table = table,
    )))
}

pub async fn force_disconnect(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    let conns = state
        .db
        .device_sysinfo_get_conns(&peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    state
        .db
        .heartbeat_command_queue(&peer_id, "disconnect", Some(&conns))
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        lang,
        cols,
        pg.query(),
        pg.page_or_first(),
        page_size,
        "ok",
        &tf2(lang, "devices.queued_disconnect", &peer_id, &conns),
    )
    .await
}

pub async fn force_sysinfo(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    state
        .db
        .heartbeat_command_queue(&peer_id, "sysinfo", None)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        lang,
        cols,
        pg.query(),
        pg.page_or_first(),
        page_size,
        "ok",
        &tf1(lang, "devices.queued_sysinfo", &peer_id),
    )
    .await
}

pub async fn delete(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    let ok = state
        .db
        .device_delete(&peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let msg = if ok {
        tf1(lang, "devices.deleted", &peer_id)
    } else {
        t(lang, "devices.already_gone").to_string()
    };
    notice_then_table(
        &state,
        lang,
        cols,
        pg.query(),
        pg.page_or_first(),
        page_size,
        if ok { "ok" } else { "error" },
        &msg,
    )
    .await
}

/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON
/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the
/// table refreshes in place. The handler reads the current value, flips
/// it, and writes back — this avoids a stale-toggle race where the row
/// the admin clicked on showed a stale state (e.g. TOFU just promoted it
/// in the background) and a "set to N" command would no-op silently.
pub async fn toggle_managed(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    let q = pg.query();
    let page = pg.page_or_first();
    let row = state
        .db
        .peer_get_auth(&peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let (_pk, was_managed) = match row {
        Some(r) => r,
        None => {
            return notice_then_table(
                &state,
                lang,
                cols,
                q,
                page,
                page_size,
                "error",
                &tf1(lang, "devices.managed_no_peer", &peer_id),
            )
            .await;
        }
    };
    let new_value = !was_managed;
    state
        .db
        .peer_set_managed(&peer_id, new_value)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    hbb_common::log::info!(
        "admin {} set peer {} managed={} via dashboard",
        admin.name,
        peer_id,
        new_value
    );
    let key = if new_value {
        "devices.managed_now_on"
    } else {
        "devices.managed_now_off"
    };
    notice_then_table(
        &state,
        lang,
        cols,
        q,
        page,
        page_size,
        "ok",
        &tf1(lang, key, &peer_id),
    )
    .await
}

// ---------- column visibility / page size POST handlers ----------

#[derive(Debug, Deserialize)]
pub struct ColumnsForm {
    pub col: String,
    pub visible: String,
    #[serde(default)]
    pub q: Option<String>,
}

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_device_columns(&state, admin.user_id).await;
    let visible = form.visible == "1" || form.visible.eq_ignore_ascii_case("true");
    match form.col.as_str() {
        "owner" => cols.owner = visible,
        "host" => cols.host = visible,
        "serial" => cols.serial = visible,
        "user" => cols.user = visible,
        "unattended_pwd" => cols.unattended_pwd = visible,
        "os" => cols.os = visible,
        "version" => cols.version = visible,
        "last_heartbeat" => cols.last_heartbeat = visible,
        "conns" => cols.conns = visible,
        "auth" => cols.auth = visible,
        _ => {}
    }
    state
        .db
        .user_pref_set(admin.user_id, DEVICE_COLS_HIDE_KEY, &cols.to_hidden_csv())
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let page_size = load_device_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>,
}

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,
            DEVICE_PAGE_SIZE_KEY,
            &form.size.to_string(),
        )
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let cols = load_device_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?,
    ))
}

/// Per-device detail page: hardware / OS inventory reported by hello-agent
/// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the
/// devices list in `#devices-region` via HTMX; a "Back to devices" button
/// re-fetches the table. Vanilla rustdesk clients don't report inventory,
/// so for those we surface a hint pointing the operator at hello-agent.
pub async fn detail(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let row = state
        .db
        .device_get_by_id(&peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let html = match row {
        Some(d) => {
            // Login events are surfaced on the detail page below the
            // inventory. We cap at 50 — that's a few weeks of typical
            // logon/logoff activity on a single-user box and stays
            // skimmable. Deeper history can be added later behind a
            // pagination control if anyone asks for it.
            let events = state
                .db
                .login_events_for_peer(&d.id, 50)
                .await
                .unwrap_or_default();
            // Performance: pull the most recent metrics sample for the
            // "right now" card, plus 24 h of samples for the sparkline,
            // plus the most recent perf events (boot/shutdown/memory-
            // exhaustion etc.) for the "recent slow events" table.
            // All three are best-effort — none of them is required for
            // the detail page to render meaningfully.
            let metrics_latest = state
                .db
                .metrics_latest(&d.id)
                .await
                .unwrap_or_default();
            let since_24h = chrono::Utc::now().timestamp() - 24 * 3600;
            let metrics_24h = state
                .db
                .metrics_samples_since(&d.id, since_24h)
                .await
                .unwrap_or_default();
            let perf_events = state
                .db
                .perf_events_for_peer(&d.id, 20)
                .await
                .unwrap_or_default();
            render_detail(
                lang,
                &d,
                &events,
                metrics_latest.as_ref(),
                &metrics_24h,
                &perf_events,
            )
        }
        None => format!(
            r##"<div class="space-y-4">
  {back}
  <div class="rounded border border-rose-700/50 bg-rose-900/30 p-3 text-sm text-rose-300">
    {no_device} <code class="font-mono">{id}</code> {in_dashboard}
  </div>
</div>"##,
            back = back_button(lang),
            no_device = t(lang, "devices.no_device_with_id"),
            in_dashboard = t(lang, "devices.in_dashboard"),
            id = html_escape(&peer_id),
        ),
    };
    Ok(Html(html))
}

// ---------- helpers ----------

/// Compute online/offline state from a SQLite `current_timestamp` string
/// ("YYYY-MM-DD HH:MM:SS" in UTC). Returns `(is_online, age_seconds)` —
/// the age is also useful for the tooltip text. On parse failure we fall
/// back to "offline" with `i64::MAX`, which is the safe direction (better
/// to show a stale row as offline than fake online).
fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) -> (bool, i64) {
    let parsed = chrono::NaiveDateTime::parse_from_str(last_heartbeat_at, "%Y-%m-%d %H:%M:%S");
    match parsed {
        Ok(naive) => {
            let last = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
            let age = (now - last).num_seconds().max(0);
            (age <= ONLINE_THRESHOLD_SECS, age)
        }
        Err(_) => (false, i64::MAX),
    }
}

async fn render_table(
    state: &Arc<AppState>,
    lang: Lang,
    cols: DeviceColumns,
    q: &str,
    page: i64,
    page_size: i64,
) -> Result<String, ApiError> {
    // Probe the filtered total so we can clamp the requested page before
    // doing the offset query (e.g. asking for page 5 after a delete that
    // left only 1 page should land on page 1, not show an empty list).
    let (total, _) = state
        .db
        .devices_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 (_, devices) = state
        .db
        .devices_list_filtered(q, offset, page_size)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let now = chrono::Utc::now();
    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_peer}</th>"##,
        c_peer = t(lang, "devices.col_peer_id"),
    );
    if cols.owner {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_owner"));
    }
    if cols.host {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_hostname"));
    }
    if cols.serial {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_serial"));
    }
    if cols.user {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_user"));
    }
    if cols.unattended_pwd {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_unattended_pwd"));
    }
    if cols.os {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_os"));
    }
    if cols.version {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_version"));
    }
    if cols.last_heartbeat {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_last_heartbeat"));
    }
    if cols.conns {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_conns"));
    }
    if cols.auth {
        let _ = write!(s, r##"
      <th class="text-left font-medium px-3 py-2">{}</th>"##, t(lang, "devices.col_auth"));
    }
    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 devices.is_empty() {
        // colspan = peer column + visible toggle columns + actions column.
        let colspan = 1 + cols.visible_count() + 1;
        let empty_msg = if q.is_empty() {
            t(lang, "devices.no_devices")
        } else {
            t(lang, "devices.no_match")
        };
        let _ = write!(
            s,
            r##"<tr><td colspan="{colspan}" class="px-3 py-6 text-slate-500 text-center text-xs">{empty}</td></tr>"##,
            colspan = colspan,
            empty = empty_msg,
        );
    } else {
        let always_show_pwd = unattended_pwd_always_visible();
        for d in &devices {
            render_device_row(&mut s, lang, cols, q, page, d, now, always_show_pwd);
        }
    }
    s.push_str("</tbody>\n</table></div>");
    s.push_str(&render_pagination_footer(lang, q, page, page_size, total, total_pages));
    Ok(s)
}

fn render_columns_popover(lang: Lang, cols: DeviceColumns) -> String {
    let items: [(&str, bool, &str); 10] = [
        ("owner", cols.owner, t(lang, "devices.col_owner")),
        ("host", cols.host, t(lang, "devices.col_hostname")),
        ("serial", cols.serial, t(lang, "devices.col_serial")),
        ("user", cols.user, t(lang, "devices.col_user")),
        ("unattended_pwd", cols.unattended_pwd, t(lang, "devices.col_unattended_pwd")),
        ("os", cols.os, t(lang, "devices.col_os")),
        ("version", cols.version, t(lang, "devices.col_version")),
        ("last_heartbeat", cols.last_heartbeat, t(lang, "devices.col_last_heartbeat")),
        ("conns", cols.conns, t(lang, "devices.col_conns")),
        ("auth", cols.auth, t(lang, "devices.col_auth")),
    ];
    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="devicesColumnToggle(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, "devices.columns"),
        checkboxes = checkboxes,
    )
}

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,
        "devices.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,
        );
    }
    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/devices/list-fragment?page={p}{q_param}" hx-target="#devices-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/devices/list-fragment?page={p}{q_param}" hx-target="#devices-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="devicesPageSize(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, "devices.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,
    )
}

/// Percent-encode characters that aren't safe in a URL query value.
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
}

/// Resolves the `--unattended-pwd-visibility` setting (env key
/// `UNATTENDED-PWD-VISIBILITY`, also settable via `.env`). Returns `true`
/// when the admin UI should surface the unattended password even while an
/// interactive user is logged in. Default (`logged-out`, or any
/// unrecognised value) keeps the original behaviour: shown only when nobody
/// is logged in.
fn unattended_pwd_always_visible() -> bool {
    crate::common::get_arg_or("unattended-pwd-visibility", "logged-out".to_owned())
        .trim()
        .eq_ignore_ascii_case("always")
}

fn render_device_row(
    s: &mut String,
    lang: Lang,
    cols: DeviceColumns,
    q: &str,
    page: i64,
    d: &DashboardDeviceRow,
    now: chrono::DateTime<chrono::Utc>,
    always_show_pwd: bool,
) {
    let parsed: serde_json::Value =
        serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
    let pick = |k: &str| -> String {
        parsed
            .get(k)
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string()
    };
    let hostname = pick("hostname");
    // Hardware serial number — only present when the agent (e.g.
    // hello-agent) ships an inventory blob alongside the sysinfo
    // payload. Vanilla rustdesk doesn't, so most rows render a dash.
    let serial_number = parsed
        .get("inventory")
        .and_then(|inv| inv.get("serial_number"))
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_string();
    // `username` is the active console user reported by the agent's
    // sysinfo. The agent suppresses the field when nobody is logged in
    // (or when it's literally "SYSTEM" on Windows), so an empty value
    // here means "no interactive user" — render that as a dash.
    let active_user = pick("username");
    let os = pick("os");
    // Version label. The sysinfo upload always carries `version` (the
    // embedded rustdesk core version, e.g. "1.4.6"). Rebrands like
    // hello-agent additionally stamp `agent_name` + `agent_version` —
    // when present we surface those instead so the admin sees
    // "Hello Agent 0.1.0" rather than the embedded core version.
    // Fallback "RustDesk <ver>" is the right default for vanilla
    // installs (they don't override the agent fields).
    let version_label = {
        let core_ver = pick("version");
        let agent_name = pick("agent_name");
        let agent_ver = pick("agent_version");
        if !agent_name.is_empty() {
            if !agent_ver.is_empty() {
                format!("{agent_name} {agent_ver}")
            } else {
                agent_name
            }
        } else if !core_ver.is_empty() {
            format!("RustDesk {core_ver}")
        } else {
            "—".to_string()
        }
    };
    let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
        .map(|v| v.len())
        .unwrap_or(0);
    let (is_online, age_secs) = online_state(&d.last_heartbeat_at, now);
    let (dot_class, tooltip) = if is_online {
        (
            "bg-emerald-400",
            tf1(lang, "devices.online", &age_secs.to_string()),
        )
    } else if age_secs == i64::MAX {
        ("bg-slate-500", t(lang, "devices.no_heartbeat").to_string())
    } else {
        (
            "bg-rose-500",
            tf1(lang, "devices.offline", &fmt_age(age_secs)),
        )
    };
    // Per-boot unattended-access password reported by hello-agent. Visible
    // only when (a) the device is online (offline rows show stale data),
    // (b) no interactive user is logged in (otherwise the supporter
    // should be using the per-session approval popup, not the password)
    // — unless `--unattended-pwd-visibility=always` overrides (b), and
    // (c) the agent has actually reported one (vanilla rustdesk
    // never will). Otherwise show a neutral dash so the column lines up.
    let unattended_pwd_cell = if is_online
        && (always_show_pwd || active_user.is_empty())
        && !d.unattended_password.is_empty()
    {
        format!(
            r##"<code class="font-mono text-xs text-amber-300 bg-slate-950 px-1.5 py-0.5 rounded border border-slate-800" title="Reported {set_at} UTC">{pw}</code>"##,
            pw = html_escape(&d.unattended_password),
            set_at = html_escape(&d.unattended_password_set_at),
        )
    } else {
        r##"<span class="text-slate-600">—</span>"##.to_string()
    };

    let id_cell = format!(
        r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
    <span class="inline-flex items-center gap-2" title="{tt}">
      <span class="inline-block w-2 h-2 rounded-full {dot}"></span>
      <span>{id}</span>
    </span>
  </td>"##,
        tt = html_escape(&tooltip),
        dot = dot_class,
        id = html_escape(&d.id),
    );

    // `?page={page}&q={q}` appended to every per-row action so the
    // re-rendered table comes back showing the same filtered page.
    let q_param = if q.is_empty() {
        String::new()
    } else {
        format!("&q={}", url_encode(q))
    };

    // Auth toggle: the menu entry's label flips based on current state,
    // and only the off→on transition needs no confirm (it strengthens
    // security). on→off removes the signature requirement and reintroduces
    // the spoofing surface, so we require a confirm on that direction.
    let toggle_managed_item = if d.managed {
        format!(
            r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/devices/{id}/toggle-managed?page={page}{q_param}"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-confirm="{confirm}">
          {label}
        </button>"##,
            id = html_escape(&d.id),
            page = page,
            q_param = q_param,
            confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)),
            label = t(lang, "devices.mark_unsigned"),
        )
    } else {
        format!(
            r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/devices/{id}/toggle-managed?page={page}{q_param}"
          hx-target="#devices-region" hx-swap="innerHTML">
          {label}
        </button>"##,
            id = html_escape(&d.id),
            page = page,
            q_param = q_param,
            label = t(lang, "devices.mark_managed"),
        )
    };

    // Open <tr> + the always-on peer id cell.
    let _ = write!(s, r##"<tr class="hover:bg-slate-800/40">
  {id_cell}"##, id_cell = id_cell);
    if cols.owner {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-300">{}</td>"##,
            html_escape(&d.owner_username),
        );
    }
    if cols.host {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400">{}</td>"##,
            html_escape(&hostname),
        );
    }
    if cols.serial {
        let cell = if serial_number.is_empty() {
            r##"<span class="text-slate-600">—</span>"##.to_string()
        } else {
            format!(
                r##"<span class="font-mono text-xs text-slate-300">{}</span>"##,
                html_escape(&serial_number),
            )
        };
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 whitespace-nowrap">{}</td>"##,
            cell,
        );
    }
    if cols.user {
        let user_cell = if active_user.is_empty() {
            "—".to_string()
        } else {
            html_escape(&active_user)
        };
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-300">{}</td>"##,
            user_cell,
        );
    }
    if cols.unattended_pwd {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 whitespace-nowrap">{}</td>"##,
            unattended_pwd_cell,
        );
    }
    if cols.os {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400">{}</td>"##,
            html_escape(&os),
        );
    }
    if cols.version {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400 whitespace-nowrap">{}</td>"##,
            html_escape(&version_label),
        );
    }
    if cols.last_heartbeat {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-500 text-xs">{}</td>"##,
            html_escape(&d.last_heartbeat_at),
        );
    }
    if cols.conns {
        let _ = write!(
            s,
            r##"
  <td class="px-3 py-2 text-slate-400">{}</td>"##,
            conn_count,
        );
    }
    if cols.auth {
        // Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat /
        // sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when
        // managed=0 and the device still posts unsigned bodies. The tooltip
        // gives the operator the one-line explanation so they know what
        // flipping the flag will do.
        let auth_cell = if d.managed {
            format!(
                r##"<td class="px-3 py-2 whitespace-nowrap">
    <span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
  </td>"##,
                tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
                label = t(lang, "devices.auth_signed"),
            )
        } else {
            format!(
                r##"<td class="px-3 py-2 whitespace-nowrap">
    <span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
  </td>"##,
                tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")),
                label = t(lang, "devices.auth_unsigned"),
            )
        };
        let _ = write!(s, "\n  {}", auth_cell);
    }
    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-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
        <a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
          href="/admin/connect/{id}" target="_blank" rel="noopener">
          {connect_web}
        </a>
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-get="/admin/pages/devices/{id}/detail"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-push-url="#devices/{id}">
          {details}
        </button>
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-get="/admin/pages/devices/{id}/exec"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-push-url="#devices/{id}/exec">
          {run_command}
        </button>
        <hr class="border-slate-700 my-1" />
        {toggle_managed_item}
        <hr class="border-slate-700 my-1" />
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/devices/{id}/disconnect?page={page}{q_param}"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-confirm="{confirm_disc}">
          {force_disc}
        </button>
        <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
          hx-post="/admin/pages/devices/{id}/sysinfo-refresh?page={page}{q_param}"
          hx-target="#devices-region" hx-swap="innerHTML">
          {force_sysinfo}
        </button>
        <hr class="border-slate-700 my-1" />
        <button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
          hx-post="/admin/pages/devices/{id}/delete?page={page}{q_param}"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-confirm="{confirm_delete}">
          {delete_device}
        </button>
      </div>
    </details>
  </td>
</tr>"##,
        id = html_escape(&d.id),
        page = page,
        q_param = q_param,
        toggle_managed_item = toggle_managed_item,
        connect_web = t(lang, "devices.connect_web"),
        details = t(lang, "devices.details"),
        run_command = t(lang, "devices.run_command"),
        confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
        force_disc = t(lang, "devices.force_disconnect"),
        force_sysinfo = t(lang, "devices.force_sysinfo"),
        confirm_delete = html_escape(&tf1(lang, "devices.confirm_delete", &d.id)),
        delete_device = t(lang, "devices.delete_device"),
    );
}

// ---------- detail page ----------

/// HTMX-only endpoint returning just the devices table fragment (no
/// outer header), so the per-device detail view's "Back to devices"
/// button, the search input, and the pagination buttons can all swap
/// the table back into `#devices-region` without re-rendering the
/// whole page wrapper. Accepts `?page=N&q=...` so the same handler
/// drives both navigation and filtering.
pub async fn list_fragment(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Query(pg): Query<DevicesListParams>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let cols = load_device_columns(&state, admin.user_id).await;
    let page_size = load_device_page_size(&state, admin.user_id).await;
    Ok(Html(
        render_table(&state, lang, cols, pg.query(), pg.page_or_first(), page_size).await?,
    ))
}

/// "Back to devices" — refetches the devices table fragment via HTMX
/// and swaps it back into `#devices-region`. Used by the detail page.
/// `hx-push-url="#devices"` resets the address bar to the list-level
/// hash so a subsequent refresh lands on the list, not the detail.
fn back_button(lang: Lang) -> String {
    format!(
        r##"<button class="text-xs text-sky-300 hover:text-sky-200"
  hx-get="/admin/pages/devices/list-fragment"
  hx-target="#devices-region"
  hx-swap="innerHTML"
  hx-push-url="#devices">{label}</button>"##,
        label = t(lang, "devices.back"),
    )
}

/// Pretty-print a JSON value for the inventory table cells. Strings are
/// returned as-is, numbers / bools rendered via Display, null / missing
/// becomes a dash. Anything more complex (objects, arrays of non-disks)
/// falls back to compact JSON so the page never panics on unexpected data.
fn fmt_inv_value(v: Option<&serde_json::Value>) -> String {
    match v {
        None | Some(serde_json::Value::Null) => "—".to_string(),
        Some(serde_json::Value::String(s)) if s.is_empty() => "—".to_string(),
        Some(serde_json::Value::String(s)) => html_escape(s),
        Some(serde_json::Value::Number(n)) => n.to_string(),
        Some(serde_json::Value::Bool(b)) => b.to_string(),
        Some(other) => html_escape(&other.to_string()),
    }
}

fn render_detail(
    lang: Lang,
    d: &DashboardDeviceRow,
    login_events: &[LoginEventRow],
    metrics_latest: Option<&MetricsSampleRow>,
    metrics_24h: &[MetricsSampleRow],
    perf_events: &[PerfEventRow],
) -> String {
    let parsed: serde_json::Value =
        serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
    let pick = |k: &str| -> String {
        parsed
            .get(k)
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string()
    };
    let agent_name = pick("agent_name");
    let agent_version = pick("agent_version");
    let core_version = pick("version");
    let hostname = pick("hostname");
    let active_user = pick("username");
    let os_runtime = pick("os");
    let cpu_runtime = pick("cpu");
    let mem_runtime = pick("memory");

    let identity_label = if !agent_name.is_empty() {
        if !agent_version.is_empty() {
            format!("{agent_name} {agent_version}")
        } else {
            agent_name.clone()
        }
    } else if !core_version.is_empty() {
        format!("RustDesk {core_version}")
    } else {
        "—".to_string()
    };

    // Header summary — same data the list shows, rendered as a description list.
    let header = format!(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
  <div class="flex items-baseline justify-between">
    <h2 class="text-lg font-semibold">{device_label} <code class="font-mono text-sky-300">{id}</code></h2>
    <span class="text-xs text-slate-500">UUID <code class="font-mono">{uuid}</code></span>
  </div>
  <dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm md:grid-cols-3">
    <div><dt class="text-xs text-slate-500">{l_host}</dt><dd class="text-slate-200">{host}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_owner}</dt><dd class="text-slate-200">{owner}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_user}</dt><dd class="text-slate-200">{user}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_agent}</dt><dd class="text-slate-200">{ident}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_os_rt}</dt><dd class="text-slate-200">{os_rt}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_last}</dt><dd class="text-slate-200">{last}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_cpu_rt}</dt><dd class="text-slate-200">{cpu_rt}</dd></div>
    <div><dt class="text-xs text-slate-500">{l_mem_rt}</dt><dd class="text-slate-200">{mem_rt}</dd></div>
  </dl>
</div>"##,
        device_label = t(lang, "devices.device_label"),
        l_host = t(lang, "devices.col_hostname"),
        l_owner = t(lang, "devices.col_owner"),
        l_user = t(lang, "devices.detail_active_user"),
        l_agent = t(lang, "devices.detail_agent"),
        l_os_rt = t(lang, "devices.detail_os_runtime"),
        l_last = t(lang, "devices.col_last_heartbeat"),
        l_cpu_rt = t(lang, "devices.detail_cpu_runtime"),
        l_mem_rt = t(lang, "devices.detail_memory_runtime"),
        id = html_escape(&d.id),
        uuid = html_escape(&d.uuid),
        host = html_escape(if hostname.is_empty() { "—" } else { &hostname }),
        owner = html_escape(if d.owner_username.is_empty() {
            "—"
        } else {
            &d.owner_username
        }),
        user = html_escape(if active_user.is_empty() { "—" } else { &active_user }),
        ident = html_escape(&identity_label),
        os_rt = html_escape(if os_runtime.is_empty() { "—" } else { &os_runtime }),
        last = html_escape(&d.last_heartbeat_at),
        cpu_rt = html_escape(if cpu_runtime.is_empty() { "—" } else { &cpu_runtime }),
        mem_rt = html_escape(if mem_runtime.is_empty() { "—" } else { &mem_runtime }),
    );

    // Inventory section — rendered whenever the device's sysinfo payload
    // contains a populated `inventory` object, regardless of which client
    // sent it. We used to gate this on `agent_name == "HelloAgent"` (the
    // explicit rebrand identity stamped by hello-agent), but that filter
    // (a) broke silently when hello-agent's APP_NAME changed casing, and
    // (b) hid any inventory data shipped by a future client variant that
    // didn't carry the exact same agent_name string. Rendering on presence
    // (`inv.is_object()`) instead of identity makes the page robust to both.
    // The "pending" message covers the only remaining absence case: an
    // agent that doesn't (or doesn't yet) report inventory.
    let inventory_section = match parsed.get("inventory") {
        Some(inv) if inv.is_object() => render_inventory_table(lang, inv),
        _ => format!(
            r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
  {msg}
</div>"##,
            msg = t(lang, "devices.inventory_pending"),
        ),
    };

    let login_section = render_login_events(lang, login_events);
    let perf_section = render_performance(lang, metrics_latest, metrics_24h, perf_events);

    format!(
        r##"<div class="space-y-4">
  <div class="flex items-center justify-between">
    {back}
    <div class="text-xs text-slate-500">{detail_view}</div>
  </div>
  {header}
  <h3 class="text-sm font-semibold text-slate-300 mt-4">{performance}</h3>
  {perf}
  <h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
  {inv}
  <h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
  {login}
</div>"##,
        back = back_button(lang),
        detail_view = t(lang, "devices.detail_view"),
        performance = t(lang, "devices.performance"),
        perf = perf_section,
        inventory = t(lang, "devices.inventory"),
        header = header,
        inv = inventory_section,
        login_history = t(lang, "devices.login_history"),
        login = login_section,
    )
}

/// Top-level Performance section: snapshot card, two sparklines (CPU /
/// memory), and a recent-events table. The whole thing is omitted in
/// favour of a "no data yet" panel when the agent hasn't reported.
fn render_performance(
    lang: Lang,
    latest: Option<&MetricsSampleRow>,
    series: &[MetricsSampleRow],
    events: &[PerfEventRow],
) -> String {
    if latest.is_none() && series.is_empty() && events.is_empty() {
        return format!(
            r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
  {msg}
</div>"##,
            msg = t(lang, "devices.perf_none"),
        );
    }

    let snapshot = render_perf_snapshot(lang, latest);
    let cpu_chart = render_sparkline(
        lang,
        series.iter().map(|s| (s.at, s.cpu_pct)).collect(),
        100.0,
        true,
        t(lang, "devices.perf_cpu"),
    );
    let mem_chart = {
        // Mem is reported as MB used / MB total; chart uses % so the
        // y-axis stays comparable to the CPU panel.
        let series_pct: Vec<(i64, f64)> = series
            .iter()
            .filter(|s| s.mem_total_mb > 0)
            .map(|s| {
                let pct = 100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64);
                (s.at, pct)
            })
            .collect();
        render_sparkline(lang, series_pct, 100.0, true, t(lang, "devices.perf_mem"))
    };
    let events_section = render_perf_events_table(lang, events);

    format!(
        r##"<div class="space-y-4">
  {snapshot}
  <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
    {cpu}
    {mem}
  </div>
  {events}
</div>"##,
        snapshot = snapshot,
        cpu = cpu_chart,
        mem = mem_chart,
        events = events_section,
    )
}

/// "Right now" card — the most recent metrics sample. Drawn as a 4-up
/// stat tile so the supporter can glance at CPU / memory / top
/// processes without reading a chart. Falls back to a thin "no live
/// data" pill when the agent has never reported.
fn render_perf_snapshot(lang: Lang, latest: Option<&MetricsSampleRow>) -> String {
    let Some(s) = latest else {
        return format!(
            r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">
  {msg}
</div>"##,
            msg = t(lang, "devices.perf_no_live"),
        );
    };
    let now = chrono::Utc::now().timestamp();
    let age = (now - s.at).max(0);
    let age_str = fmt_age(age);
    let cpu_color = pct_color(s.cpu_pct);
    let mem_pct = if s.mem_total_mb > 0 {
        100.0 * (s.mem_used_mb as f64) / (s.mem_total_mb as f64)
    } else {
        0.0
    };
    let mem_color = pct_color(mem_pct);
    let mem_used_gb = (s.mem_used_mb as f64) / 1024.0;
    let mem_total_gb = (s.mem_total_mb as f64) / 1024.0;
    let top_cpu = if s.top_cpu_name.is_empty() {
        "—".to_string()
    } else {
        format!(
            "{name} <span class=\"text-xs text-slate-400\">{pct:.0}%</span>",
            name = html_escape(&s.top_cpu_name),
            pct = s.top_cpu_pct,
        )
    };
    let top_mem = if s.top_mem_name.is_empty() {
        "—".to_string()
    } else {
        let mb = s.top_mem_mb;
        let mem_disp = if mb >= 1024 {
            format!("{:.1} GB", (mb as f64) / 1024.0)
        } else {
            format!("{} MB", mb)
        };
        format!(
            "{name} <span class=\"text-xs text-slate-400\">{disp}</span>",
            name = html_escape(&s.top_mem_name),
            disp = html_escape(&mem_disp),
        )
    };
    let uptime_str = if s.uptime_secs > 0 {
        fmt_age(s.uptime_secs)
    } else {
        "—".to_string()
    };

    format!(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-4">
  <div class="flex items-baseline justify-between mb-3">
    <h4 class="text-sm font-semibold text-slate-200">{l_now}</h4>
    <span class="text-xs text-slate-500" title="{at_full} UTC">{l_age}</span>
  </div>
  <dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
    <div>
      <dt class="text-xs text-slate-500">{l_cpu}</dt>
      <dd class="text-lg font-semibold {cpu_cls} tabular-nums">{cpu:.0}%</dd>
    </div>
    <div>
      <dt class="text-xs text-slate-500">{l_mem}</dt>
      <dd class="text-lg font-semibold {mem_cls} tabular-nums">{mem_pct:.0}%</dd>
      <dd class="text-xs text-slate-500 tabular-nums">{used:.1} / {total:.1} GB</dd>
    </div>
    <div>
      <dt class="text-xs text-slate-500">{l_top_cpu}</dt>
      <dd class="text-slate-200 font-mono text-xs truncate" title="{top_cpu_raw}">{top_cpu}</dd>
    </div>
    <div>
      <dt class="text-xs text-slate-500">{l_top_mem}</dt>
      <dd class="text-slate-200 font-mono text-xs truncate" title="{top_mem_raw}">{top_mem}</dd>
    </div>
    <div>
      <dt class="text-xs text-slate-500">{l_uptime}</dt>
      <dd class="text-slate-300 tabular-nums">{uptime}</dd>
    </div>
    <div>
      <dt class="text-xs text-slate-500">{l_procs}</dt>
      <dd class="text-slate-300 tabular-nums">{procs}</dd>
    </div>
  </dl>
</div>"##,
        l_now = t(lang, "devices.perf_now"),
        l_age = tf1(lang, "devices.perf_sampled_ago", &age_str),
        at_full = html_escape(&fmt_unix_utc(s.at)),
        l_cpu = t(lang, "devices.perf_cpu"),
        cpu_cls = cpu_color,
        cpu = s.cpu_pct,
        l_mem = t(lang, "devices.perf_mem"),
        mem_cls = mem_color,
        mem_pct = mem_pct,
        used = mem_used_gb,
        total = mem_total_gb,
        l_top_cpu = t(lang, "devices.perf_top_cpu"),
        top_cpu_raw = html_escape(&s.top_cpu_name),
        top_cpu = top_cpu,
        l_top_mem = t(lang, "devices.perf_top_mem"),
        top_mem_raw = html_escape(&s.top_mem_name),
        top_mem = top_mem,
        l_uptime = t(lang, "devices.perf_uptime"),
        uptime = html_escape(&uptime_str),
        l_procs = t(lang, "devices.perf_proc_count"),
        procs = s.proc_count,
    )
}

/// Color-code a percentage value (0–100) — green up to 60, amber up to
/// 85, red above. Used for the snapshot stat tiles so the supporter
/// can spot a wedged-laptop at a glance.
fn pct_color(pct: f64) -> &'static str {
    if pct >= 85.0 {
        "text-rose-400"
    } else if pct >= 60.0 {
        "text-amber-300"
    } else {
        "text-emerald-300"
    }
}

/// Render an inline-SVG sparkline. `series` is a (unix-seconds, value)
/// vector; `max_y` clamps the y-axis (so two side-by-side charts share
/// a scale); `bucketed = true` downsamples by averaging into 96 buckets
/// so the polyline string stays short for a wide time window.
fn render_sparkline(
    lang: Lang,
    series: Vec<(i64, f64)>,
    max_y: f64,
    bucketed: bool,
    title: &str,
) -> String {
    const WIDTH: f64 = 600.0;
    const HEIGHT: f64 = 80.0;
    const PAD: f64 = 4.0;

    if series.is_empty() {
        return format!(
            r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
  <h4 class="text-xs uppercase text-slate-500 mb-1">{title}</h4>
  <div class="text-xs text-slate-500">{msg}</div>
</div>"##,
            title = html_escape(title),
            msg = t(lang, "devices.perf_no_chart"),
        );
    }

    let points = if bucketed && series.len() > 96 {
        downsample_avg(&series, 96)
    } else {
        series.clone()
    };

    let min_x = points.first().map(|p| p.0).unwrap_or(0);
    let max_x = points.last().map(|p| p.0).unwrap_or(0);
    let span_x = (max_x - min_x).max(1) as f64;

    let plot_w = WIDTH - 2.0 * PAD;
    let plot_h = HEIGHT - 2.0 * PAD;

    let mut path = String::new();
    let mut area = String::new();
    let mut peak: f64 = 0.0;
    let mut last: f64 = 0.0;
    for (i, (t, v)) in points.iter().enumerate() {
        let x = PAD + plot_w * ((t - min_x) as f64) / span_x;
        let y_norm = (v / max_y).clamp(0.0, 1.0);
        let y = PAD + plot_h * (1.0 - y_norm);
        if i == 0 {
            path.push_str(&format!("M{:.1},{:.1}", x, y));
            area.push_str(&format!("M{:.1},{:.1}", x, PAD + plot_h));
            area.push_str(&format!(" L{:.1},{:.1}", x, y));
        } else {
            path.push_str(&format!(" L{:.1},{:.1}", x, y));
            area.push_str(&format!(" L{:.1},{:.1}", x, y));
        }
        peak = peak.max(*v);
        last = *v;
    }
    let last_x = PAD + plot_w;
    area.push_str(&format!(" L{:.1},{:.1} Z", last_x, PAD + plot_h));

    // Hours-from-now labels: oldest point's age, "now" on the right.
    let span_secs = (max_x - min_x).max(0);
    let span_label = if span_secs >= 3600 {
        format!("-{}h", span_secs / 3600)
    } else if span_secs >= 60 {
        format!("-{}m", span_secs / 60)
    } else {
        format!("-{}s", span_secs)
    };

    format!(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
  <div class="flex items-baseline justify-between mb-1">
    <h4 class="text-xs uppercase text-slate-500">{title}</h4>
    <span class="text-[11px] text-slate-500 tabular-nums">{l_peak} <span class="text-slate-300">{peak:.0}%</span> &nbsp; {l_now} <span class="text-slate-300">{last:.0}%</span></span>
  </div>
  <svg viewBox="0 0 {w} {h}" preserveAspectRatio="none" class="w-full h-20">
    <line x1="{pad}" y1="{ymid:.1}" x2="{xend:.1}" y2="{ymid:.1}" stroke="#1f2937" stroke-width="1" stroke-dasharray="2,3"/>
    <path d="{area}" fill="#0ea5e9" fill-opacity="0.10" stroke="none"/>
    <path d="{path}" fill="none" stroke="#38bdf8" stroke-width="1.5" stroke-linejoin="round"/>
  </svg>
  <div class="flex justify-between text-[10px] text-slate-500 mt-1 tabular-nums">
    <span>{older}</span>
    <span>{l_now_short}</span>
  </div>
</div>"##,
        title = html_escape(title),
        l_peak = t(lang, "devices.perf_peak"),
        peak = peak,
        l_now = t(lang, "devices.perf_latest"),
        last = last,
        w = WIDTH,
        h = HEIGHT,
        pad = PAD,
        ymid = PAD + plot_h * 0.5,
        xend = WIDTH - PAD,
        area = area,
        path = path,
        older = html_escape(&span_label),
        l_now_short = t(lang, "devices.perf_now_short"),
    )
}

/// Mean-pool a (timestamp, value) series down to `target` buckets,
/// keeping the bucket-mean timestamp as the bucket's x. Empty buckets
/// are dropped so the resulting polyline doesn't draw zero-lines for
/// stretches where the agent was offline.
fn downsample_avg(series: &[(i64, f64)], target: usize) -> Vec<(i64, f64)> {
    if series.len() <= target {
        return series.to_vec();
    }
    let min_x = series.first().map(|p| p.0).unwrap_or(0);
    let max_x = series.last().map(|p| p.0).unwrap_or(0);
    let span = (max_x - min_x).max(1);
    let bucket_secs = (span as usize) / target.max(1);
    let bucket_secs = bucket_secs.max(1) as i64;

    let mut buckets: Vec<(i64, f64, usize)> = Vec::with_capacity(target);
    let mut current_bucket: i64 = -1;
    for (t, v) in series {
        let b = (t - min_x) / bucket_secs;
        if b != current_bucket {
            buckets.push((*t, *v, 1));
            current_bucket = b;
        } else if let Some(last) = buckets.last_mut() {
            last.1 += *v;
            last.2 += 1;
        }
    }
    buckets
        .into_iter()
        .map(|(t, sum, n)| (t, sum / (n as f64)))
        .collect()
}

/// Recent perf-events table — boot/shutdown/sleep degradation, memory
/// exhaustion, BSODs, unexpected reboots. Empty list → a neutral
/// "nothing flagged yet" panel so the heading still has a body.
fn render_perf_events_table(lang: Lang, events: &[PerfEventRow]) -> String {
    if events.is_empty() {
        return format!(
            r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">
  {msg}
</div>"##,
            msg = t(lang, "devices.perf_events_none"),
        );
    }
    // Collapsed by default — the table is information-dense and most
    // operators only look at it when they're chasing a specific
    // complaint. The Wi-Fi-nearby section uses the same `<details>`
    // pattern earlier on this page.
    let mut s = format!(
        r##"<details class="rounded-md border border-slate-800 bg-slate-900">
  <summary class="cursor-pointer px-3 py-2 text-xs uppercase text-slate-400 hover:text-slate-200 select-none">{l_events} <span class="text-slate-500">({n})</span></summary>
  <div class="border-t border-slate-800 overflow-hidden">
<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_when}</th>
      <th class="text-left font-medium px-3 py-2">{c_source}</th>
      <th class="text-left font-medium px-3 py-2">{c_summary}</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-800">"##,
        l_events = t(lang, "devices.perf_events_heading"),
        n = events.len(),
        c_when = t(lang, "devices.perf_events_col_when"),
        c_source = t(lang, "devices.perf_events_col_source"),
        c_summary = t(lang, "devices.perf_events_col_summary"),
    );
    for ev in events {
        let when = fmt_unix_utc(ev.at);
        let (level_cls, _level_label) = match ev.level {
            1 => ("bg-rose-900/40 text-rose-300 border-rose-800", "critical"),
            2 => ("bg-rose-900/30 text-rose-300 border-rose-900", "error"),
            3 => ("bg-amber-900/40 text-amber-300 border-amber-800", "warning"),
            _ => ("bg-slate-800 text-slate-300 border-slate-700", "info"),
        };
        let source_label = match ev.provider.as_str() {
            "diag-perf" => t(lang, "devices.perf_src_diag_perf"),
            "res-exh" => t(lang, "devices.perf_src_res_exh"),
            "system" => t(lang, "devices.perf_src_system"),
            other => other,
        };
        let _ = write!(
            s,
            r##"<tr class="hover:bg-slate-800/40 align-top">
  <td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap">{when}</td>
  <td class="px-3 py-2">
    <span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {lvl_cls}">{src} · {eid}</span>
  </td>
  <td class="px-3 py-2 text-slate-200 text-xs">{summary}</td>
</tr>"##,
            when = html_escape(&when),
            lvl_cls = level_cls,
            src = html_escape(source_label),
            eid = ev.event_id,
            summary = html_escape(&ev.summary),
        );
    }
    s.push_str("</tbody></table></div></details>");
    s
}

/// Render the per-device login history table. Empty input → a neutral
/// "no events yet" panel so the heading still has something under it.
/// We render the agent-reported `at` in the standard SQLite UTC format
/// (no per-locale fiddling) and only flag a "received_at differs from at"
/// case as a tooltip — clock skew on the agent side is the only place that
/// matters operationally.
fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String {
    if events.is_empty() {
        return format!(
            r##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
  {msg}
</div>"##,
            msg = t(lang, "devices.login_none"),
        );
    }
    let mut s = format!(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<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_when}</th>
      <th class="text-left font-medium px-3 py-2">{c_event}</th>
      <th class="text-left font-medium px-3 py-2">{c_user}</th>
      <th class="text-left font-medium px-3 py-2">{c_session}</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-800">"##,
        c_when = t(lang, "devices.login_col_when"),
        c_event = t(lang, "devices.login_col_event"),
        c_user = t(lang, "devices.login_col_user"),
        c_session = t(lang, "devices.login_col_session"),
    );
    for ev in events {
        let when = fmt_unix_utc(ev.at);
        // Surface clock-skew >5 min as a tooltip — that's the threshold the
        // signed-API gate also uses, so anything above that is already on
        // the operator's radar via 401s.
        let skew = ev.received_at - ev.at;
        let when_attr = if skew.abs() > 300 {
            format!(
                r##" title="received {recv} UTC (clock skew {skew:+}s)""##,
                recv = html_escape(&fmt_unix_utc(ev.received_at)),
                skew = skew,
            )
        } else {
            String::new()
        };
        let (badge_class, badge_label) = match ev.kind.as_str() {
            "logon" => (
                "bg-emerald-900/40 text-emerald-300 border-emerald-800",
                t(lang, "devices.login_kind_logon"),
            ),
            "logoff" => (
                "bg-slate-800 text-slate-300 border-slate-700",
                t(lang, "devices.login_kind_logoff"),
            ),
            _ => (
                "bg-amber-900/40 text-amber-300 border-amber-800",
                // Unknown kind — display the raw string so an operator
                // running a newer agent against an older server can still
                // see what was reported.
                "",
            ),
        };
        let badge_text = if badge_label.is_empty() {
            html_escape(&ev.kind)
        } else {
            badge_label.to_string()
        };
        let user_display = if ev.domain.is_empty() {
            html_escape(if ev.username.is_empty() { "—" } else { &ev.username })
        } else if ev.username.is_empty() {
            html_escape(&ev.domain)
        } else {
            html_escape(&format!("{}\\{}", ev.domain, ev.username))
        };
        let session_kind = if ev.session_kind.is_empty() {
            "—".to_string()
        } else {
            html_escape(&ev.session_kind)
        };
        let _ = write!(
            s,
            r##"<tr class="hover:bg-slate-800/40">
  <td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap"{when_attr}>{when}</td>
  <td class="px-3 py-2"><span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {bc}">{bt}</span></td>
  <td class="px-3 py-2 text-slate-200">{user}</td>
  <td class="px-3 py-2 text-slate-400 font-mono text-xs">{sk} #{sid}</td>
</tr>"##,
            when_attr = when_attr,
            when = html_escape(&when),
            bc = badge_class,
            bt = badge_text,
            user = user_display,
            sk = session_kind,
            sid = ev.session_id,
        );
    }
    s.push_str("</tbody></table></div>");
    s
}

/// Format a unix epoch as `YYYY-MM-DD HH:MM:SS` UTC. Matches the format
/// SQLite's `current_timestamp` produces, so all the other timestamps on
/// the device detail page line up visually with login-event rows.
fn fmt_unix_utc(ts: i64) -> String {
    use chrono::TimeZone;
    chrono::Utc
        .timestamp_opt(ts, 0)
        .single()
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
        .unwrap_or_else(|| ts.to_string())
}

fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
    let row = |label: &str, key: &str| {
        format!(
            r##"<tr class="border-b border-slate-800">
  <th class="text-left text-xs uppercase text-slate-500 px-3 py-2 w-1/3">{label}</th>
  <td class="px-3 py-2 text-slate-200 font-mono text-xs">{val}</td>
</tr>"##,
            label = label,
            val = fmt_inv_value(inv.get(key)),
        )
    };

    // Disks need their own renderer — they're an array of objects.
    let disks_html = match inv.get("disks") {
        Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
            let mut s = format!(
                r##"<table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_model}</th><th class="text-right font-medium px-2 py-1">{c_size}</th><th class="text-left font-medium px-2 py-1">{c_media}</th></tr></thead><tbody>"##,
                c_name = t(lang, "devices.disk_name"),
                c_model = t(lang, "devices.disk_model"),
                c_size = t(lang, "devices.disk_size"),
                c_media = t(lang, "devices.disk_media"),
            );
            for disk in arr {
                let name = fmt_inv_value(disk.get("name"));
                let model = fmt_inv_value(disk.get("model"));
                let size = fmt_inv_value(disk.get("size_gb"));
                let media = fmt_inv_value(disk.get("media"));
                let _ = write!(
                    s,
                    r##"<tr class="border-t border-slate-800"><td class="px-2 py-1 font-mono text-slate-300">{name}</td><td class="px-2 py-1 text-slate-300">{model}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{size}</td><td class="px-2 py-1 text-slate-400">{media}</td></tr>"##,
                );
            }
            s.push_str("</tbody></table>");
            s
        }
        _ => r##"<span class="text-slate-500">—</span>"##.to_string(),
    };

    // BitLocker is sensitive — render in a copy-friendly monospace box and
    // a slightly louder color, but don't try to obscure it. The whole
    // detail page already requires admin auth.
    let bl_key_raw = inv
        .get("bitlocker_recovery_key")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let bl_html = if bl_key_raw.is_empty() {
        format!(
            r##"<span class="text-slate-500">{}</span>"##,
            t(lang, "devices.bitlocker_unavailable"),
        )
    } else {
        format!(
            r##"<code class="block font-mono text-xs text-amber-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all break-all">{}</code>"##,
            html_escape(bl_key_raw)
        )
    };

    let nics_html = render_nics(lang, inv.get("network_interfaces"));
    let wifi_html = render_wifi(lang, inv.get("wifi_current"), inv.get("wifi_nearby"));
    let software_html = render_installed_software(lang, inv.get("installed_software"));
    let public_ip_raw = inv
        .get("public_ip")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let public_ip_html = if public_ip_raw.is_empty() {
        format!(
            r##"<span class="text-slate-500">{}</span>"##,
            t(lang, "devices.public_ip_failed"),
        )
    } else {
        format!(
            r##"<code class="font-mono text-xs text-sky-300 bg-slate-950 px-2 py-1 rounded border border-slate-800 select-all">{}</code>"##,
            html_escape(public_ip_raw)
        )
    };

    format!(
        r##"<div class="space-y-4">
  <div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
    <table class="w-full text-sm">
      <tbody>
        {sn}
        {mfr}
        {model}
        {dom}
        {os_d}
        {os_r}
        {cpu_m}
        {cpu_s}
        {cpu_pc}
        {cpu_lc}
        {ram}
      </tbody>
    </table>
  </div>
  <div>
    <h4 class="text-xs uppercase text-slate-500 mb-1">{l_disks}</h4>
    <div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2">
      {disks}
    </div>
  </div>
  <div>
    <h4 class="text-xs uppercase text-slate-500 mb-1">{l_nics}</h4>
    {nics}
  </div>
  {wifi}
  <div>
    <h4 class="text-xs uppercase text-slate-500 mb-1">{l_pip}</h4>
    {public_ip}
  </div>
  {software}
  <div>
    <h4 class="text-xs uppercase text-slate-500 mb-1">{l_bl}</h4>
    {bl}
  </div>
</div>"##,
        sn = row(t(lang, "devices.serial_number"), "serial_number"),
        mfr = row(t(lang, "devices.manufacturer"), "manufacturer"),
        model = row(t(lang, "devices.model"), "model"),
        dom = row(t(lang, "devices.windows_domain"), "domain"),
        os_d = row(t(lang, "devices.os_distro"), "os_distro"),
        os_r = row(t(lang, "devices.os_release"), "os_release"),
        cpu_m = row(t(lang, "devices.cpu_model"), "cpu_model"),
        cpu_s = row(t(lang, "devices.cpu_speed"), "cpu_speed_ghz"),
        cpu_pc = row(t(lang, "devices.cpu_phys_cores"), "cpu_cores_physical"),
        cpu_lc = row(t(lang, "devices.cpu_logical_cores"), "cpu_cores_logical"),
        ram = row(t(lang, "devices.ram_gb"), "ram_gb"),
        disks = disks_html,
        nics = nics_html,
        wifi = wifi_html,
        public_ip = public_ip_html,
        software = software_html,
        l_disks = t(lang, "devices.disks"),
        l_nics = t(lang, "devices.network_interfaces"),
        l_pip = t(lang, "devices.public_ip"),
        l_bl = t(lang, "devices.bitlocker"),
        bl = bl_html,
    )
}

/// Render the network-interfaces array as a table (one row per NIC).
/// Wi-Fi NICs get a small badge so the operator can spot them at a glance
/// next to the Wi-Fi-current section. Empty input → dash.
fn render_nics(lang: Lang, nics: Option<&serde_json::Value>) -> String {
    let arr = match nics {
        Some(serde_json::Value::Array(a)) if !a.is_empty() => a,
        _ => {
            return r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-2"><span class="text-slate-500">—</span></div>"##.to_string();
        }
    };
    let mut s = format!(
        r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_desc}</th><th class="text-left font-medium px-2 py-1">MAC</th><th class="text-left font-medium px-2 py-1">{c_status}</th><th class="text-left font-medium px-2 py-1">IPv4</th><th class="text-left font-medium px-2 py-1">IPv6</th><th class="text-right font-medium px-2 py-1">Mbps</th></tr></thead><tbody>"##,
        c_name = t(lang, "devices.disk_name"),
        c_desc = t(lang, "devices.nic_description"),
        c_status = t(lang, "devices.nic_status"),
    );
    for nic in arr {
        let name = fmt_inv_value(nic.get("name"));
        let descr = fmt_inv_value(nic.get("description"));
        let mac = fmt_inv_value(nic.get("mac"));
        let status = fmt_inv_value(nic.get("status"));
        let speed = fmt_inv_value(nic.get("speed_mbps"));
        let is_wifi = nic
            .get("is_wifi")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let wifi_badge = if is_wifi {
            r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-sky-900/50 text-sky-300">Wi-Fi</span>"##
        } else {
            ""
        };
        let ipv4 = render_ip_list(nic.get("ipv4"));
        let ipv6 = render_ip_list(nic.get("ipv6"));
        let _ = write!(
            &mut s,
            r##"<tr class="border-t border-slate-800 align-top"><td class="px-2 py-1 text-slate-200">{name}{badge}</td><td class="px-2 py-1 text-slate-400">{descr}</td><td class="px-2 py-1 font-mono text-slate-300">{mac}</td><td class="px-2 py-1 text-slate-400">{status}</td><td class="px-2 py-1 font-mono text-slate-300">{ipv4}</td><td class="px-2 py-1 font-mono text-slate-300 break-all">{ipv6}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{speed}</td></tr>"##,
            name = name,
            badge = wifi_badge,
            descr = descr,
            mac = mac,
            status = status,
            ipv4 = ipv4,
            ipv6 = ipv6,
            speed = speed,
        );
    }
    s.push_str("</tbody></table></div>");
    s
}

/// Render an array-of-strings IP list as a `<br>`-separated block, or
/// dash when the array is empty / missing. Each address is HTML-escaped.
fn render_ip_list(v: Option<&serde_json::Value>) -> String {
    match v {
        Some(serde_json::Value::Array(a)) if !a.is_empty() => a
            .iter()
            .filter_map(|x| x.as_str())
            .map(html_escape)
            .collect::<Vec<_>>()
            .join("<br>"),
        _ => "—".to_string(),
    }
}

/// Render the Wi-Fi section: current connection (if any) and the
/// nearby-SSID scan (collapsed by default to keep the page short).
/// Returns an empty string when neither is present, so the surrounding
/// detail page can omit the heading entirely.
fn render_wifi(
    lang: Lang,
    current: Option<&serde_json::Value>,
    nearby: Option<&serde_json::Value>,
) -> String {
    let has_current = matches!(current, Some(v) if v.is_object());
    let nearby_arr = match nearby {
        Some(serde_json::Value::Array(a)) if !a.is_empty() => Some(a),
        _ => None,
    };
    if !has_current && nearby_arr.is_none() {
        return String::new();
    }

    let current_html = if let Some(c) = current.filter(|v| v.is_object()) {
        // Rate: native API returns rx/tx in Kbps; render the higher of
        // the two as Mbps. Most APs report identical rx/tx, so a single
        // figure is plenty for the operator's "is this slow" question.
        let rate_mbps = {
            let rx = c.get("rx_kbps").and_then(|v| v.as_u64()).unwrap_or(0);
            let tx = c.get("tx_kbps").and_then(|v| v.as_u64()).unwrap_or(0);
            let max = rx.max(tx);
            if max == 0 {
                "—".to_string()
            } else {
                format!("{} Mbps", max / 1000)
            }
        };
        let signal_with_dbm = {
            let pct = c.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
            let dbm = c.get("rssi_dbm").and_then(|v| v.as_i64());
            match dbm {
                Some(d) => format!("{pct}% ({d} dBm)"),
                None => format!("{pct}%"),
            }
        };
        format!(
            r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3">
  <dl class="grid grid-cols-2 gap-x-6 gap-y-1 text-xs md:grid-cols-3">
    <div><dt class="text-slate-500">SSID</dt><dd class="text-slate-200 font-mono">{ssid}</dd></div>
    <div><dt class="text-slate-500">BSSID</dt><dd class="text-slate-300 font-mono">{bssid}</dd></div>
    <div><dt class="text-slate-500">{l_signal}</dt><dd class="text-slate-200">{sig}</dd></div>
    <div><dt class="text-slate-500">{l_auth}</dt><dd class="text-slate-300">{auth}</dd></div>
    <div><dt class="text-slate-500">{l_cipher}</dt><dd class="text-slate-300">{cipher}</dd></div>
    <div><dt class="text-slate-500">{l_rate}</dt><dd class="text-slate-300">{rate}</dd></div>
  </dl>
</div>"##,
            ssid = fmt_inv_value(c.get("ssid")),
            bssid = fmt_inv_value(c.get("bssid")),
            sig = signal_with_dbm,
            auth = fmt_inv_value(c.get("auth")),
            cipher = fmt_inv_value(c.get("cipher")),
            rate = rate_mbps,
            l_signal = t(lang, "devices.wifi_signal"),
            l_auth = t(lang, "devices.wifi_auth"),
            l_cipher = t(lang, "devices.wifi_cipher"),
            l_rate = t(lang, "devices.wifi_rate"),
        )
    } else {
        format!(
            r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-3 text-xs text-slate-500">{}</div>"##,
            t(lang, "devices.wifi_not_connected"),
        )
    };

    let nearby_html = if let Some(arr) = nearby_arr {
        let mut s = format!(
            r##"<details class="rounded-md border border-slate-800 bg-slate-900">
  <summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{nearby_label}</summary>
  <div class="p-2"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">SSID</th><th class="text-left font-medium px-2 py-1">{l_auth}</th><th class="text-left font-medium px-2 py-1">{l_cipher}</th><th class="text-right font-medium px-2 py-1">{l_signal}</th></tr></thead><tbody>"##,
            nearby_label = tf1(lang, "devices.wifi_nearby", &arr.len().to_string()),
            l_auth = t(lang, "devices.wifi_auth"),
            l_cipher = t(lang, "devices.wifi_cipher"),
            l_signal = t(lang, "devices.wifi_signal"),
        );
        for net in arr {
            let _ = write!(
                &mut s,
                r##"<tr class="border-t border-slate-800"><td class="px-2 py-1 font-mono text-slate-300">{ssid}</td><td class="px-2 py-1 text-slate-400">{auth}</td><td class="px-2 py-1 text-slate-400">{cipher}</td><td class="px-2 py-1 text-right font-mono text-slate-200">{sig}%</td></tr>"##,
                ssid = fmt_inv_value(net.get("ssid")),
                auth = fmt_inv_value(net.get("auth")),
                cipher = fmt_inv_value(net.get("cipher")),
                sig = fmt_inv_value(net.get("signal_pct")),
            );
        }
        s.push_str("</tbody></table></div></details>");
        s
    } else {
        String::new()
    };

    format!(
        r##"<div>
  <h4 class="text-xs uppercase text-slate-500 mb-1">{wifi_current}</h4>
  {current}
  {nearby}
</div>"##,
        wifi_current = t(lang, "devices.wifi_current"),
        current = current_html,
        nearby = if nearby_html.is_empty() {
            String::new()
        } else {
            format!("<div class=\"mt-2\">{}</div>", nearby_html)
        },
    )
}

/// Render the `installed_software` array as a collapsed `<details>` table.
///
/// hello-agent enumerates the Windows Add/Remove Programs registry hives
/// and uploads the result under `inventory.installed_software` as a sorted
/// array of `{name, version, publisher, install_date, bitness}` objects.
/// We render it collapsed by default because the list is routinely
/// 100-300 entries on a real machine — expanding it inline would push
/// the rest of the inventory off the screen. The summary line carries
/// the entry count so the operator can see "is there anything here"
/// without opening it.
///
/// Returns an empty string when the field is absent or empty so the
/// surrounding template can drop the entire block — agents that don't
/// (or can't) report installed software don't get a stray empty header.
fn render_installed_software(lang: Lang, sw: Option<&serde_json::Value>) -> String {
    let arr = match sw {
        Some(serde_json::Value::Array(a)) if !a.is_empty() => a,
        _ => return String::new(),
    };

    let mut rows = String::new();
    for entry in arr {
        let name = fmt_inv_value(entry.get("name"));
        let version = fmt_inv_value(entry.get("version"));
        let publisher = fmt_inv_value(entry.get("publisher"));
        // Bitness is a small badge next to the name when present, matching
        // the Wi-Fi badge on the NICs table. We treat the absence of the
        // field as "unknown" and render nothing rather than a "?" — the
        // page doesn't owe the operator a verdict it can't actually make.
        let bitness_raw = entry
            .get("bitness")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let bitness_badge = match bitness_raw {
            "64" => {
                r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-400">x64</span>"##
            }
            "32" => {
                r##" <span class="ml-1 inline-block text-[10px] px-1 py-0 rounded bg-slate-800 text-slate-500">x86</span>"##
            }
            _ => "",
        };
        let _ = write!(
            &mut rows,
            r##"<tr class="border-t border-slate-800 align-top"><td class="px-2 py-1 text-slate-200">{name}{badge}</td><td class="px-2 py-1 font-mono text-slate-300">{version}</td><td class="px-2 py-1 text-slate-400">{publisher}</td></tr>"##,
            name = name,
            badge = bitness_badge,
            version = version,
            publisher = publisher,
        );
    }

    format!(
        r##"<div>
  <h4 class="text-xs uppercase text-slate-500 mb-1">{label}</h4>
  <details class="rounded-md border border-slate-800 bg-slate-900">
    <summary class="cursor-pointer px-3 py-2 text-xs text-slate-400 hover:text-slate-200 select-none">{count_label}</summary>
    <div class="p-2 overflow-x-auto"><table class="w-full text-xs"><thead><tr class="text-slate-500"><th class="text-left font-medium px-2 py-1">{c_name}</th><th class="text-left font-medium px-2 py-1">{c_version}</th><th class="text-left font-medium px-2 py-1">{c_publisher}</th></tr></thead><tbody>{rows}</tbody></table></div>
  </details>
</div>"##,
        label = t(lang, "devices.installed_software"),
        count_label = tf1(lang, "devices.installed_software_count", &arr.len().to_string()),
        c_name = t(lang, "devices.col_software_name"),
        c_version = t(lang, "devices.col_software_version"),
        c_publisher = t(lang, "devices.col_software_publisher"),
        rows = rows,
    )
}

/// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string
/// for the offline tooltip. The exact heartbeat timestamp is already
/// shown in the table cell — this is just for the friendly tooltip.
fn fmt_age(secs: i64) -> String {
    if secs < 60 {
        format!("{}s", secs)
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else if secs < 86_400 {
        format!("{}h", secs / 3600)
    } else {
        format!("{}d", secs / 86_400)
    }
}

async fn notice_then_table(
    state: &Arc<AppState>,
    lang: Lang,
    cols: DeviceColumns,
    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))
}

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