"use strict";

// ─── tiny helpers ──────────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));

async function api(method, path, body) {
  const opts = { method, headers: {} };
  // Server requires application/json on every POST/DELETE (CSRF hardening:
  // HTML forms can never set this content type), so set it even when there
  // is no body to send.
  if (method === "POST" || method === "DELETE") {
    opts.headers["Content-Type"] = "application/json";
  }
  if (body !== undefined) {
    opts.body = JSON.stringify(body);
  }
  const res = await fetch(path, opts);
  const ct = res.headers.get("content-type") || "";
  const data = ct.includes("application/json") ? await res.json() : await res.text();
  if (!res.ok) {
    const msg = data && data.error ? data.error : (typeof data === "string" ? data : "Request failed");
    throw new Error(msg);
  }
  return data;
}

let toastTimer;
function toast(msg, kind) {
  const t = $("#toast");
  t.textContent = msg;
  t.className = "toast" + (kind ? " " + kind : "");
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => t.classList.add("hidden"), 3200);
}

function fmtBytes(n) {
  n = Number(n) || 0;
  if (n < 1024) return n + " B";
  const u = ["KB", "MB", "GB", "TB"];
  let i = -1;
  do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
  return n.toFixed(n < 10 ? 1 : 0) + " " + u[i];
}

function fmtAgo(ts) {
  if (!ts) return "never";
  const s = Math.floor(Date.now() / 1000) - ts;
  if (s < 0) return "just now";
  if (s < 60) return s + "s ago";
  if (s < 3600) return Math.floor(s / 60) + "m ago";
  if (s < 86400) return Math.floor(s / 3600) + "h ago";
  return Math.floor(s / 86400) + "d ago";
}

function fmtDate(ts) {
  if (!ts) return "—";
  const d = new Date(ts * 1000);
  return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}

// ─── state ───────────────────────────────────────────────────────────────────────
let clientsCache = [];
let clientFilter = "";
let refreshTimer;
let clientRows = new Map(); // id -> row (keyed DOM reconciliation, see paintClients)
let hideDisabled = false;
let hideOffline = false;

// ─── initial load / routing ────────────────────────────────────────────────────
async function boot() {
  try {
    const setup = await api("GET", "/api/setup-status");
    $("#loading").classList.add("hidden");
    if (!setup.server_initialized) {
      showSetup(setup);
    } else {
      showDashboard();
    }
  } catch (e) {
    $("#loading").textContent = "Error: " + e.message;
  }
}

function showView(id) {
  $$(".view").forEach((v) => v.classList.add("hidden"));
  $(id).classList.remove("hidden");
}

// ─── setup wizard ──────────────────────────────────────────────────────────────
function showSetup(setup) {
  showView("#setup");
  const badge = $("#depsBadge");
  if (setup.deps_installed) {
    badge.textContent = "Installed";
    badge.className = "badge ok";
    $("#installDepsBtn").disabled = true;
  } else {
    badge.textContent = "Not installed";
    badge.className = "badge no";
  }
}

$("#installDepsBtn").addEventListener("click", async (e) => {
  const btn = e.currentTarget;
  btn.disabled = true;
  btn.innerHTML = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> Installing… (may take minutes)';
  try {
    await api("POST", "/api/install-deps");
    toast("Dependencies installed", "ok");
    boot();
  } catch (err) {
    toast(err.message, "err");
    btn.disabled = false;
    btn.textContent = "Install dependencies";
  }
});

$("#initForm").addEventListener("submit", async (e) => {
  e.preventDefault();
  const btn = e.target.querySelector('button[type=submit]');
  const fd = new FormData(e.target);
  const body = {};
  for (const [k, v] of fd.entries()) body[k] = v.trim();
  btn.disabled = true;
  btn.innerHTML = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> Initialising…';
  try {
    await api("POST", "/api/init-server", body);
    toast("Server initialised", "ok");
    boot();
  } catch (err) {
    toast(err.message, "err");
    btn.disabled = false;
    btn.textContent = "Initialise server";
  }
});

