//! 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, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::DashboardDeviceRow;
use axum::extract::{Extension, Path};
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;

const PAGE_SIZE: i64 = 100;

/// 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;

pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let table = render_table(&state, lang).await?;
    Ok(Html(format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">{heading}</h2>
    <p class="text-xs text-slate-500">{tagline}</p>
  </header>
  <section id="devices-region">
    {table}
  </section>
</div>"##,
        heading = t(lang, "devices.heading"),
        tagline = t(lang, "devices.tagline"),
        table = table,
    )))
}

pub async fn force_disconnect(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    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,
        "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>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    state
        .db
        .heartbeat_command_queue(&peer_id, "sysinfo", None)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    notice_then_table(
        &state,
        lang,
        "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>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    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, 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>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    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,
                "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, "ok", &tf1(lang, key, &peer_id)).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) => render_detail(lang, &d),
        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) -> Result<String, ApiError> {
    let (total, devices) = state
        .db
        .devices_list_all(0, 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>
      <th class="text-left font-medium px-3 py-2">{c_owner}</th>
      <th class="text-left font-medium px-3 py-2">{c_host}</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_pwd}</th>
      <th class="text-left font-medium px-3 py-2">{c_os}</th>
      <th class="text-left font-medium px-3 py-2">{c_ver}</th>
      <th class="text-left font-medium px-3 py-2">{c_last}</th>
      <th class="text-left font-medium px-3 py-2">{c_conns}</th>
      <th class="text-left font-medium px-3 py-2">{c_auth}</th>
      <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_peer = t(lang, "devices.col_peer_id"),
        c_owner = t(lang, "devices.col_owner"),
        c_host = t(lang, "devices.col_hostname"),
        c_user = t(lang, "devices.col_user"),
        c_pwd = t(lang, "devices.col_unattended_pwd"),
        c_os = t(lang, "devices.col_os"),
        c_ver = t(lang, "devices.col_version"),
        c_last = t(lang, "devices.col_last_heartbeat"),
        c_conns = t(lang, "devices.col_conns"),
        c_auth = t(lang, "devices.col_auth"),
        c_actions = t(lang, "common.actions"),
    );
    if devices.is_empty() {
        let _ = write!(
            s,
            r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
            t(lang, "devices.no_devices"),
        );
    }
    let always_show_pwd = unattended_pwd_always_visible();
    for d in &devices {
        render_device_row(&mut s, lang, d, now, always_show_pwd);
    }
    let _ = write!(
        s,
        r##"</tbody>
</table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{count}</div>
</div>"##,
        count = tf1(lang, "devices.devices_count", &total.to_string()),
    );
    Ok(s)
}

/// 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,
    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");
    // `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),
    );

    // 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"),
        )
    };
    // 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"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-confirm="{confirm}">
          {label}
        </button>"##,
            id = html_escape(&d.id),
            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"
          hx-target="#devices-region" hx-swap="innerHTML">
          {label}
        </button>"##,
            id = html_escape(&d.id),
            label = t(lang, "devices.mark_managed"),
        )
    };

    let _ = write!(
        s,
        r##"<tr class="hover:bg-slate-800/40">
  {id_cell}
  <td class="px-3 py-2 text-slate-300">{owner}</td>
  <td class="px-3 py-2 text-slate-400">{host}</td>
  <td class="px-3 py-2 text-slate-300">{user}</td>
  <td class="px-3 py-2 whitespace-nowrap">{unattended_pwd}</td>
  <td class="px-3 py-2 text-slate-400">{os}</td>
  <td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
  <td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
  <td class="px-3 py-2 text-slate-400">{n}</td>
  {auth_cell}
  <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">
          {details}
        </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"
          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"
          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"
          hx-target="#devices-region" hx-swap="innerHTML"
          hx-confirm="{confirm_delete}">
          {delete_device}
        </button>
      </div>
    </details>
  </td>
</tr>"##,
        id_cell = id_cell,
        id = html_escape(&d.id),
        owner = html_escape(&d.owner_username),
        host = html_escape(&hostname),
        user = if active_user.is_empty() {
            "—".to_string()
        } else {
            html_escape(&active_user)
        },
        unattended_pwd = unattended_pwd_cell,
        os = html_escape(&os),
        ver = html_escape(&version_label),
        last = html_escape(&d.last_heartbeat_at),
        n = conn_count,
        auth_cell = auth_cell,
        toggle_managed_item = toggle_managed_item,
        connect_web = t(lang, "devices.connect_web"),
        details = t(lang, "devices.details"),
        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 can swap the table back into `#devices-region` without
/// re-rendering the whole page wrapper. Same shape as
/// `notice_then_table` minus the notice banner.
pub async fn list_fragment(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    Ok(Html(render_table(&state, lang).await?))
}

/// "Back to devices" — refetches the devices table fragment via HTMX
/// and swaps it back into `#devices-region`. Used by the detail page.
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">{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) -> 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"),
        ),
    };

    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">{inventory}</h3>
  {inv}
</div>"##,
        back = back_button(lang),
        detail_view = t(lang, "devices.detail_view"),
        inventory = t(lang, "devices.inventory"),
        header = header,
        inv = inventory_section,
    )
}

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,
    kind: &str,
    msg: &str,
) -> Result<Html<String>, ApiError> {
    let mut html = notice_html(kind, msg);
    html.push_str(&render_table(state, lang).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()))
    }
}
