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

pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let table = render_table(&state).await?;
    Ok(Html(format!(
        r##"<div class="space-y-6">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">Devices</h2>
    <p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
  </header>
  <section id="devices-region">
    {table}
  </section>
</div>"##
    )))
}

pub async fn force_disconnect(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    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,
        "ok",
        &format!("Queued disconnect for {} (conns={})", peer_id, conns),
    )
    .await
}

pub async fn force_sysinfo(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    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,
        "ok",
        &format!("Queued sysinfo refresh for {}", peer_id),
    )
    .await
}

pub async fn delete(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    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 {
        format!("Deleted device {}.", peer_id)
    } else {
        "Device already gone.".to_string()
    };
    notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
}

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

async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
    let (total, devices) = state
        .db
        .devices_list_all(0, PAGE_SIZE)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    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">Peer ID</th>
      <th class="text-left font-medium px-3 py-2">Owner</th>
      <th class="text-left font-medium px-3 py-2">Hostname</th>
      <th class="text-left font-medium px-3 py-2">OS</th>
      <th class="text-left font-medium px-3 py-2">Last heartbeat</th>
      <th class="text-left font-medium px-3 py-2">Conns</th>
      <th class="text-right font-medium px-3 py-2 w-1">Actions</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-800">"##
    );
    if devices.is_empty() {
        s.push_str(
            r##"<tr><td colspan="7" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
        );
    }
    for d in &devices {
        render_device_row(&mut s, d);
    }
    let _ = write!(
        s,
        r##"</tbody>
</table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
</div>"##
    );
    Ok(s)
}

fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
    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");
    let os = pick("os");
    let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
        .map(|v| v.len())
        .unwrap_or(0);
    let _ = write!(
        s,
        r##"<tr class="hover:bg-slate-800/40">
  <td class="px-3 py-2 font-mono text-slate-200">{id}</td>
  <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-400">{os}</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>
  <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 client)
        </a>
        <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="Disconnect all active sessions on {id}?">
          Force disconnect
        </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 refresh
        </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="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
          Delete device
        </button>
      </div>
    </details>
  </td>
</tr>"##,
        id = html_escape(&d.id),
        owner = html_escape(&d.owner_username),
        host = html_escape(&hostname),
        os = html_escape(&os),
        last = html_escape(&d.last_heartbeat_at),
        n = conn_count
    );
}

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

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