// ─── dashboard ─────────────────────────────────────────────────────────────────
async function showDashboard() {
  showView("#dashboard");
  paintSkeleton();
  await refreshAll();
  clearInterval(refreshTimer);
  refreshTimer = setInterval(refreshAll, 10000);
}

// paintSkeleton shows shimmer placeholders until the first data arrives, so the
// dashboard never flashes empty "–" cells (Feedback: loading states).
function paintSkeleton() {
  const list = $("#clientList");
  list.innerHTML = "";
  for (let n = 0; n < 3; n++) {
    const row = document.createElement("div");
    row.className = "client skeleton";
    row.innerHTML =
      '<span class="sk sk-dot"></span>' +
      '<div class="info"><div class="sk sk-line1"></div><div class="sk sk-line2"></div></div>' +
      '<span class="sk sk-tr"></span>';
    list.appendChild(row);
  }
}

let refreshing = false;
async function refreshAll() {
  if (refreshing) return;
  refreshing = true;
  const icon = $("#refreshBtn .ic");
  if (icon) icon.classList.add("spin"); // auto-refresh activity indicator
  try {
    const [status, clients] = await Promise.all([
      api("GET", "/api/status"),
      api("GET", "/api/clients"),
    ]);
    renderStatus(status);
    renderClients(clients);
  } catch (e) {
    toast(e.message, "err");
  } finally {
    refreshing = false;
    if (icon) icon.classList.remove("spin");
  }
}

function renderStatus(s) {
  $("#stTotal").textContent = s.total_clients ?? 0;
  $("#stActive").textContent = s.active_clients ?? 0;
  $("#stOnline").textContent = s.online_clients ?? 0;
  $("#svIface").textContent = s.interface || "–";
  $("#svEndpoint").textContent = (s.public_ip || "?") + ":" + (s.port || "?");
  $("#svNetwork").textContent = s.network || "–";
  $("#svKey").textContent = s.public_key || "–";
  const b = $("#ifaceBadge");
  if (s.interface_up) { b.innerHTML = '<span class="badge-dot"></span>UP'; b.className = "badge up"; }
  else { b.innerHTML = '<span class="badge-dot"></span>DOWN'; b.className = "badge down"; }
}

// renderClients stores the fresh list, then paints it through the active filter.
function renderClients(clients) {
  clientsCache = clients;
  paintClients();
}

// createClientRow builds a client row's DOM once and wires its event handlers
// once. The row object keeps references to the bits that can change plus the
// last-painted value of each, so later updates touch only what actually
// changed instead of tearing the node down (see updateClientRow).
function createClientRow(c) {
  const el = document.createElement("div");
  el.className = "client";
  el.setAttribute("role", "button");
  el.tabIndex = 0;
  el.innerHTML = `
    <span class="dot"></span>
    <div class="info">
      <div class="name"></div>
      <div class="sub"></div>
      <div class="note hidden"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-message"/></svg><span></span></div>
    </div>
    <div class="traffic">
      <div class="down"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-down"/></svg><span></span></div>
      <div class="up"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-up"/></svg><span></span></div>
    </div>
    <label class="switch">
      <input type="checkbox" />
      <span class="slider"></span>
    </label>`;

  const row = {
    id: c.id,
    el,
    dot: el.querySelector(".dot"),
    name: el.querySelector(".name"),
    sub: el.querySelector(".sub"),
    note: el.querySelector(".note"),
    noteText: el.querySelector(".note span"),
    rx: el.querySelector(".down span"),
    tx: el.querySelector(".up span"),
    switchLabel: el.querySelector(".switch"),
    input: el.querySelector(".switch input"),
    // last-painted values, used by updateClientRow to skip no-op writes
    _dotClass: null, _name: null, _sub: null, _note: null,
    _rx: null, _tx: null, _enabled: null,
  };

  el.addEventListener("click", (e) => {
    // Ignore clicks that originate on the toggle switch.
    if (e.target.closest(".switch")) return;
    openDetail(row.id);
  });
  // Keyboard activation (role="button"): Enter/Space open the detail view,
  // but not when focus is on the inner switch (it has its own handling).
  el.addEventListener("keydown", (e) => {
    if (e.target.closest(".switch")) return;
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      openDetail(row.id);
    }
  });
  row.input.addEventListener("change", () => toggleClient(row.id, row.input));

  updateClientRow(row, c);
  return row;
}

// updateClientRow paints only the fields that changed since the last call,
// so a 10s refresh with unchanged data touches no DOM at all — the row's
// entrance animation and focus/scroll state are left completely alone.
function updateClientRow(row, c) {
  const enabled = c.is_enabled === "ACTIVE";

  const dotClass = "dot" + (!enabled ? " disabled" : (c.online ? " online" : ""));
  if (dotClass !== row._dotClass) {
    row.dot.className = dotClass;
    row._dotClass = dotClass;
  }

  if (c.name !== row._name) {
    row.name.textContent = c.name;
    row.el.setAttribute("aria-label", "Open " + c.name);
    row.input.setAttribute("aria-label", "Enable " + c.name);
    row._name = c.name;
  }

  const handshake = enabled
    ? (c.online ? "online" : (c.latest_handshake ? fmtAgo(c.latest_handshake) : "—"))
    : "disabled";
  const sub = c.ip + " · " + handshake;
  if (sub !== row._sub) {
    row.sub.textContent = sub;
    row._sub = sub;
  }

  const noteVal = c.comment || "";
  if (noteVal !== row._note) {
    row.note.classList.toggle("hidden", !noteVal);
    if (noteVal) {
      row.note.title = noteVal;
      row.noteText.textContent = noteVal;
    }
    row._note = noteVal;
  }

  const rx = fmtBytes(c.total_rx);
  if (rx !== row._rx) { row.rx.textContent = rx; row._rx = rx; }
  const tx = fmtBytes(c.total_tx);
  if (tx !== row._tx) { row.tx.textContent = tx; row._tx = tx; }

  if (enabled !== row._enabled) {
    row.switchLabel.title = enabled ? "Enabled" : "Disabled";
    // Don't stomp on the checkbox mid-toggle: toggleClient disables it for
    // the duration of its request and repaints once that settles.
    if (!row.input.disabled) row.input.checked = enabled;
    row._enabled = enabled;
  }
}

// clientRank orders the list so online clients surface at the top; everyone
// else (enabled-idle and disabled) keeps registry order.
function clientRank(c) {
  return c.online ? 0 : 1;
}

// paintClients reconciles clientsCache (filtered by search/hideDisabled and
// sorted by clientRank) against the DOM by client id instead of rebuilding
// the list from scratch.
// Existing rows are matched by id and patched in place; only genuinely new
// rows are created (and get the entrance animation) and only genuinely
// removed/filtered-out rows are dropped. On a steady-state refresh this is a
// no-op for structure — nothing is torn down, so the list never flashes.
function paintClients() {
  const list = $("#clientList");
  if (list.querySelector(".skeleton")) {
    // First real paint after the loading skeleton: those rows aren't keyed.
    list.innerHTML = "";
    clientRows.clear();
  }

  const q = clientFilter.trim().toLowerCase();
  const clients = clientsCache.filter((c) => {
    if (hideDisabled && c.is_enabled !== "ACTIVE") return false;
    if (hideOffline && !c.online) return false;
    if (!q) return true;
    return (c.name || "").toLowerCase().includes(q) ||
      (c.ip || "").toLowerCase().includes(q) ||
      (c.comment || "").toLowerCase().includes(q);
  });
  // Online clients first; stable sort keeps registry order within each group.
  clients.sort((a, b) => clientRank(a) - clientRank(b));

  $("#clientEmpty").classList.toggle("hidden", clientsCache.length > 0);
  $("#clientNoMatch").classList.toggle("hidden", !(clientsCache.length > 0 && clients.length === 0));

  const seen = new Set();
  let prevEl = null;
  clients.forEach((c, i) => {
    seen.add(c.id);
    let row = clientRows.get(c.id);
    if (!row) {
      row = createClientRow(c);
      row.el.style.animationDelay = (Math.min(i, 8) * 35) + "ms";
      clientRows.set(c.id, row);
    } else {
      updateClientRow(row, c);
    }
    const ref = prevEl ? prevEl.nextSibling : list.firstChild;
    if (ref !== row.el) list.insertBefore(row.el, ref);
    prevEl = row.el;
  });

  for (const [id, row] of clientRows) {
    if (!seen.has(id)) {
      row.el.remove();
      clientRows.delete(id);
    }
  }
}

// toggleClient enables/disables a client from its row switch. The input is
// disabled during the request and reverted on failure.
async function toggleClient(id, input) {
  const act = input.checked ? "enable" : "disable";
  input.disabled = true;
  try {
    await api("POST", "/api/clients/" + id + "/" + act);
    toast("Client " + act + "d", "ok");
    await refreshAll();
  } catch (err) {
    input.checked = !input.checked; // revert optimistic flip
    toast(err.message, "err");
  } finally {
    input.disabled = false;
  }
}

// ─── server controls ───────────────────────────────────────────────────────────
$$("[data-server]").forEach((btn) => {
  btn.addEventListener("click", async () => {
    const action = btn.dataset.server;
    btn.disabled = true;
    try {
      await api("POST", "/api/server/" + action);
      toast("Server " + action + "ed", "ok");
      await refreshAll();
    } catch (e) {
      toast(e.message, "err");
    } finally {
      btn.disabled = false;
    }
  });
});

$("#syncBtn").addEventListener("click", async (e) => {
  const btn = e.currentTarget; // capture before await (currentTarget nulls out)
  const restart = confirm("Rebuild interface config from the registry.\n\nRestart the service to apply now?");
  btn.disabled = true;
  try {
    await api("POST", "/api/server/sync", { restart });
    toast("Config synced" + (restart ? " & restarted" : ""), "ok");
    await refreshAll();
  } catch (err) {
    toast(err.message, "err");
  } finally {
    btn.disabled = false;
  }
});

$("#refreshBtn").addEventListener("click", () => { if (!$("#dashboard").classList.contains("hidden")) refreshAll(); else boot(); });

// ─── search / filters ──────────────────────────────────────────────────────────
$("#clientSearch").addEventListener("input", (e) => {
  clientFilter = e.target.value;
  paintClients();
});

$("#hideDisabled").addEventListener("change", (e) => {
  hideDisabled = e.target.checked;
  paintClients();
});

$("#hideOffline").addEventListener("change", (e) => {
  hideOffline = e.target.checked;
  paintClients();
});

// ─── create client ─────────────────────────────────────────────────────────────
$("#createBtn").addEventListener("click", () => openModal("#createModal"));
$("#createForm").addEventListener("submit", async (e) => {
  e.preventDefault();
  const btn = e.target.querySelector('button[type=submit]');
  const name = e.target.name.value.trim();
  if (!name) return;
  btn.disabled = true;
  try {
    const c = await api("POST", "/api/clients", { name });
    closeModals();
    e.target.reset();
    toast("Client “" + c.name + "” created", "ok");
    await refreshAll();
    openDetail(c.id);
  } catch (err) {
    toast(err.message, "err");
  } finally {
    btn.disabled = false;
  }
});

// ─── client detail ─────────────────────────────────────────────────────────────
let detailId = null;
function openDetail(id) {
  const c = clientsCache.find((x) => x.id === id);
  if (!c) return;
  detailId = id;
  $("#dName").textContent = c.name;
  const st = $("#dStatus");
  if (c.is_enabled === "ACTIVE") {
    st.innerHTML = c.online
      ? '<span class="badge up">Online</span>'
      : '<span class="badge">Active · idle</span>';
  } else {
    st.innerHTML = '<span class="badge no">Disabled</span>';
  }
  $("#dIP").textContent = c.ip;
  $("#dEndpoint").textContent = c.endpoint || "—";
  $("#dHandshake").textContent = c.is_enabled === "ACTIVE" ? fmtAgo(c.latest_handshake) : "—";
  $("#dRx").textContent = fmtBytes(c.total_rx);
  $("#dTx").textContent = fmtBytes(c.total_tx);
  $("#dSince").textContent = fmtDate(c.stats_since);
  $("#dQR").src = "/api/clients/" + id + "/qr?t=" + Date.now();
  $("#dDownload").href = "/api/clients/" + id + "/config";
  $("#dDownload").setAttribute("download", c.name + ".conf");

  renderComment(c.comment || "");

  openModal("#detailModal");
}

// renderComment shows the note (or a placeholder) and collapses the editor back
// to its read-only view.
function renderComment(comment) {
  const view = $("#dCommentView");
  if (comment) {
    view.textContent = comment;
    view.classList.remove("muted");
  } else {
    view.textContent = "— no comment —";
    view.classList.add("muted");
  }
  $("#dCommentView").classList.remove("hidden");
  $("#dCommentEditor").classList.add("hidden");
  $("#dCommentEdit").classList.remove("hidden");
}

$("#dCommentEdit").addEventListener("click", () => {
  const c = clientsCache.find((x) => x.id === detailId);
  $("#dCommentText").value = c ? (c.comment || "") : "";
  $("#dCommentView").classList.add("hidden");
  $("#dCommentEdit").classList.add("hidden");
  $("#dCommentEditor").classList.remove("hidden");
  $("#dCommentText").focus();
});

$("#dCommentCancel").addEventListener("click", () => {
  const c = clientsCache.find((x) => x.id === detailId);
  renderComment(c ? (c.comment || "") : "");
});

$("#dCommentSave").addEventListener("click", async (e) => {
  const btn = e.currentTarget;
  const id = detailId;
  const comment = $("#dCommentText").value.trim();
  btn.disabled = true;
  try {
    const res = await api("POST", "/api/clients/" + id + "/comment", { comment });
    const saved = res.comment || "";
    const c = clientsCache.find((x) => x.id === id);
    if (c) c.comment = saved; // keep cache in sync without a full refetch
    renderComment(saved);
    toast("Comment saved", "ok");
  } catch (err) {
    toast(err.message, "err");
  } finally {
    btn.disabled = false;
  }
});

$("#dDelete").addEventListener("click", async (e) => {
  // Capture the button now: e.currentTarget is null after the first await, so
  // re-enabling it in finally must not go through the event object (that threw
  // and left the button permanently disabled — the "delete works only once" bug).
  const btn = e.currentTarget;
  const id = detailId;
  const c = clientsCache.find((x) => x.id === id);
  if (!confirm("Delete client “" + (c ? c.name : id) + "”? This cannot be undone.")) return;
  btn.disabled = true;
  try {
    await api("DELETE", "/api/clients/" + id);
    closeModals();
    toast("Client deleted", "ok");
    await refreshAll();
  } catch (err) {
    toast(err.message, "err");
  } finally {
    btn.disabled = false;
  }
});

// ─── modal plumbing ────────────────────────────────────────────────────────────
function openModal(sel) { $(sel).classList.remove("hidden"); }
$("#dResetStats").addEventListener("click", async (e) => {
  const btn = e.currentTarget; // capture before await (see #dDelete note)
  const id = detailId;
  if (!confirm("Reset accumulated traffic stats for this client?")) return;
  btn.disabled = true;
  try {
    await api("POST", "/api/clients/" + id + "/stats/reset");
    toast("Stats reset", "ok");
    await refreshAll();
    if (clientsCache.find((x) => x.id === id)) openDetail(id); // re-render with zeroed totals
  } catch (err) {
    toast(err.message, "err");
  } finally {
    btn.disabled = false;
  }
});

function closeModals() { $$(".modal").forEach((m) => m.classList.add("hidden")); }
$$("[data-close]").forEach((b) => b.addEventListener("click", closeModals));
$$(".modal").forEach((m) => m.addEventListener("click", (e) => { if (e.target === m) closeModals(); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModals(); });

boot();
