// ================ helpers ================
const $  = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
// Shared status chrome used by all module browsers (mods / workshop / library).
const installedBadge = () => `<span style="font-size:12px;padding:6px 12px;border-radius:var(--radius);background:rgba(46,224,107,.14);color:var(--ok);font-weight:600">✓ Already installed</span>`;
const tagChip = t => `<span style="font-size:11px;padding:2px 8px;border-radius:var(--radius-pill);background:rgba(255,255,255,0.06);border:1px solid var(--border)">${esc(t)}</span>`;

// ---- shared empty / loading / error states -------------------------------
// Single-color inline SVG glyphs (styled via .empty .big → currentColor)
// replacing the color-emoji era. Keep 24x24 viewBox, fill currentColor.
const EMPTY_ICONS = {
  server:  '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M4 3h16a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm0 11h16a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Zm2-9v3h2V5H6Zm0 11v3h2v-3H6Z"/></svg>',
  search:  '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M10 2a8 8 0 1 0 4.9 14.32l5.39 5.38 1.42-1.42-5.39-5.38A8 8 0 0 0 10 2Zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z"/></svg>',
  clock:   '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8Zm1-13h-2v6l5.25 3.15 1-1.65-4.25-2.5Z"/></svg>',
  disk:    '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M5 3h11l5 5v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm2 2v5h9V5H7Zm5 8a3 3 0 1 0 3 3 3 3 0 0 0-3-3Z"/></svg>',
  health:  '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M3 12h4l2-6 4 12 2-6h6v2h-4.5l-3.5 8-4-13-1.5 5H3Z"/></svg>',
  puzzle:  '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M10 2a2.5 2.5 0 0 0-2.5 2.5V5H4a1 1 0 0 0-1 1v3.5h.5a2.5 2.5 0 0 1 0 5H3V19a1 1 0 0 0 1 1h3.5v-.5a2.5 2.5 0 0 1 5 0v.5H16a1 1 0 0 0 1-1v-3.5h.5a2.5 2.5 0 0 0 0-5H17V6a1 1 0 0 0-1-1h-3.5v-.5A2.5 2.5 0 0 0 10 2Z"/></svg>',
  box:     '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="m12 2 9 4.5v11L12 22l-9-4.5v-11Zm0 2.24L5.85 7.31 12 10.38l6.15-3.07ZM5 8.93v7.33l6 3v-7.33Zm14 0-6 3v7.33l6-3Z"/></svg>',
  users:   '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M9 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4Zm0 2c-3.33 0-7 1.67-7 4v2h14v-2c0-2.33-3.67-4-7-4Zm8-2a3 3 0 1 0-3-3 3 3 0 0 0 3 3Zm.5 2.13A9.16 9.16 0 0 1 21 16.5V19h3v-2c0-1.77-2.11-3.24-4.5-3.87Z" transform="translate(-1 0) scale(.92)"/></svg>',
  moon:    '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M12.34 2.02a10 10 0 1 0 9.64 12.32 8 8 0 0 1-9.64-12.32Z"/></svg>',
  antenna: '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor" aria-hidden="true"><path d="M12 8a3 3 0 0 1 3 3 3 3 0 0 1-1.5 2.6L17 22h-2.2l-.9-2h-3.8l-.9 2H7l3.5-8.4A3 3 0 0 1 9 11a3 3 0 0 1 3-3Zm0 6.5-1 2.5h2ZM5.64 4.64l1.41 1.41a7 7 0 0 0 0 9.9l-1.41 1.41a9 9 0 0 1 0-12.72Zm12.72 0a9 9 0 0 1 0 12.72l-1.41-1.41a7 7 0 0 0 0-9.9Z"/></svg>',
};
function emptyState(icon, title, hint) {
  const ic = icon ? (EMPTY_ICONS[icon] || icon) : "";
  return `<div class="empty">${ic ? `<div class="big">${ic}</div>` : ""}${title || ""}${hint ? `<span class="hint">${hint}</span>` : ""}</div>`;
}
function loadingState(label) {
  return `<div class="empty"><span class="dim">${esc(label || "Loading…")}</span></div>`;
}
function errorState(msg) {
  return `<div class="empty"><div class="big" style="font-size:30px;color:var(--warn)">⚠</div>${esc(msg || "Something went wrong.")}</div>`;
}

// Docker reports CPU as percent-of-one-core summed across all cores, so a
// busy server on a big host can read "600%" (= 6 cores). Humans expect
// percent-of-the-whole-machine, so when the agent told us the host's core
// count we show that, with the core count alongside. Falls back to a plain
// cores figure for stats from agents that predate host_cpus.
function fmtCpu(stats) {
  const raw = Number(stats.cpu) || 0;
  const cores = raw / 100;
  if (stats.hostCpus > 0) {
    const hostPct = Math.min(100, raw / stats.hostCpus);
    return {
      barPct: hostPct,
      text: `${hostPct.toFixed(0)}%`,
      long: `${hostPct.toFixed(0)}% · ${cores.toFixed(1)}c`,
      title: `${cores.toFixed(1)} of ${stats.hostCpus} host cores in use (docker-style: ${raw.toFixed(0)}%)`,
    };
  }
  return {
    barPct: Math.min(100, raw),
    text: `${cores.toFixed(1)}c`,
    long: `${cores.toFixed(1)} cores`,
    title: `${cores.toFixed(1)} CPU cores in use (docker-style: ${raw.toFixed(0)}%)`,
  };
}

// Shared right-click context menu. Items: [{label, icon?, onClick, danger?, divider?, disabled?, title?}].
function panelContextMenu({ x, y, items }) {
  document.querySelectorAll('.panel-ctxmenu').forEach(m => m.remove());
  const menu = document.createElement('div');
  menu.className = 'panel-ctxmenu';
  menu.style.left = '0px'; menu.style.top = '0px';
  for (const it of items) {
    if (it.divider) {
      const d = document.createElement('div'); d.className = 'panel-ctxmenu-divider'; menu.appendChild(d); continue;
    }
    const el = document.createElement('div');
    el.className = 'panel-ctxmenu-item' + (it.danger ? ' danger' : '') + (it.disabled ? ' disabled' : '');
    if (it.title) el.title = it.title;
    el.innerHTML = `<span class="panel-ctxmenu-icon">${it.icon || ''}</span><span>${it.label}</span>`;
    if (!it.disabled) el.addEventListener('click', () => { closeMenu(); try { it.onClick?.(); } catch (e) { console.error(e); } });
    menu.appendChild(el);
  }
  document.body.appendChild(menu);
  // Position with viewport flip.
  const r = menu.getBoundingClientRect();
  let nx = x, ny = y;
  if (nx + r.width > window.innerWidth - 8)  nx = Math.max(8, window.innerWidth - r.width - 8);
  if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
  menu.style.left = nx + 'px'; menu.style.top = ny + 'px';
  function closeMenu() { menu.remove(); document.removeEventListener('mousedown', onAway, true); document.removeEventListener('keydown', onKey, true); }
  function onAway(e) { if (!menu.contains(e.target)) closeMenu(); }
  function onKey(e) { if (e.key === 'Escape') closeMenu(); }
  setTimeout(() => {
    document.addEventListener('mousedown', onAway, true);
    document.addEventListener('keydown', onKey, true);
  }, 0);
  return { close: closeMenu };
}

function toast(msg, kind, ms) {
  const host = $("#toasts");
  // Screen readers announce appended toasts without stealing focus.
  if (!host.hasAttribute("aria-live")) {
    host.setAttribute("aria-live", "polite");
    host.setAttribute("aria-relevant", "additions");
  }
  const el = document.createElement("div");
  el.className = "toast " + (kind || "");
  el.innerHTML = `<button class="close" type="button" aria-label="Dismiss">×</button>${esc(msg)}`;
  host.appendChild(el);
  // Fade out via .leaving, remove on transitionend (fallback timer in case
  // transitions are disabled, e.g. prefers-reduced-motion).
  const leave = () => {
    if (el.classList.contains("leaving")) return;
    el.classList.add("leaving");
    el.addEventListener("transitionend", () => el.remove(), { once: true });
    setTimeout(() => el.remove(), 400);
  };
  el.querySelector(".close").addEventListener("click", leave);
  // Default 5s; longer for sticky info ("warn" / explicit ms override).
  // TTL pauses while hovered so the operator can finish reading.
  const ttl = (typeof ms === "number" && ms > 0) ? ms : 5000;
  let timer = setTimeout(leave, ttl);
  el.addEventListener("mouseenter", () => { clearTimeout(timer); timer = null; });
  el.addEventListener("mouseleave", () => { if (!timer) timer = setTimeout(leave, ttl); });
}
function setStatus(t, cls) {
  $("#status").textContent = t;
  const d = $("#status-dot");
  d.className = "dot " + (cls === "ok" ? "ok" : cls === "err" ? "err" : "");
}
async function fetchJSON(url, opts) {
  const r = await fetch(url, opts);
  if (r.status === 401) { location.href = "/login?next=" + encodeURIComponent(location.pathname); throw new Error("unauthenticated"); }
  if (!r.ok) {
    // Read the body ONCE as text, then try to parse it as JSON. Calling
    // r.json() and THEN r.text() on the same Response throws "body stream
    // already read" — which masked the real error on the ARK Files tab
    // (Ragnarok's big save dir returns a non-JSON / empty error body, so
    // r.json() threw, and the catch's r.text() hit the locked stream).
    const raw = await r.text().catch(() => "");
    let body = { message: raw };
    try { body = JSON.parse(raw); } catch {}
    // Attach status + machine-readable code so callers can branch on a specific
    // failure (e.g. the 7DTD 409 "version_locked") instead of matching on the
    // human message text. writeError() emits {code, message}; older endpoints
    // emit {error}. Backward-compatible: existing callers still read .message.
    const e = new Error(body.message || body.error || raw || `${r.status}`);
    e.status = r.status;
    e.code = body.code || (typeof body.error === "string" ? body.error : "") || "";
    throw e;
  }
  return r.json();
}

// ================ Build-tag capabilities ================
// The controller advertises optional (build-tag-gated) features via
// GET /api/capabilities. Default is everything OFF: a public build
// shows no RefugeBot install UI and no SaaS cross-sell link.
window.PANEL_CAPS = {};
document.addEventListener("DOMContentLoaded", async () => {
  try {
    window.PANEL_CAPS = await fetchJSON("/api/capabilities");
  } catch { return; } // caps stay {} → all gated UI hidden
  if (window.PANEL_CAPS.refugebot) {
    // Inject the RefugeBot Server sidebar link (Tools section, above Steam).
    const steam = document.querySelector('.side-link[data-scroll="steam"]');
    if (steam && !document.getElementById("side-refugebot")) {
      const a = document.createElement("a");
      a.id = "side-refugebot";
      a.className = "side-link";
      a.href = "https://app.refugegaming.org";
      a.target = "_blank";
      a.rel = "noopener";
      a.title = "Open RefugeBot Server (app.refugegaming.org)";
      a.innerHTML = '<span class="ic">🤖</span><span>RefugeBot Server</span><span style="margin-left:auto;opacity:.5;font-size:12px">↗</span>';
      steam.parentNode.insertBefore(a, steam);
    }
  }
});

// ================ Global action indicator ================
//
// runAction(buttonEl, asyncFn, opts?) wraps any async handler so the
// button shows live feedback — spinner + bottom progress bar during
// the action, a green ✓ with success label for 1.5 s on resolve, a
// red ✕ with the error tooltip for 2.5 s on reject. The button's
// original content is preserved and restored.
//
// A top-of-page 2px progress strip shows when ANY action is in flight,
// giving the whole UI a "something is happening" signal.
//
// Apply to any button that kicks off an async operation. Zero-cost if
// a button doesn't use it — legacy buttons keep working unchanged.

let _actInFlight = 0;
function _actBumpGlobal(delta) {
  _actInFlight = Math.max(0, _actInFlight + delta);
  const bar = $("#act-progress-global");
  if (bar) bar.classList.toggle("on", _actInFlight > 0);
}

async function runAction(btn, fn, opts) {
  opts = opts || {};
  if (!btn) return fn();
  // Overlay approach — no more clobbering the button's label. The button's
  // underlying innerHTML (■ Stop / ▶ Start / ↻ Restart / etc.) stays 100%
  // owned by updateCard. We attach an absolutely-positioned overlay span
  // with the loading / success / error indicator; updateCard can repaint
  // the button label any time it wants without fighting us, and we just
  // remove our overlay when done. Eliminates the "button briefly reverts
  // from Start back to Stop after the 1.5s success hold" class of bugs.
  const origDisabled = btn.disabled;
  const origPosition = btn.style.position;
  if (!btn.style.position) btn.style.position = "relative";
  const overlay = document.createElement("span");
  overlay.className = "act-overlay is-loading";
  overlay.innerHTML = `<span class="act-spin"></span><span>${esc(opts.loadingLabel || labelFromNode(btn) + "…")}</span>`;
  btn.appendChild(overlay);
  btn.disabled = true;
  btn.dataset._actRestore = "1";
  _actBumpGlobal(+1);
  const removeOverlay = () => {
    if (overlay.parentNode === btn) btn.removeChild(overlay);
    if (!origPosition) btn.style.removeProperty("position");
    btn.disabled = origDisabled;
    delete btn.dataset._actRestore;
    btn.title = "";
  };
  try {
    const result = await fn();
    overlay.classList.remove("is-loading");
    overlay.classList.add("is-success");
    overlay.innerHTML = `<span class="act-check">✓</span><span>${esc(opts.successLabel || "Success")}</span>`;
    setTimeout(removeOverlay, opts.successHold || 1200);
    return result;
  } catch (err) {
    overlay.classList.remove("is-loading");
    overlay.classList.add("is-error");
    overlay.innerHTML = `<span class="act-x">✕</span><span>${esc(opts.errorLabel || "Failed")}</span>`;
    btn.title = (err && err.message) || String(err);
    setTimeout(removeOverlay, opts.errorHold || 2200);
    throw err;
  } finally {
    _actBumpGlobal(-1);
  }
}

// labelFromNode strips existing icon children and returns plain text.
function labelFromNode(el) {
  const clone = el.cloneNode(true);
  clone.querySelectorAll(".act-spin, .act-check, .act-x").forEach(n => n.remove());
  return clone.textContent.trim();
}
let _modalZ = 100;

// ---- modal close stack + focus trap --------------------------------------
// Every open modal (static #id modals AND dynamically-built wrappers) pushes
// one close function here, in open order. Escape pops ONLY the top entry, so
// stacked modals close one per keypress and _modalZ decrements exactly once
// per close (fixes the old per-modal-keydown double-decrement drift).
const _modalCloseStack = [];
function _modalStackPush(fn) { _modalCloseStack.push(fn); return fn; }
function _modalStackRemove(fn) {
  const i = _modalCloseStack.indexOf(fn);
  if (i >= 0) _modalCloseStack.splice(i, 1);
}
function closeTopModal() {
  const fn = _modalCloseStack[_modalCloseStack.length - 1];
  if (fn) fn();
  return !!fn;
}
// trapFocus: wrap Tab / Shift+Tab at the first/last focusable inside the
// modal wrapper so keyboard focus never escapes to the page behind it.
function trapFocus(bg) {
  if (bg._focusTrapped) return;
  bg._focusTrapped = true;
  const SEL = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
  bg.addEventListener("keydown", e => {
    if (e.key !== "Tab") return;
    const items = [...bg.querySelectorAll(SEL)].filter(el => el.offsetParent !== null);
    if (!items.length) { e.preventDefault(); return; }
    const first = items[0], last = items[items.length - 1];
    const inside = bg.contains(document.activeElement);
    if (e.shiftKey && (!inside || document.activeElement === first)) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && (!inside || document.activeElement === last)) { e.preventDefault(); first.focus(); }
  });
}
// initDynamicModal: shared chrome for dynamically-built modal wrappers —
// z-bump, aria, focus trap, backdrop click, and stack registration. Returns
// the close() the builder should use. Escape is handled centrally.
function initDynamicModal(bg, onClose) {
  bg.setAttribute("role", "dialog");
  bg.setAttribute("aria-modal", "true");
  _modalZ += 2; bg.style.zIndex = _modalZ;
  trapFocus(bg);
  const close = () => {
    _modalStackRemove(close);
    bg.remove();
    _modalZ = Math.max(100, _modalZ - 2);
    if (onClose) onClose();
  };
  _modalStackPush(close);
  bg._stackClose = close; // lets the shared [data-close] handler close us too
  bg.addEventListener("click", e => { if (e.target === bg) close(); });
  return close;
}
function showModal(id) {
  const m = $(id); if (!m) return;
  m.classList.remove("closing");
  m.setAttribute("role", "dialog");
  m.setAttribute("aria-modal", "true");
  _modalZ += 2;
  m.style.zIndex = _modalZ;
  m.style.display = "flex";
  trapFocus(m);
  if (m._stackClose) _modalStackRemove(m._stackClose); // re-show → move to top
  m._stackClose = _modalStackPush(() => hideModal(id));
}
function hideModal(id) {
  const m = $(id); if (!m || m.style.display === "none") return;
  if (m._stackClose) { _modalStackRemove(m._stackClose); m._stackClose = null; }
  m.classList.add("closing");
  // Match the CSS exit animation duration (modalOut + bgFadeOut = .14s).
  // Reduced-motion shortens to .10s; pick whichever the media query is
  // currently in via matchMedia.
  const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const closeMs = reduced ? 110 : 150;
  setTimeout(() => {
    m.style.display = "none"; m.classList.remove("closing");
    m.style.zIndex = ""; // let CSS default take over again
    const anyOpen = [...$$(".modal-bg")].some(x => x.style.display === "flex");
    if (!anyOpen) _modalZ = 100;
  }, closeMs);
  if (id === "#instance-modal") onInstanceModalClose();
}
document.addEventListener("click", e => {
  if (e.target.matches("[data-close]") || (e.target.closest && e.target.closest("[data-close]"))) {
    const m = (e.target.closest("[data-close]") || e.target).closest(".modal-bg");
    // Dynamic (id-less) wrappers close via their registered stack fn —
    // hideModal("#") on an empty id would throw in querySelector.
    if (m) { if (m.id) hideModal("#" + m.id); else if (m._stackClose) m._stackClose(); }
  }
  if (e.target.classList.contains("modal-bg")) {
    if (e.target.id) hideModal("#" + e.target.id);
    else if (e.target._stackClose) e.target._stackClose();
  }
});
document.addEventListener("keydown", e => {
  if (e.key === "Escape") {
    // Pop only the top of the shared close stack (static + dynamic modals).
    if (closeTopModal()) return;
    // Fallback for anything shown without registering (legacy paths).
    const open = [...$$(".modal-bg")].filter(m => m.style.display !== "none" && !m.classList.contains("closing"));
    if (open.length) hideModal("#" + open[open.length - 1].id);
  }
});

// ------------------ Steam login gate ------------------
//
// Some SteamCMD apps (DayZ, Arma, a few others) refuse +login anonymous.
// When the operator triggers an update on such a module and the panel has
// no cached credentials, /api/instances/<id>/update returns 200 with
// {steam_login_required: true} rather than forwarding to the agent. We
// detect that here, pop the Steam-login modal, and retry once the login
// ceremony completes.
// confirmVersionUnlock gates a deliberate 7DTD game-version SWITCH on a LOCKED
// (frozen-branch) server behind a typed-slug confirm — per the destructive-
// action rule, since moving a live world between game versions (e.g. 2.6 → 3.0)
// can corrupt the save. Returns true only if the operator typed the exact
// instance id. `message` is the controller's version_locked explanation.
function confirmVersionUnlock(instanceID, message) {
  const typed = window.prompt(
    (message || "This server is locked to its current game version.") +
    "\n\nSwitching game versions can CORRUPT an existing world and is not reversible.\n\n" +
    "Type the server id to confirm the switch:\n" + instanceID, "");
  return typed !== null && typed.trim() === instanceID;
}

async function triggerUpdateWithSteamGate(instanceID, providerID, allowVersionChange) {
  const payload = {};
  if (providerID) payload.provider_id = providerID;
  if (allowVersionChange) payload.allow_version_change = true;
  const body = JSON.stringify(payload);
  let r;
  try {
    r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/update`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body });
  } catch (err) {
    // 7DTD version lock: the controller refuses to switch a LOCKED server to a
    // different game version with 409 version_locked, unless we resend with
    // allow_version_change. Confirm with a typed slug, then retry exactly once.
    if (err && err.code === "version_locked" && !allowVersionChange) {
      if (!confirmVersionUnlock(instanceID, err.message)) return null; // cancelled
      return triggerUpdateWithSteamGate(instanceID, providerID, true);
    }
    throw err;
  }
  if (r && r.steam_login_required) {
    const ok = await openSteamLoginModal(r.reason || "This module needs a Steam account that owns it.");
    if (!ok) return null;  // user cancelled
    // Retry — now credentials are stored, controller will forward.
    r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/update`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body });
    if (r && r.steam_login_required) {
      throw new Error("Steam login did not take; try again or re-enter credentials");
    }
  }
  return r;
}

// openSteamLoginModal shows the modal and returns a Promise<boolean> that
// resolves to true when login succeeds, false when the user cancels.
function openSteamLoginModal(introText) {
  return new Promise((resolve) => {
    const reset = () => {
      $("#sl-user").value = "";
      $("#sl-pass").value = "";
      $("#sl-guard").value = "";
      $("#sl-error").style.display = "none";
      $("#sl-error").textContent = "";
      // Reset error styling — the submit handler temporarily repurposes
      // this div as a yellow "waiting on Steam push" banner.
      $("#sl-error").style.color = "var(--err)";
      $("#sl-error").style.background = "rgba(239,68,68,0.08)";
      $("#sl-error").style.borderLeftColor = "var(--err)";
      $("#sl-submit").disabled = false;
      $("#sl-submit").textContent = "Sign in to Steam";
    };
    if (introText) $("#sl-intro").textContent = introText;
    reset();
    showModal("#steam-login-modal");
    setTimeout(() => $("#sl-user").focus(), 80);

    const cancelBtn = $("#sl-cancel");
    const submitBtn = $("#sl-submit");
    const closeBtn  = $("#steam-login-modal [data-close]");

    const cleanup = () => {
      cancelBtn.onclick = null;
      submitBtn.onclick = null;
      if (closeBtn) closeBtn.onclick = null;
    };
    const done = (result) => { cleanup(); hideModal("#steam-login-modal"); resolve(result); };

    cancelBtn.onclick = () => done(false);
    if (closeBtn) closeBtn.onclick = () => done(false);

    submitBtn.onclick = async () => {
      const username = $("#sl-user").value.trim();
      const password = $("#sl-pass").value;
      const guardCode = $("#sl-guard").value.trim().toUpperCase();
      if (!username || !password) {
        $("#sl-error").textContent = "username and password are required";
        $("#sl-error").style.display = "block";
        return;
      }
      submitBtn.disabled = true;
      submitBtn.textContent = "📱 Waiting for Steam Guard approval (up to 90s)…";
      $("#sl-error").style.display = "none";
      // Also surface a yellow banner so the wait state is impossible to
      // miss — the button text alone is too easy to overlook on a long
      // press, which is why the operator wonders if the modal is hung.
      const errEl = $("#sl-error");
      errEl.style.color = "#d97706";
      errEl.style.background = "rgba(217,119,6,0.08)";
      errEl.style.borderLeftColor = "#d97706";
      errEl.textContent = "📱 Check your Steam Mobile app for a push notification — tap Approve. The panel will wait up to 90 seconds.";
      errEl.style.display = "block";
      try {
        const r = await fetchJSON("/api/steam/login", {
          method: "POST",
          headers: {"Content-Type":"application/json"},
          body: JSON.stringify({ username, password, guard_code: guardCode }),
        });
        if (r && r.ok) {
          toast(`Steam login saved for ${r.username}`, "ok");
          done(true);
          return;
        }
        if (r && r.need_guard) {
          $("#sl-intro").textContent = "Steam Guard code required. Check your email or the Steam Mobile app.";
          $("#sl-guard").focus();
          $("#sl-error").textContent = "Enter the Steam Guard code above and resubmit.";
          $("#sl-error").style.display = "block";
          submitBtn.disabled = false;
          submitBtn.textContent = "Retry with code";
          return;
        }
        $("#sl-error").textContent = (r && r.error) || "Login failed — check your credentials.";
        $("#sl-error").style.display = "block";
        submitBtn.disabled = false;
        submitBtn.textContent = "Sign in to Steam";
      } catch (err) {
        $("#sl-error").textContent = "Request failed: " + err.message;
        $("#sl-error").style.display = "block";
        submitBtn.disabled = false;
        submitBtn.textContent = "Sign in to Steam";
      }
    };
  });
}

// Keep toggle label text in sync with the underlying checkbox state.
document.addEventListener("change", e => {
  if (e.target.matches(".toggle input[type='checkbox']")) {
    const lbl = e.target.closest(".toggle").querySelector("[data-toggle-label]");
    if (lbl) { lbl.textContent = e.target.checked ? "Enabled" : "Disabled"; lbl.classList.toggle("on", e.target.checked); }
  }
});

function fmtBytes(n) {
  if (!n) return "0 B";
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
  if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " MB";
  return (n / 1024 / 1024 / 1024).toFixed(2) + " GB";
}
function fmtTime(iso) {
  if (!iso) return "—";
  const d = new Date(iso); if (isNaN(d)) return iso;
  const diff = (Date.now() - d) / 1000;
  if (diff < 60) return Math.floor(diff) + "s ago";
  if (diff < 3600) return Math.floor(diff / 60) + "m ago";
  if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
  return d.toLocaleString();
}

// ================ game appearance (emoji + gradient + artwork) ================
// Each entry can carry three art sources (first non-empty wins):
//   art    — panel-hosted image in /game-art/<module-id>.{jpg,png} (best —
//            no external dependency, loads instantly, survives Steam CDN
//            renames).
//   steam  — Steam app id → hotlink to cdn.cloudflare.steamstatic.com.
//            Fallback for modules where we haven't packaged art yet.
//   grad   — gradient used for the flat tile on non-Steam games + as a
//            background-color behind the image while it loads.
const moduleAppearance = {
  "minecraft-java":  { emoji: "🧱", grad: "linear-gradient(135deg,#4c8b32,#84ce5c)", art: "/game-art/minecraft-java.png" },
  "minecraft-bedrock": { emoji: "🧱", grad: "linear-gradient(135deg,#2d6929,#5a9d56)" },
  "7dtd":            { emoji: "🧟", grad: "linear-gradient(135deg,#7b3b12,#d97706)", art: "/game-art/7dtd.jpg",          steam: "251570" },
  "valheim":         { emoji: "⚔️", grad: "linear-gradient(135deg,#3b5998,#8da5d4)", steam: "896660" },
  "ark":             { emoji: "🦖", grad: "linear-gradient(135deg,#1f6f47,#45a877)", steam: "346110" },
  "ark-sa":          { emoji: "🦖", grad: "linear-gradient(135deg,#3a2e1e,#d97706)", art: "/game-art/ark-sa.jpg",        steam: "2430930" },
  "terraria":        { emoji: "🌳", grad: "linear-gradient(135deg,#2d7141,#78c87a)", steam: "105600" },
  "rust":            { emoji: "🔧", grad: "linear-gradient(135deg,#8b4a2b,#d97706)", steam: "252490" },
  "cs2":             { emoji: "🔫", grad: "linear-gradient(135deg,#2a3a4a,#4a5e74)", steam: "730" },
  "tf2":             { emoji: "🔨", grad: "linear-gradient(135deg,#a0443a,#e07960)", steam: "440" },
  "satisfactory":    { emoji: "🏭", grad: "linear-gradient(135deg,#d97706,#fbbf24)", steam: "526870" },
  "factorio":        { emoji: "🏭", grad: "linear-gradient(135deg,#8c6239,#d4a373)", steam: "427520" },
  "palworld":        { emoji: "🐾", grad: "linear-gradient(135deg,#006d77,#83c5be)", steam: "1623730" },
  "empyrion":        { emoji: "🚀", grad: "linear-gradient(135deg,#0b2545,#5c7cfa)", art: "/game-art/empyrion.jpg",          steam: "383120" },
  "valheim":         { emoji: "⚔️", grad: "linear-gradient(135deg,#3b5998,#8da5d4)", art: "/game-art/valheim.jpg",           steam: "892970" },
  "palworld":        { emoji: "🐾", grad: "linear-gradient(135deg,#006d77,#83c5be)", art: "/game-art/palworld.jpg",          steam: "1623730" },
  "factorio":        { emoji: "🏭", grad: "linear-gradient(135deg,#8c6239,#d4a373)", art: "/game-art/factorio.jpg",          steam: "427520" },
  "satisfactory":    { emoji: "🏭", grad: "linear-gradient(135deg,#d97706,#fbbf24)", art: "/game-art/satisfactory.jpg",      steam: "526870" },
  "barotrauma":      { emoji: "🚢", grad: "linear-gradient(135deg,#1e3a5f,#3b6e8c)", art: "/game-art/barotrauma.jpg",        steam: "602960" },
  "enshrouded":      { emoji: "🌫️", grad: "linear-gradient(135deg,#4a2c2a,#a45e4a)", art: "/game-art/enshrouded.jpg",        steam: "1203620" },
  "terraria":        { emoji: "🌳", grad: "linear-gradient(135deg,#2d7141,#78c87a)", art: "/game-art/terraria.jpg",          steam: "105600" },
  "project-zomboid": { emoji: "🧟", grad: "linear-gradient(135deg,#6a4c1e,#b89040)", art: "/game-art/project-zomboid.jpg",   steam: "108600" },
  "core-keeper":     { emoji: "⛏️", grad: "linear-gradient(135deg,#2e1a47,#6b3fa0)", art: "/game-art/core-keeper.jpg",       steam: "1621690" },
  "soulmask":        { emoji: "🗿", grad: "linear-gradient(135deg,#3a2416,#7a5230)", art: "/game-art/soulmask.jpg",          steam: "2646460" },
  "v-rising":        { emoji: "🧛", grad: "linear-gradient(135deg,#3b0a0a,#991b1b)", art: "/game-art/v-rising.jpg",          steam: "1604030" },
  "sons-of-the-forest": { emoji: "🌲", grad: "linear-gradient(135deg,#14532d,#4d7c0f)", art: "/game-art/sons-of-the-forest.jpg", steam: "1326470" },
  "windrose":        { emoji: "🧭", grad: "linear-gradient(135deg,#1e3a5f,#60a5fa)", art: "/game-art/windrose.jpg",          steam: "3041230" },
  "conan-exiles":    { emoji: "⚔️", grad: "linear-gradient(135deg,#5a1a00,#c2410c)", art: "/game-art/conan-exiles.jpg",     steam: "440900" },
  "rust":            { emoji: "🔧", grad: "linear-gradient(135deg,#8b4a2b,#d97706)", art: "/game-art/rust.jpg",              steam: "252490" },
  "dragonwilds":     { emoji: "🐉", grad: "linear-gradient(135deg,#4c1d95,#f59e0b)", art: "/game-art/dragonwilds.jpg",      steam: "1374490" },
  "dayz":            { emoji: "🧟", grad: "linear-gradient(135deg,#2d3e2e,#6b7f56)", art: "/game-art/dayz.jpg",             steam: "221100" },
  "vein":            { emoji: "🩸", grad: "linear-gradient(135deg,#2a0a0a,#7a1010)", art: "/game-art/vein.jpg",             steam: "1857950" },
  "project-zomboid": { emoji: "🧟", grad: "linear-gradient(135deg,#6a4c1e,#b89040)", steam: "108600" },
  "gmod":            { emoji: "🔧", grad: "linear-gradient(135deg,#334155,#64748b)", steam: "4020" },
  "demo":            { emoji: "🧪", grad: "linear-gradient(135deg,#4b5563,#9ca3af)" },
  "steamcmd-test":   { emoji: "⚙️", grad: "linear-gradient(135deg,#1e3a8a,#3b82f6)" },
};

// Steam CDN URL builder. header.jpg is the 460x215 banner that Steam shows
// at the top of the store page; it's the canonical "game art" asset.
function steamHeaderURL(appid) {
  return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
}
function steamCapsuleURL(appid) {
  // 467x181 tall capsule — nice for the square-ish tile on cards.
  return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_600x900.jpg`;
}
// Art URL resolver — local panel-hosted art wins over Steam CDN so packaged
// games render instantly offline and don't break when Steam renames things.
function artURLFor(app) {
  if (!app) return "";
  if (app.art) return app.art;
  if (app.steam) return steamHeaderURL(app.steam);
  return "";
}

// Square game-icon resolver. Looks for a bundled file under
// /game-art/icons/<module-id>.jpg (every module with a Steam library
// poster has one — see static/game-art/icons/). Returns "" when the
// module has no bundled icon, in which case the caller should fall
// back to the emoji.
//
// Bundled — never reaches over the network at runtime. Adding a new
// module means dropping a new <id>.jpg into the icons/ directory and
// listing the id below; that keeps the offline guarantee even when
// Steam renames or moves their asset paths.
const _GAME_ICON_IDS = new Set([
  "7dtd","ark-sa","barotrauma","conan-exiles","core-keeper","dayz",
  "dragonwilds","empyrion","enshrouded","factorio","palworld",
  "project-zomboid","rust","satisfactory","sons-of-the-forest",
  "soulmask","terraria","v-rising","valheim","vein","windrose",
]);
function gameIconURLFor(moduleID) {
  if (!moduleID) return "";
  if (_GAME_ICON_IDS.has(moduleID)) return `/game-art/icons/${moduleID}.jpg`;
  return "";
}

// Module-id → short label for the tile's top-left chip. Hand-curated
// for known games (so "ark-sa" reads as "ARKASA" not "Ark-sa") with a
// reasonable fallback for unknown modules.
const MODULE_ABBR = {
  "7dtd":              "7DTD",
  "ark":               "ARK",
  "ark-sa":            "ARKASA",
  "minecraft-java":    "MC",
  "valheim":           "Valheim",
  "v-rising":          "VRising",
  "empyrion":          "Empyrion",
  "palworld":          "Palworld",
  "rust":              "Rust",
  "factorio":          "Factorio",
  "satisfactory":      "Satisfactory",
  "barotrauma":        "Barotrauma",
  "enshrouded":        "Enshrouded",
  "terraria":          "Terraria",
  "project-zomboid":   "PZ",
  "core-keeper":       "CoreKeeper",
  "soulmask":          "Soulmask",
  "sons-of-the-forest":"SOTF",
  "windrose":          "Windrose",
  "conan-exiles":      "Conan",
  "dayz":              "DayZ",
  "vein":              "VEIN",
  "dragonwilds":       "DragonWilds",
  "cs2":               "CS2",
  "tf2":               "TF2",
  "gmod":              "GMod",
  "demo":              "Demo",
  "steamcmd-test":     "SteamCMD",
};
function _moduleAbbr(moduleID) {
  if (!moduleID) return "—";
  if (MODULE_ABBR[moduleID]) return MODULE_ABBR[moduleID];
  // Fallback: drop common suffixes, then PascalCase the segments.
  let id = String(moduleID)
    .replace(/-server$/i, "")
    .replace(/-galactic-survival$/i, "")
    .replace(/-edition$/i, "");
  return id.split("-").map(p => p ? p[0].toUpperCase() + p.slice(1) : "").join("");
}

function appearanceFor(moduleID) {
  // WI-07: manifest appearance (from /api/modules) wins; inline map is
  // the fallback for modules without a manifest dir (ark, cs2, tf2, gmod)
  // or before the first modules poll lands.
  const meta = _modMeta(moduleID);
  if (meta && meta.appearance) return meta.appearance;
  const base = moduleAppearance[moduleID];
  if (base) return base;
  // Deterministic fallback: hash to a hue.
  let h = 0; for (const c of moduleID) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  const hue = h % 360;
  return { emoji: "🎮", grad: `linear-gradient(135deg, hsl(${hue} 55% 30%), hsl(${(hue+30)%360} 65% 55%))` };
}

// ================ auth header ================
async function loadMe() {
  try {
    const me = await fetchJSON("/api/me");
    $("#whoami").textContent = me.email;
  } catch {}
}
$("#logout").addEventListener("click", async () => {
  try { await fetchJSON("/api/logout", { method: "POST" }); } catch {}
  location.href = "/login";
});

// ================ data ================
const latestStats = {};
const latestAppState = {};
let allInstances = [];
let allAgents = [];
let allModules = [];
// agentsLoaded flips true after the first /api/agents response lands
// (success OR empty). Used by renderAgents to avoid flashing the
// "No agents yet" empty state on hard refreshes when the render races
// the API call.
let agentsLoaded = false;
let instancesLoaded = false;

// ================ polling ================

// Controller-liveness tracker. Polls fail in two distinct ways:
//
//   1. one-off network blip — controller is up, packet got dropped. Next
//      tick recovers cleanly. Don't bother the operator.
//   2. controller restart — sustained failures, then recovery. Open
//      browser tabs at this point have stale JS state (in-flight fetches
//      that pre-date the restart, polling timers tied to the old session).
//      The new page load works; the open tab is lying half-frozen. We
//      surface a sticky reload prompt so the operator doesn't sit
//      wondering why a tab is stuck (see panel/memory/gotchas.md
//      "Dashboard tab gets stuck on 'loading…' after a controller
//      restart").
//
// Threshold: 3 consecutive poll failures before we count the controller
// as down. Lower would flap on transient blips; higher would keep the
// operator confused longer.
const controllerLiveness = {
  consecutiveFailures: 0,
  wasDown: false,           // true once we've crossed the down threshold
  bannerShown: false,       // dedupe so we don't keep stacking banners
  downAt: 0,                // timestamp first failure (for the banner copy)
  downThreshold: 3,
};

function notePollFailure(err) {
  controllerLiveness.consecutiveFailures++;
  if (!controllerLiveness.wasDown && controllerLiveness.consecutiveFailures === 1) {
    controllerLiveness.downAt = Date.now();
  }
  if (!controllerLiveness.wasDown && controllerLiveness.consecutiveFailures >= controllerLiveness.downThreshold) {
    controllerLiveness.wasDown = true;
  }
  setStatus("api: " + (err && err.message ? err.message : err), "err");
}

function notePollSuccess() {
  // Recovery after a confirmed down period → controller almost certainly
  // restarted. Show the reload prompt once per recovery (bannerShown
  // resets on dismiss + on next down→up cycle).
  if (controllerLiveness.wasDown && !controllerLiveness.bannerShown) {
    showControllerRestartBanner(Date.now() - controllerLiveness.downAt);
    controllerLiveness.bannerShown = true;
  }
  if (controllerLiveness.consecutiveFailures > 0 || controllerLiveness.wasDown) {
    controllerLiveness.consecutiveFailures = 0;
    controllerLiveness.wasDown = false;
  }
}

// showControllerRestartBanner — sticky banner top-of-page urging a reload
// after a detected controller restart. Auto-reloads after 30s if the
// operator doesn't dismiss; that grace gives them time to look at any
// in-progress UI before the JS state resets.
function showControllerRestartBanner(downForMs) {
  let bar = document.getElementById("controller-restart-bar");
  if (bar) return; // already showing
  bar = document.createElement("div");
  bar.id = "controller-restart-bar";
  bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:9999;background:#0b1220;border-bottom:1px solid rgba(96,165,250,.4);color:#e2e8f0;padding:9px 16px;font-size:13px;display:flex;align-items:center;gap:14px;box-shadow:0 4px 12px rgba(0,0,0,.4)";
  const downSec = Math.max(1, Math.round(downForMs / 1000));
  bar.innerHTML = `
    <span style="font-size:16px">🔄</span>
    <div style="flex:1;line-height:1.4">
      <strong style="color:#93c5fd">Controller reconnected after ${downSec}s offline.</strong>
      <span class="dim" style="margin-left:6px">Reload the page so this tab's state matches the new server. (Auto-reloads in <span id="ctrl-restart-counter">30</span>s.)</span>
    </div>
    <button id="ctrl-restart-reload" class="primary" style="padding:5px 14px">Reload now</button>
    <button id="ctrl-restart-dismiss" style="padding:5px 12px">Dismiss</button>
  `;
  document.body.appendChild(bar);
  document.getElementById("ctrl-restart-reload").addEventListener("click", () => location.reload());
  document.getElementById("ctrl-restart-dismiss").addEventListener("click", () => {
    if (bar.parentNode) bar.parentNode.removeChild(bar);
    if (window._ctrlRestartTimer) { clearInterval(window._ctrlRestartTimer); window._ctrlRestartTimer = null; }
    // Don't clear bannerShown — operator dismissed; respect that until
    // the next down→up transition.
  });
  let countdown = 30;
  if (window._ctrlRestartTimer) clearInterval(window._ctrlRestartTimer);
  window._ctrlRestartTimer = setInterval(() => {
    countdown--;
    const el = document.getElementById("ctrl-restart-counter");
    if (el) el.textContent = String(countdown);
    if (countdown <= 0) {
      clearInterval(window._ctrlRestartTimer);
      window._ctrlRestartTimer = null;
      location.reload();
    }
  }, 1000);
}

async function pollAgents() {
  if (document.hidden) return; // hidden tab: skip — visibilitychange refetches on return
  try {
    const { agents } = await fetchJSON("/api/agents");
    applyAgents(agents);
  } catch (e) { notePollFailure(e); }
}
// applyAgents — shared sink for the /api/agents poll AND the SSE connect
// snapshot (same DTO shape on both paths).
function applyAgents(agents) {
  allAgents = agents || [];
  agentsLoaded = true;
  renderAgents();
  syncAgentPicker();
  refreshHeroStats();
  notePollSuccess();
}
async function pollModules() {
  if (document.hidden) return; // hidden tab: skip — visibilitychange refetches on return
  try {
    const { modules } = await fetchJSON("/api/modules");
    allModules = modules || [];
    syncModulePicker();
  } catch {}
}
// Previous poll snapshot — used to detect status transitions (stopped →
// running, etc.) across poll ticks so we can arm client-side overlays
// even when the operator didn't click the button themselves.
const _instancePrevStatus = new Map();

let _lastInstancesJSON = "";
let _clusterTick = 0;
async function pollInstances() {
  // Hidden tab: skip entirely — SSE keeps state warm and the next visible
  // poll resyncs. Saves a render pass every 2s in background tabs.
  if (document.hidden) return;
  try {
    const { instances } = await fetchJSON("/api/instances");
    await applyInstances(instances);
  } catch (e) { notePollFailure(e); }
}
// applyInstances — shared sink for the /api/instances poll AND the SSE
// connect snapshot; everything below is the historical pollInstances body.
async function applyInstances(instances) {
  try {
    // PERF: most 2-second ticks change NOTHING — but the old path still
    // rebuilt toolbar HTML, diffed every card, and re-ran hero stats each
    // tick, costing a style/layout pass that competed with hover + console
    // rendering. If the payload is byte-identical to the previous tick,
    // skip all render work (overlay timers/SSE handle their own repaints).
    const rawJSON = JSON.stringify(instances || []);
    if (rawJSON === _lastInstancesJSON) {
      scanReadyForUnconfirmedRunning();
      notePollSuccess();
      // Cluster membership can change while the instances payload is static
      // (a join/leave doesn't alter /api/instances). Re-check on a slow cadence
      // and repaint cards only if the cluster map actually changed.
      if ((_clusterTick = (_clusterTick + 1) % 5) === 0) {
        if (await refreshClusterMap() && typeof renderServerCards === "function") renderServerCards();
      }
      return;
    }
    _lastInstancesJSON = rawJSON;
    // Keep the cluster→color map fresh for the card badges (cheap; cached).
    await refreshClusterMap();
    // Filter out ids the operator just deleted client-side. The backend
    // delete is async (30-90s when sidecars need cleanup); without this
    // the card would visibly pop back into the dashboard for the next
    // 2-second poll cycle, looking like the delete failed.
    allInstances = (instances || []).filter(i => !instanceLocallyDeleted.has(i.instance_id));
    instancesLoaded = true;
    // Reflect server-driven transient states for operators who didn't
    // trigger the Create themselves (refreshed mid-install, or watching
    // someone else's instance come up). Agent emits `detail=installing`
    // during first-time SteamCMD download via our sidecar; mark when we
    // see it so the card pulses blue. We deliberately DO NOT clear on
    // status transitions away from stopped — for image-managed modules
    // like ark-sa, the container's entrypoint runs its own SteamCMD and
    // the agent reports status=running the moment docker-start returns,
    // long before the game is actually up. Clearing on that transition
    // made the pill lie. Installing clears on: the markInstalling SSE
    // listener matching "update complete" / ready-regex / crash status,
    // or the 30-min safety timer.
    for (const i of allInstances) {
      if (i.detail === "installing" && !instanceInstalling.has(i.instance_id)) {
        markInstalling(i.instance_id);
      }
      // Auto-clear stuck "installing" overlay: agent reports detail=running
      // (or anything non-"installing") once the steamcmd sidecar is done.
      // If the markInstalling SSE listener missed the "update complete" log
      // line — happens on a refresh after install finished, or when the
      // sidecar exited cleanly with no terminal log line — without this
      // check the overlay would persist for the full 30-min safety timer.
      else if (instanceInstalling.has(i.instance_id) && i.detail !== "installing" && i.status !== "creating") {
        clearInstalling(i.instance_id, false);
      }
      // Auto-start path: the agent fires docker-start and emits RUNNING
      // without the client ever calling markStarting. Without a transient
      // overlay, the pill flips straight to "RUNNING" even though the
      // game itself may still be minutes away from being joinable (ARK:
      // "Server has successfully started" comes ~2 min before the actual
      // "advertising for join" line). Detect the stopped/starting →
      // running transition and arm a starting overlay so the pill stays
      // yellow until the module's ready-regex fires for real. If the
      // overlay is already armed (user clicked Start, or we're mid-
      // install), respect that.
      //
      // IMPORTANT — only fire when we've actually OBSERVED a prior
      // non-running state. If `prev` is undefined (first time we've seen
      // this instance this session) we have no way to know whether the
      // server just started or has been running for days; treat it as
      // "already settled" and don't arm the overlay. That first-poll
      // over-arming was the cause of every card on a fresh page-load
      // showing STARTING forever.
      const prev = _instancePrevStatus.get(i.instance_id);
      if (i.status === "running"
          && prev !== undefined && prev !== "running"
          && !instanceStarting.has(i.instance_id)
          && !instanceInstalling.has(i.instance_id)
          && !instanceReady.has(i.instance_id)) {
        markStarting(i.instance_id);
      }
      _instancePrevStatus.set(i.instance_id, i.status);
    }
    // Evict entries for instances that no longer exist so the map doesn't
    // grow unbounded over a long-running dashboard session.
    const currentIds = new Set(allInstances.map(i => i.instance_id));
    for (const id of _instancePrevStatus.keys()) {
      if (!currentIds.has(id)) _instancePrevStatus.delete(id);
    }
    // Self-heal stale "deleting" / "installing" / "starting" overlays for
    // ids that have completely disappeared from the instance list — the
    // backend already removed the row, so the card will unmount on the
    // next render anyway. Without this, a slow background watcher could
    // hold the Set populated AFTER the card's gone, which means the next
    // instance with the same id (rare but possible) would inherit the
    // ghost state.
    for (const id of instanceDeleting) if (!currentIds.has(id)) instanceDeleting.delete(id);
    for (const id of instanceInstalling.keys()) if (!currentIds.has(id)) clearInstalling(id, false);
    for (const id of instanceStarting.keys())   if (!currentIds.has(id)) clearStarting(id);
    for (const id of instanceStopping.keys())   if (!currentIds.has(id)) clearStopping(id);
    renderServerCards();
    syncInstancePickers();
    refreshHeroStats();
    // Background ready-scan for log-only modules whose status is running
    // but whose ready-regex hasn't fired this session. Without this, a
    // page refresh on an already-running ARK leaves the card stuck on
    // "starting" until the operator opens the console (which does the
    // same scan). Mirrors ensureConsoleStream's history scan.
    scanReadyForUnconfirmedRunning();
    notePollSuccess();
  } catch (e) { notePollFailure(e); }
}

// scanReadyForUnconfirmedRunning — on every poll, kick off a one-shot
// log-history fetch for any log-only running instance whose ready signal
// hasn't been confirmed yet this session. The fetch is the same one the
// console pane does on open; we just run it eagerly from the dashboard.
// _readyScanInflight prevents the same instance being scanned twice
// concurrently across overlapping polls (2s tick + ~200ms fetch latency).
//
// CRITICAL: filter matches by log timestamp > instance.updated_at. The
// docker log buffer carries lines across container restarts, so a freshly
// started ARK has the PREVIOUS run's "Server has completed startup" line
// sitting in /logs/recent. Without this filter the scanner instantly marks
// ready and the pill flips to RUNNING the moment the page polls — exactly
// the bug READY_LOG_ONLY exists to prevent. updated_at is only bumped on
// status changes (NULL-distinct check in db.UpdateInstanceStatus), so it
// doubles as a reliable "current-run started at" boundary.
const _readyScanInflight = new Set();
const _readyScanLastAt = new Map();
async function scanReadyForUnconfirmedRunning() {
  for (const i of allInstances) {
    if (i.status !== "running") continue;
    if (!READY_LOG_ONLY.has(i.module_id)) continue;
    if (instanceConfirmedReady.has(i.instance_id)) continue;
    if (_readyScanInflight.has(i.instance_id)) continue;
    // Throttle: this used to refetch 400 log lines EVERY 2s poll for the
    // whole boot of an unconfirmed server (minutes of redundant fetches
    // through the proxy chain). 10s granularity is plenty — the live
    // per-instance SSE catches the ready line between scans anyway.
    const lastAt = _readyScanLastAt.get(i.instance_id) || 0;
    if (Date.now() - lastAt < 10000) continue;
    _readyScanLastAt.set(i.instance_id, Date.now());
    const pat = readyPatternFor(i.module_id);
    if (!pat) continue;
    // Boundary for filtering stale ready lines from previous container
    // runs. 30s grace for clock skew between docker host + log shipper.
    let sinceMs = 0;
    if (i.updated_at) {
      const t = Date.parse(i.updated_at);
      if (!isNaN(t)) sinceMs = t - 30 * 1000;
    }
    _readyScanInflight.add(i.instance_id);
    (async (id, pattern, since) => {
      try {
        const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/logs/recent?limit=400`);
        for (const ev of (r.events || [])) {
          if (!ev.log || !ev.log.line) continue;
          if (since > 0 && ev.log.at) {
            const lineAt = Date.parse(ev.log.at);
            if (!isNaN(lineAt) && lineAt < since) continue; // stale, previous run
          }
          if (pattern.test(ev.log.line)) {
            instanceConfirmedReady.add(id);
            paintStartingUI();
            renderServerCards();
            return;
          }
        }
      } catch { /* silent — next poll will retry */ }
      finally { _readyScanInflight.delete(id); }
    })(i.instance_id, pat, sinceMs);
  }
}

// Refresh the 4 hero stat cards + sidebar counts. Recomputed cheaply on
// every data poll so the dashboard stays in sync without an extra fetch.
function refreshHeroStats() {
  const total = allInstances.length;
  const running = allInstances.filter(i => i.status === "running").length;
  const agentsConn = allAgents.filter(a => a.connected).length;
  const agentsTotal = allAgents.length;
  // Players online: sum across all latestAppState entries — but only for
  // instances that are actually running, so a stale sample from a server
  // that just stopped doesn't keep contributing to the lobby count.
  const aliveIds = new Set(allInstances.filter(i => i.status === "running").map(i => i.instance_id));
  let players = 0, playersCap = 0;
  for (const id in latestAppState) {
    if (!aliveIds.has(id)) continue;
    const s = latestAppState[id];
    if (typeof s.players_online === "number") players += s.players_online;
    if (typeof s.players_max === "number")    playersCap += s.players_max;
  }
  // CPU: sum of latest stats entries for running servers.
  let cpuSum = 0, cpuCount = 0;
  for (const id in latestStats) {
    if (!aliveIds.has(id)) continue;
    const s = latestStats[id];
    if (typeof s.cpu === "number") { cpuSum += s.cpu; cpuCount++; }
  }
  const setText = (id, v) => { const el = $(id); if (el) el.textContent = v; };
  // Count-up: when a stat number changes, briefly animate to the new
  // value rather than snapping. Makes the dashboard feel "alive" without
  // being noisy. Stays a no-op for first paint (no `data-prev` set).
  const animateNumber = (selector, newVal) => {
    const el = $(selector);
    if (!el) return;
    const cur = parseFloat(el.dataset.prev || "");
    const target = (typeof newVal === "number") ? newVal : parseFloat(newVal);
    if (!isNaN(target)) el.dataset.prev = String(target);
    if (isNaN(cur) || isNaN(target) || cur === target) {
      el.textContent = (typeof newVal === "number") ? String(newVal) : newVal;
      return;
    }
    const start = performance.now();
    const dur = 450;
    const step = (now) => {
      const t = Math.min(1, (now - start) / dur);
      // ease-out
      const ease = 1 - Math.pow(1 - t, 3);
      const v = cur + (target - cur) * ease;
      el.textContent = Number.isInteger(target) ? Math.round(v).toString() : v.toFixed(1);
      if (t < 1) requestAnimationFrame(step);
      else el.textContent = (typeof newVal === "number") ? String(newVal) : newVal;
    };
    requestAnimationFrame(step);
  };
  animateNumber("#stat-running", running);
  setText("#stat-running-hint", `of ${total}`);
  animateNumber("#stat-players", players);
  setText("#stat-players-hint", playersCap > 0 ? `/ ${playersCap} cap` : "");
  animateNumber("#stat-agents", agentsConn);
  setText("#stat-agents-hint", agentsTotal === agentsConn ? `online` : `of ${agentsTotal}`);
  // Fleet-wide CPU: sum of docker-style percents across hosts has no single
  // "% of host" meaning, so the hero shows total cores in use instead.
  setText("#stat-cpu", cpuCount ? `${(cpuSum / 100).toFixed(1)}` : "—");
  setText("#stat-cpu-hint", cpuCount ? `cores · ${cpuCount} live` : "idle");
  setText("#side-server-count", total);
  setText("#side-agent-count", agentsConn);
  setText("#page-sub", total > 0 ? `— ${running} / ${total} running` : "");
  // Topbar live health pill — running/total + agents online.
  setText("#topbar-running", `${running}/${total}`);
  setText("#topbar-agents", `${agentsConn}/${agentsTotal}`);
  const agentSeg = $("#topbar-agents-seg");
  if (agentSeg) agentSeg.classList.toggle("warn", agentsTotal > agentsConn);

  // Cluster-health sparklines: push this tick's totals into a rolling
  // ring buffer and redraw the trend lines under the Players + CPU cards.
  // Seeded on load from /api/metrics/history (controller-side sampler) so
  // the trend survives a page reload, then extended live each poll tick.
  _pushHeroHistory(players, cpuCount ? cpuSum : 0);
  _drawSpark("#spark-players", _heroHistory.players, "rgba(95,178,255");
  _drawSpark("#spark-cpu", _heroHistory.cpu, "rgba(255,177,59");
}

// Rolling history for the overview sparklines. Capped at SPARK_MAX points.
// Seeded from the controller's /api/metrics/history on boot (last hour at
// 30s resolution) then extended live; keyed series so adding another later
// is trivial.
const SPARK_MAX = 120;
const _heroHistory = { players: [], cpu: [] };
function _pushHeroHistory(players, cpu) {
  _heroHistory.players.push(players);
  _heroHistory.cpu.push(cpu);
  if (_heroHistory.players.length > SPARK_MAX) _heroHistory.players.shift();
  if (_heroHistory.cpu.length > SPARK_MAX) _heroHistory.cpu.shift();
}
// Seed the sparkline buffers from the controller's rolling metrics history
// so trends are visible immediately on load instead of building from zero.
// Best-effort: a missing endpoint (old controller) or empty history just
// leaves the buffers to fill live. Called once on boot.
let _sparkSeeded = false;
async function seedSparkHistory() {
  if (_sparkSeeded) return;
  _sparkSeeded = true;
  try {
    const { samples } = await fetchJSON("/api/metrics/history");
    if (!Array.isArray(samples) || !samples.length) return;
    // Keep the most recent SPARK_MAX points; prepend before live ticks.
    const recent = samples.slice(-SPARK_MAX);
    _heroHistory.players = recent.map(s => s.players | 0).concat(_heroHistory.players).slice(-SPARK_MAX);
    _heroHistory.cpu     = recent.map(s => s.cpu | 0).concat(_heroHistory.cpu).slice(-SPARK_MAX);
    _drawSpark("#spark-players", _heroHistory.players, "rgba(95,178,255");
    _drawSpark("#spark-cpu", _heroHistory.cpu, "rgba(255,177,59");
  } catch { /* old controller / no history — fill live */ }
}
// Render a series as an SVG area+line sparkline into the given <svg>. The
// viewBox is 100x28; we map points across the width and scale to the
// series' own max (with a small headroom) so the trend is always visible
// regardless of absolute magnitude. rgbPrefix is like "rgba(95,178,255".
function _drawSpark(sel, series, rgbPrefix) {
  const svg = $(sel); if (!svg) return;
  const lineEl = svg.querySelector(".spark-line");
  const areaEl = svg.querySelector(".spark-area");
  if (!lineEl || !areaEl) return;
  // Need at least 2 points to draw a trend; before that, stay blank.
  if (series.length < 2) { lineEl.setAttribute("d", ""); areaEl.setAttribute("d", ""); return; }
  const W = 100, H = 28, pad = 3;
  const max = Math.max(1, ...series) * 1.15; // headroom so the peak isn't flush to the top
  const n = series.length;
  const x = (i) => (n === 1 ? 0 : (i / (n - 1)) * W);
  const y = (v) => H - pad - (v / max) * (H - pad * 2);
  let d = "";
  series.forEach((v, i) => { d += (i === 0 ? "M" : "L") + x(i).toFixed(1) + " " + y(v).toFixed(1) + " "; });
  lineEl.setAttribute("d", d.trim());
  // Area path: line, then drop to the baseline and close.
  areaEl.setAttribute("d", `${d.trim()} L${W} ${H} L0 ${H} Z`);
  // Tint via inline style so each card keeps its accent color.
  lineEl.style.stroke = rgbPrefix + ",.9)";
  areaEl.style.fill = rgbPrefix + ",.14)";
}
async function pollSchedules() {
  try {
    const { schedules } = await fetchJSON("/api/schedules");
    renderSchedules(schedules || []);
  } catch {}
}

// ================ agent panes + nested server cards (diff-render, no flash) ================
//
// Layout inspired by AMP's Instance Manager: each agent is a collapsible
// panel; its game server cards nest inside. We diff-render at both levels
// (per-agent and per-card) so polling updates never flash the UI.

// ---- FRESH SERVERS LAYOUT — flat grid + filter strip + agents drawer ----
// Replaces the older AMP-style "one collapsible pane per agent". All servers
// flow into a single dense grid; agent identity rides on each card as a
// chip; agent CRUD lives in a collapsible drawer at the bottom; status +
// agent + sort + search filters live in a toolbar above the grid.
const serverFilter = { status: "all", agent: "all", module: "all", sort: "name", search: "", group: (localStorage.getItem("rcc.group.v1") || "game") };
let _flatShellRendered = false;

function renderAgentsAndServers() {
  const wrap = $("#agents-wrap");
  const total = allInstances.length;
  const running = allInstances.filter(i => i.status === "running").length;
  const connected = allAgents.filter(a => a.connected).length;
  // Legacy element guard — moved into sidebar/hero on shell restructure.
  const legacyCount = $("#agent-count");
  if (legacyCount) legacyCount.textContent = `${allAgents.length} agent${allAgents.length === 1 ? "" : "s"} (${connected} online) · ${total} server${total === 1 ? "" : "s"} · ${running} running`;

  if (!allAgents.length) {
    // First page-load race: the render can fire before the first
    // /api/agents response lands. Show a neutral "loading" state then,
    // not the "no agents" CTA — otherwise every hard refresh flashes
    // the empty state for a beat before the real data arrives.
    if (!agentsLoaded) {
      wrap.innerHTML = loadingState("Loading agents…");
    } else {
      wrap.innerHTML = `<div class="empty"><div class="big">${EMPTY_ICONS.server}</div>No agents yet<span class="hint">Click "Pair new agent" to add one</span></div>`;
    }
    return;
  }

  // Build the shell once — toolbar, batch-actions row, grid, agents drawer.
  if (!_flatShellRendered) {
    wrap.innerHTML = `
      <div class="svr-toolbar" id="svr-toolbar"></div>
      <div class="svr-batchbar" id="svr-batchbar" style="display:none"></div>
      <div class="svr-grid" id="svr-grid"></div>
      <details class="agents-drawer" id="agents-drawer">
        <summary>⚙️ Manage agents <span class="dim" style="margin-left:auto;font-size:11.5px" id="agents-drawer-summary"></span></summary>
        <div class="agents-drawer-body" id="agents-drawer-body"></div>
      </details>
    `;
    _flatShellRendered = true;
    // Toolbar click delegation: status pills, agent chips, module chips
    $("#svr-toolbar").addEventListener("click", e => {
      const pill = e.target.closest("[data-svr-pill]");
      if (!pill) return;
      const [k, v] = pill.dataset.svrPill.split(":");
      if (k === "status") serverFilter.status = v;
      else if (k === "agent") serverFilter.agent = v;
      else if (k === "module") serverFilter.module = v;
      _refreshToolbarActive();
      _applyServerFilter();
      _renderBatchBar();
    });
    // Batch action click delegation
    $("#svr-batchbar").addEventListener("click", e => {
      const btn = e.target.closest("[data-batch]");
      if (!btn) return;
      runBatchAction(btn.dataset.batch, btn);
    });
  }

  _renderToolbar();
  _renderGrid();
  _renderBatchBar();
  _renderAgentsDrawer();
}

function _renderToolbar() {
  const tb = $("#svr-toolbar"); if (!tb) return;
  const total = allInstances.length;
  const running = allInstances.filter(i => i.status === "running").length;
  const stopped = allInstances.filter(i => i.status === "stopped").length;
  const issues  = allInstances.filter(i => /^(crashed|error|delete_failed|install_failed)/i.test(i.status || "")).length;
  // Per-agent server counts so the chip shows "agent (12)" instead of guessing.
  const byAgent = {};
  for (const a of allAgents) byAgent[a.agent_id] = 0;
  for (const i of allInstances) if (byAgent[i.agent_id] !== undefined) byAgent[i.agent_id]++;

  const statusPills = [
    { v: "all",     label: "All",     count: total },
    { v: "running", label: "Running", count: running, status: "running" },
    { v: "stopped", label: "Stopped", count: stopped, status: "stopped" },
  ];
  if (issues > 0) statusPills.push({ v: "issues", label: "Issues", count: issues, status: "issues" });

  const html = `
    <div class="svr-tb-group">
      <span class="svr-tb-label">Show</span>
      ${statusPills.map(p => `
        <button class="svr-pill ${serverFilter.status === p.v ? "active" : ""}" data-svr-pill="status:${esc(p.v)}"${p.status ? ` data-status="${p.status}"` : ""}>
          ${p.status ? `<span class="pill-dot"></span>` : ""}${esc(p.label)}<span class="pill-count">${p.count}</span>
        </button>
      `).join("")}
    </div>
    ${(() => {
      // Per-game chips. Only render the group when there's more than one
      // distinct module in play — single-game panels don't need a "Game"
      // filter, the agent + status chips already disambiguate. Counts
      // refresh on every toolbar render.
      const byModule = {};
      for (const i of allInstances) byModule[i.module_id] = (byModule[i.module_id] || 0) + 1;
      const modIDs = Object.keys(byModule);
      if (modIDs.length < 2) return "";
      modIDs.sort((a, b) => (byModule[b] - byModule[a]) || a.localeCompare(b));
      return `
        <div class="svr-tb-sep"></div>
        <div class="svr-tb-group svr-tb-games">
          <span class="svr-tb-label">Game</span>
          <button class="svr-pill ${serverFilter.module === "all" ? "active" : ""}" data-svr-pill="module:all">All</button>
          ${modIDs.map(m => {
            const mod = (allModules.find(x => x.id === m) || {});
            const lab = _moduleAbbr(m) || mod.name || m;
            return `<button class="svr-pill ${serverFilter.module === m ? "active" : ""}" data-svr-pill="module:${esc(m)}" title="${esc(mod.name || m)}">${esc(lab)} <span class="pill-count">${byModule[m]}</span></button>`;
          }).join("")}
        </div>
      `;
    })()}
    <div class="svr-tb-spacer"></div>
    <div class="svr-tb-cluster">
      <div class="svr-tb-group svr-group-toggle" role="group" aria-label="Group servers by">
        <span class="svr-tb-label">Group</span>
        <button class="svr-pill ${serverFilter.group === "game" ? "active" : ""}" data-svr-group="game" title="Group servers by game">By game</button>
        <button class="svr-pill ${serverFilter.group === "host" ? "active" : ""}" data-svr-group="host" title="Group servers by host (agent)">By host</button>
        <button class="svr-pill ${serverFilter.group === "flat" ? "active" : ""}" data-svr-group="flat" title="No grouping — one flat grid">Flat</button>
      </div>
      <span class="svr-tb-btn">
        <select id="svr-sort" title="Sort">
          <option value="name"${serverFilter.sort === "name" ? " selected" : ""}>Name</option>
          <option value="status"${serverFilter.sort === "status" ? " selected" : ""}>Status</option>
          <option value="recent"${serverFilter.sort === "recent" ? " selected" : ""}>Last updated</option>
        </select>
      </span>
    </div>
  `;
  // Dirty-check: most poll ticks change nothing here. Skipping the innerHTML
  // assignment when the markup is byte-identical avoids tearing down + re-
  // attaching listeners and saves a style/layout pass. The old DOM (and its
  // listeners) stays live, so no re-wiring is needed on the skip path.
  if (html === _renderToolbar._lastHTML) return;
  _renderToolbar._lastHTML = html;
  tb.innerHTML = html;
  const sortSel = $("#svr-sort");
  if (sortSel) sortSel.addEventListener("change", e => {
    serverFilter.sort = e.target.value;
    _renderGrid();
  });
  // Group-by toggle: game / host / flat. Persisted per-device. Re-renders
  // the grid (which rebuilds sections from scratch when the mode changes).
  tb.querySelectorAll("[data-svr-group]").forEach(btn => {
    btn.addEventListener("click", () => {
      const mode = btn.dataset.svrGroup;
      if (serverFilter.group === mode) return;
      serverFilter.group = mode;
      try { localStorage.setItem("rcc.group.v1", mode); } catch {}
      tb.querySelectorAll("[data-svr-group]").forEach(b => b.classList.toggle("active", b.dataset.svrGroup === mode));
      _renderGrid();
    });
  });
}

function _refreshToolbarActive() {
  document.querySelectorAll("#svr-toolbar [data-svr-pill]").forEach(p => {
    const [k, v] = p.dataset.svrPill.split(":");
    const isActive =
      (k === "status" && serverFilter.status === v) ||
      (k === "agent"  && serverFilter.agent  === v) ||
      (k === "module" && serverFilter.module === v);
    p.classList.toggle("active", isActive);
  });
}

function _serverSortCmp(a, b) {
  if (serverFilter.sort === "agent") {
    const ax = (a.agent_id || ""); const bx = (b.agent_id || "");
    if (ax !== bx) return ax.localeCompare(bx);
    return a.instance_id.localeCompare(b.instance_id);
  }
  if (serverFilter.sort === "status") {
    const order = { running: 0, starting: 1, stopping: 2, ready: 3, stopped: 4, crashed: 5, deleting: 6 };
    const ai = order[a.status] != null ? order[a.status] : 9;
    const bi = order[b.status] != null ? order[b.status] : 9;
    if (ai !== bi) return ai - bi;
    return a.instance_id.localeCompare(b.instance_id);
  }
  if (serverFilter.sort === "recent") {
    const at = a.updated_at ? Date.parse(a.updated_at) : 0;
    const bt = b.updated_at ? Date.parse(b.updated_at) : 0;
    return bt - at;
  }
  return a.instance_id.localeCompare(b.instance_id);
}

function _renderGrid() {
  const grid = $("#svr-grid"); if (!grid) return;
  // Always render every instance; filter visibility is layered on top.
  // Orphan instances (whose agent_id no longer exists) still appear here —
  // their agent chip just shows the unknown id with the offline marker so
  // the operator can find + delete them.
  const list = allInstances.slice().sort(_serverSortCmp);

  // Harvest every existing card element (from flat mode OR previous
  // per-agent sections) so a group-mode toggle reuses the same DOM nodes —
  // animations don't restart, click state doesn't reset, etc.
  const existing = new Map();
  grid.querySelectorAll(":scope [data-card-id]").forEach(el => {
    if (el.dataset.cardId) existing.set(el.dataset.cardId, el);
  });

  // Grouping is operator-selectable (Group toggle in the toolbar):
  //   game → bucket by module_id, header = game name + icon
  //   host → bucket by agent_id,  header = agent name + online dot
  //   flat → no grouping, one responsive grid
  // "flat" also covers the trivial single-bucket case implicitly.
  const groupMode = serverFilter.group || "game";
  const grouped = groupMode !== "flat";
  grid.classList.toggle("grouped", grouped);
  grid.dataset.groupMode = groupMode;

  if (!grouped) {
    // Flat mode: clear non-card children, then diff-render into the grid.
    for (const el of Array.from(grid.children)) {
      if (!(el.dataset && el.dataset.cardId)) el.remove();
    }
    const seen = new Set();
    list.forEach((i, idx) => {
      seen.add(i.instance_id);
      let el = existing.get(i.instance_id);
      if (!el) { el = buildCard(i); grid.appendChild(el); }
      else if (el.parentElement !== grid) grid.appendChild(el);
      el.style.setProperty("--i", Math.min(idx, 24));
      updateCard(el, i);
    });
    list.forEach((i, idx) => {
      const el = existing.get(i.instance_id) || grid.querySelector(`[data-card-id="${CSS.escape(i.instance_id)}"]`);
      if (!el) return;
      const ref = grid.children[idx];
      if (ref && ref !== el) grid.insertBefore(el, ref);
      else if (!ref) grid.appendChild(el);
    });
    for (const [id, el] of existing) if (!seen.has(id)) el.remove();
  } else {
    // Grouped mode (game | host): diff render. Reuse existing <section>
    // nodes keyed by bucket id so a refresh tick only updates header text +
    // the cards inside, never destroys the layout (which would flash/jitter
    // the page). Strip any flat-mode leftovers (cards that ended up directly
    // on #svr-grid before the mode switch) — re-homed below. When the mode
    // CHANGES (game<->host), the old sections key on a different namespace
    // and won't be reused, so we also drop stale sections at the end.
    for (const el of Array.from(grid.children)) {
      if (el.classList.contains("svr-agent-section")) continue;
      if (el.dataset && el.dataset.cardId) continue;
      el.remove();
    }

    // bucketKeyFor + bucketMetaFor abstract over the two grouping modes so
    // the diff-render loop below is identical for game and host.
    const byHost = groupMode === "host";
    const bucketKeyFor = (i) => byHost ? (i.agent_id || "(unknown)") : (i.module_id || "(unknown)");

    // Bucket the globally-sorted list so each section's internal order
    // matches the chosen sort within that bucket's slice.
    const buckets = new Map();
    for (const i of list) {
      const k = bucketKeyFor(i);
      if (!buckets.has(k)) buckets.set(k, []);
      buckets.get(k).push(i);
    }

    // Section order:
    //   host → known agents first (by label), then orphan buckets
    //   game → by running-count desc, then total desc, then label
    let orderedKeys = [];
    if (byHost) {
      const knownAgents = allAgents.slice().sort((a, b) => (a.label || a.agent_id).localeCompare(b.label || b.agent_id));
      const knownIds = new Set();
      for (const a of knownAgents) {
        knownIds.add(a.agent_id);
        if (buckets.has(a.agent_id)) orderedKeys.push(a.agent_id);
      }
      for (const k of buckets.keys()) if (!knownIds.has(k)) orderedKeys.push(k);
    } else {
      orderedKeys = Array.from(buckets.keys()).sort((a, b) => {
        const ba = buckets.get(a), bb = buckets.get(b);
        const ra = ba.filter(x => x.status === "running").length;
        const rb = bb.filter(x => x.status === "running").length;
        if (rb !== ra) return rb - ra;
        if (bb.length !== ba.length) return bb.length - ba.length;
        const la = (allModules.find(m => m.id === a) || {}).name || a;
        const lb = (allModules.find(m => m.id === b) || {}).name || b;
        return la.localeCompare(lb);
      });
    }

    // Index existing sections so we can reuse them in place. The data-group
    // attr is namespaced by mode so a game-section is never mistaken for a
    // host-section of the same id.
    const existingSections = new Map();
    grid.querySelectorAll(":scope > .svr-agent-section").forEach(sec => {
      if (sec.dataset.groupKey) existingSections.set(sec.dataset.groupKey, sec);
    });

    const seen = new Set();
    const seenKeys = new Set();
    let cardIdx = 0;
    orderedKeys.forEach((key, secIdx) => {
      const nsKey = groupMode + ":" + key;
      seenKeys.add(nsKey);
      const bucket = buckets.get(key) || [];
      const total = bucket.length;
      const running = bucket.filter(x => x.status === "running").length;

      // Resolve header presentation per mode.
      let lab, icon = "", dotOn = false, dotOff = false, badgeText = "", badgeCls = "";
      if (byHost) {
        const agent = allAgents.find(a => a.agent_id === key);
        const isKnown = !!agent;
        const offline = !isKnown || !agent.connected;
        lab = agent ? (agent.label || agent.agent_id) : key;
        dotOn = !offline; dotOff = offline;
        if (!isKnown) { badgeText = "orphaned"; badgeCls = "orphan"; }
        else if (offline) { badgeText = "offline"; badgeCls = "offline"; }
      } else {
        const mod = (allModules.find(m => m.id === key) || {});
        lab = mod.name || key;
        const app = appearanceFor(key);
        icon = (app && app.emoji) ? app.emoji : "🎮";
      }

      let section = existingSections.get(nsKey);
      if (!section) {
        section = document.createElement("section");
        section.className = "svr-agent-section";
        section.dataset.groupKey = nsKey;
        section.innerHTML = `
          <div class="svr-agent-header">
            <span class="svr-agent-icon" data-grp-icon style="display:none"></span>
            <span class="svr-agent-dot" data-grp-dot style="display:none"></span>
            <span class="svr-agent-lab" data-grp-lab></span>
            <span class="svr-agent-badge" data-grp-badge style="display:none"></span>
            <span class="svr-agent-meta" data-grp-meta></span>
          </div>
          <div class="svr-group-grid"></div>
        `;
        grid.appendChild(section);
      }
      // Update header in place (text-only changes don't reflow the cards).
      const iconEl = section.querySelector("[data-grp-icon]");
      const dot    = section.querySelector("[data-grp-dot]");
      const labEl  = section.querySelector("[data-grp-lab]");
      const badge  = section.querySelector("[data-grp-badge]");
      const meta   = section.querySelector("[data-grp-meta]");
      if (icon) {
        iconEl.style.display = "";
        if (iconEl.textContent !== icon) iconEl.textContent = icon;
        dot.style.display = "none";
      } else {
        iconEl.style.display = "none";
        dot.style.display = "";
        dot.classList.toggle("on", dotOn);
        dot.classList.toggle("off", dotOff);
      }
      if (labEl.textContent !== lab) labEl.textContent = lab;
      if (badgeText) {
        badge.style.display = "";
        badge.className = "svr-agent-badge " + badgeCls;
        if (badge.textContent !== badgeText) badge.textContent = badgeText;
      } else {
        badge.style.display = "none";
        badge.textContent = "";
      }
      const metaText = `${total} server${total === 1 ? "" : "s"} · ${running} running`;
      if (meta.textContent !== metaText) meta.textContent = metaText;

      // Reorder section to match desired position (cheap if already correct).
      const refSec = grid.children[secIdx];
      if (refSec && refSec !== section) grid.insertBefore(section, refSec);
      else if (!refSec) grid.appendChild(section);

      // Diff the inner cards.
      const inner = section.querySelector(".svr-group-grid");
      bucket.forEach((i, idx) => {
        seen.add(i.instance_id);
        let el = existing.get(i.instance_id);
        if (!el) el = buildCard(i);
        if (el.parentElement !== inner) inner.appendChild(el);
        const ref = inner.children[idx];
        if (ref && ref !== el) inner.insertBefore(el, ref);
        else if (!ref) inner.appendChild(el);
        el.style.setProperty("--i", Math.min(cardIdx++, 24));
        updateCard(el, i);
      });
      // Trim any cards in this section that no longer belong.
      Array.from(inner.children).forEach(el => {
        if (el.dataset && el.dataset.cardId && !bucket.find(x => x.instance_id === el.dataset.cardId)) {
          el.remove();
        }
      });
    });
    // Remove sections whose bucket disappeared (incl. all sections from the
    // previous group mode, which carry a different namespace prefix).
    existingSections.forEach((sec, nsKey) => {
      if (!seenKeys.has(nsKey)) sec.remove();
    });
    for (const [id, el] of existing) if (!seen.has(id)) el.remove();
  }
  _applyServerFilter();
}

// In-app confirm/prompt for bulk operations. Returns:
//   { confirmed: true,  value: <input string|null> }   on OK
//   { confirmed: false, value: null }                   on Cancel/Esc
//
// Replaces window.confirm()/prompt() for batch actions because those
// browser-native dialogs are blocky, breaking, sometimes blocked by
// extensions, and don't match the rest of the panel. opts shape:
//   title           — modal heading (string)
//   message         — primary copy (string or HTML; use safeHTML to
//                     pre-escape if interpolating user content)
//   list            — optional [{label}] for the "affected servers" UL
//   listHeader      — optional header above the list
//   grace           — optional informational hint ("Sequential — 10s …")
//   input           — optional { label, placeholder, default } to show
//                     a single-line freeform input. value lands in
//                     resolve's `value` field.
//   okLabel         — Confirm button label (default "Confirm")
//   okStyle         — "primary" (default) or "danger" tinting
function bulkConfirm(opts) {
  return new Promise((resolve) => {
    const modal = $("#bulk-confirm-modal");
    const titleEl = $("#bcm-title");
    const msgEl = $("#bcm-message");
    const inputWrap = $("#bcm-input-wrap");
    const inputLab = $("#bcm-input-label");
    const inputEl = $("#bcm-input");
    const listWrap = $("#bcm-list-wrap");
    const listHead = $("#bcm-list-head");
    const listEl = $("#bcm-list");
    const graceEl = $("#bcm-grace");
    const okBtn = $("#bcm-ok");
    const cancelBtn = $("#bcm-cancel");

    titleEl.textContent = opts.title || "Confirm";
    if (opts.html) msgEl.innerHTML = opts.message || "";
    else msgEl.textContent = opts.message || "";

    if (opts.input) {
      inputWrap.style.display = "";
      inputLab.textContent = opts.input.label || "";
      inputEl.placeholder = opts.input.placeholder || "";
      inputEl.value = opts.input.default || "";
    } else {
      inputWrap.style.display = "none";
      inputEl.value = "";
    }

    if (opts.list && opts.list.length) {
      listWrap.style.display = "";
      listHead.textContent = opts.listHeader || `Affects ${opts.list.length} server${opts.list.length === 1 ? "" : "s"}:`;
      listEl.innerHTML = opts.list.slice(0, 12).map(s => `<li>${esc(s.label || s)}</li>`).join("")
        + (opts.list.length > 12 ? `<li class="more">… and ${opts.list.length - 12} more</li>` : "");
    } else {
      listWrap.style.display = "none";
      listEl.innerHTML = "";
    }

    if (opts.grace) {
      graceEl.style.display = "";
      graceEl.textContent = opts.grace;
    } else {
      graceEl.style.display = "none";
    }

    okBtn.textContent = opts.okLabel || "Confirm";
    okBtn.classList.toggle("danger", opts.okStyle === "danger");

    let cleaned = false;
    function cleanup(result) {
      if (cleaned) return;
      cleaned = true;
      okBtn.removeEventListener("click", onOk);
      cancelBtn.removeEventListener("click", onCancel);
      modal.removeEventListener("click", onBgClick);
      inputEl.removeEventListener("keydown", onKey);
      document.removeEventListener("keydown", onEsc);
      hideModal("#bulk-confirm-modal");
      resolve(result);
    }
    function onOk() {
      const value = opts.input ? inputEl.value : null;
      if (opts.input && opts.input.required !== false && (!value || !value.trim())) {
        inputEl.focus();
        inputEl.style.borderColor = "rgba(248,81,73,.6)";
        return;
      }
      cleanup({ confirmed: true, value });
    }
    function onCancel() { cleanup({ confirmed: false, value: null }); }
    function onBgClick(e) {
      if (e.target === modal) cleanup({ confirmed: false, value: null });
      const closeBtn = e.target.closest("[data-close]");
      if (closeBtn && modal.contains(closeBtn)) cleanup({ confirmed: false, value: null });
    }
    function onKey(e) {
      if (e.key === "Enter") { e.preventDefault(); onOk(); }
    }
    function onEsc(e) { if (e.key === "Escape") onCancel(); }

    okBtn.addEventListener("click", onOk);
    cancelBtn.addEventListener("click", onCancel);
    modal.addEventListener("click", onBgClick);
    inputEl.addEventListener("keydown", onKey);
    document.addEventListener("keydown", onEsc);

    showModal("#bulk-confirm-modal");
    setTimeout(() => {
      if (opts.input) inputEl.focus();
      else okBtn.focus();
    }, 50);
  });
}

// Game-specific batch commands. Keyed by module-id; each entry is an
// optional list of "extra" batch actions surfaced when the dashboard is
// filtered to that one module. `rcon` is the literal command string;
// `confirmIf` is an optional predicate against the RCON output that
// decides whether the per-server confirmation should glow green ("got
// the response we expected") instead of just neutral ack.
//
// Keep the list focused — one or two signature commands per game. The
// modal's full RCON console is the right place for one-off ops.
// `confirmIf` is intentionally absent on save-style commands — RCON
// "no error" is the canonical success signal. ARK's SaveWorld in
// particular sometimes returns no body at all but still flushes the
// world to disk; if confirmIf returned false on empty output the
// operator saw "unexpected response" while the save was actually
// landing. Output text is shown in the toast for informational value.
const BATCH_GAME_ACTIONS = {
  "ark-sa": [
    { id: "save",     label: "💾 Save World", title: "Sends SaveWorld over RCON to every visible server", rcon: "SaveWorld" },
    { id: "broadcast", label: "📢 Broadcast", title: "Send a broadcast message to every visible server",
      rconPrompt: { prompt: "Broadcast message to all visible ARK servers:", template: 'Broadcast "{msg}"' } },
  ],
  "7dtd": [
    { id: "save",     label: "💾 Save World", title: "Sends saveworld over telnet RCON to every visible server", rcon: "saveworld" },
    { id: "say",      label: "📢 Say",        title: "Send a chat message to every visible server",
      rconPrompt: { prompt: "Chat message to broadcast:", template: 'say "{msg}"' } },
  ],
  "minecraft-java": [
    { id: "save",     label: "💾 Save All",   title: "Runs save-all on every visible server", rcon: "save-all" },
  ],
  "valheim": [],
  "v-rising": [],
  "factorio": [
    { id: "save",     label: "💾 Save",       title: "Runs /save on every visible server", rcon: "/save" },
  ],
  "palworld":  [
    { id: "save",     label: "💾 Save",       title: "Runs Save on every visible server", rcon: "Save" },
  ],
};

// Tracks the currently-running batch so we can show a "Cancel" button
// + ignore overlapping triggers. One queue at a time globally.
let _batchInFlight = null;

function _visibleInstances() {
  // The grid renders all instances and toggles display:none for hidden
  // ones; query that instead of recomputing the filter so we always
  // act on what the operator can see (and can verify before clicking).
  const grid = $("#svr-grid"); if (!grid) return [];
  const visible = [];
  for (const el of grid.children) {
    if (!el.dataset || !el.dataset.cardId) continue;
    if (el.style.display === "none") continue;
    const i = allInstances.find(x => x.instance_id === el.dataset.cardId);
    if (i) visible.push(i);
  }
  return visible;
}

function _renderBatchBar() {
  const bar = $("#svr-batchbar"); if (!bar) return;
  const list = _visibleInstances();
  // Hide the bar only when nothing's visible. Even a single visible
  // server gets the bar so game-specific commands (Save World, Say,
  // Broadcast) surface as soon as the filter narrows to one game —
  // operators kept asking "where did Save World go" when scoping to
  // a single 7DTD or V Rising server.
  if (list.length === 0) {
    if (_renderBatchBar._lastHTML !== "") {
      _renderBatchBar._lastHTML = "";
      bar.style.display = "none"; bar.innerHTML = "";
    }
    return;
  }
  bar.style.display = "";

  const runningCount = list.filter(i => i.status === "running").length;
  const stoppedCount = list.filter(i => i.status === "stopped" || /^crashed/i.test(i.status || "")).length;
  const startable = stoppedCount > 0;
  const stoppable = runningCount > 0;
  const restartable = runningCount > 0;

  // Game-specific extras only appear when the filter narrows to ONE
  // module (the request was "if I filter the list by game, game-
  // specific actions should pop up").
  let extrasHTML = "";
  if (serverFilter.module !== "all") {
    const extras = BATCH_GAME_ACTIONS[serverFilter.module] || [];
    if (extras.length) {
      const mod = (allModules.find(m => m.id === serverFilter.module) || {});
      extrasHTML = `
        <span class="svr-batch-sep"></span>
        <span class="svr-batch-label">${esc(mod.name || serverFilter.module)}</span>
        ${extras.map(a => `<button class="svr-batch-btn game" data-batch="${esc(a.id)}" title="${esc(a.title || a.label)}"${runningCount === 0 ? " disabled" : ""}>${esc(a.label)}</button>`).join("")}
      `;
    }
  }

  const inFlight = _batchInFlight ? `
    <div class="svr-batch-progress" id="svr-batch-progress">
      <span class="dot"></span>
      <span class="line" id="svr-batch-line">${esc(_batchInFlight.statusLine || "running…")}</span>
      <button class="svr-batch-cancel" data-batch="cancel" type="button">Cancel</button>
    </div>` : "";

  const html = `
    <span class="svr-batch-label">Bulk (${list.length})</span>
    <button class="svr-batch-btn start" data-batch="start" title="Start every visible server, sequentially with a 10-second grace period between starts"${startable ? "" : " disabled"}>▶ Start all (${stoppedCount})</button>
    <button class="svr-batch-btn stop"  data-batch="stop"  title="Stop every visible running server, sequentially"${stoppable ? "" : " disabled"}>■ Stop all (${runningCount})</button>
    <button class="svr-batch-btn restart" data-batch="restart" title="Restart every visible running server, sequentially with a 10-second grace period"${restartable ? "" : " disabled"}>⟳ Restart all (${runningCount})</button>
    ${extrasHTML}
    <span class="svr-tb-spacer"></span>
    ${inFlight}
  `;
  // Dirty-check — skip the innerHTML write when nothing changed (click
  // handling is delegated on the container, so the skip path loses nothing).
  if (html === _renderBatchBar._lastHTML) return;
  _renderBatchBar._lastHTML = html;
  bar.innerHTML = html;
}

// Sequential batch runner. Clicks queue every visible server, then
// fire one at a time so we never slam an agent or the host with
// concurrent starts (cold-start ARK pulls dozens of GB into RAM,
// concurrent starts swap-thrash the box). Per-server result lands
// as a toast + the line in the batch progress strip.
//
// Grace periods:
//   start   — 10s between (allows ports to fully bind, ASA to claim
//             SteamCMD, DB writes to settle).
//   stop    — 2s between (just enough that the agent's stop reply +
//             container teardown unwind in order).
//   restart — 12s between (stop + start handshake takes ~10s on ARK).
//   rcon    — 1s between (agent dispatcher serializes anyway, but the
//             grace gives SaveWorld in particular a moment to land its
//             flush before the next call).
async function runBatchAction(actionID, btn) {
  if (actionID === "cancel") {
    if (_batchInFlight) _batchInFlight.cancel = true;
    return;
  }
  if (_batchInFlight) {
    toast("Another batch is still running — Cancel it first.", "err");
    return;
  }
  const targets = _visibleInstances();
  if (!targets.length) { toast("Nothing visible to batch.", "err"); return; }

  // Map action → per-target work function + in-flight grace period.
  // Kept here so the queue runner stays generic.
  let workFn, label, graceMs;
  // Game-specific action lookup.
  let gameAction = null;
  if (serverFilter.module !== "all") {
    gameAction = (BATCH_GAME_ACTIONS[serverFilter.module] || []).find(a => a.id === actionID) || null;
  }
  // Game-specific RCON prompts (e.g. "Broadcast message:") are shown
  // up-front in the same in-app modal as the confirm step below — but
  // we capture the value here so the confirm dialog can show what's
  // about to be sent. Native window.prompt is intentionally avoided.
  // promptValue stays null when the action doesn't need an input;
  // the confirm modal will then render without the input field.
  let promptValue = null;
  // Resolved at confirm-time below so cancel cleanly aborts.

  switch (actionID) {
    case "start":
      label = "Start";
      graceMs = 10000;
      workFn = async (i) => {
        // Optimistic flip — same path the per-tile Start button takes
        // (markStarting opens an SSE so the yellow pulse rides the card
        // until the ready-regex / RCON / instance_state confirms running).
        markStarting(i.instance_id);
        try {
          return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/start`, { method: "POST" });
        } catch (err) {
          clearStarting(i.instance_id);
          throw err;
        }
      };
      break;
    case "stop":
      label = "Stop";
      graceMs = 2000;
      workFn = async (i) => {
        clearStarting(i.instance_id);
        markStopping(i.instance_id);
        try {
          return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/stop`, { method: "POST" });
        } catch (err) {
          clearStopping(i.instance_id);
          throw err;
        }
      };
      break;
    case "restart":
      label = "Restart";
      graceMs = 12000;
      workFn = async (i) => {
        // Mirror doInstanceRestart's Phase-1/Phase-2 trick — keep the
        // amber overlay on via the lightweight class while we land the
        // Stop, THEN arm a fresh markStarting so its SSE only sees the
        // post-Start events (otherwise the replay-tail of the previous
        // run would clear the pulse instantly).
        _paintRestartingCard(i.instance_id, true);
        try {
          await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/stop?grace=30`, { method: "POST" });
        } catch (err) {
          _paintRestartingCard(i.instance_id, false);
          throw err;
        }
        // Wait for the container to actually report stopped before Start.
        const deadline = Date.now() + 45000;
        while (Date.now() < deadline) {
          await new Promise(r => setTimeout(r, 1000));
          try {
            const { instances } = await fetchJSON("/api/instances");
            const cur = instances.find(x => x.instance_id === i.instance_id);
            if (!cur || cur.status === "stopped" || cur.status === "crashed") break;
          } catch {}
        }
        _paintRestartingCard(i.instance_id, false);
        markStarting(i.instance_id);
        try {
          return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/start`, { method: "POST" });
        } catch (err) {
          clearStarting(i.instance_id);
          throw err;
        }
      };
      break;
    default:
      if (!gameAction) { toast(`Unknown batch action: ${actionID}`, "err"); return; }
      label = gameAction.label.replace(/^[^\w]+\s*/, "");
      graceMs = 1000;
      const cmdTemplate = gameAction.rcon || (gameAction.rconPrompt && gameAction.rconPrompt.template) || "";
      const cmd = promptValue ? cmdTemplate.replace("{msg}", promptValue.replace(/"/g, '\\"')) : cmdTemplate;
      workFn = async (i) => {
        const r = await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/rcon`, {
          method: "POST", headers: {"Content-Type": "application/json"},
          body: JSON.stringify({ command: cmd, timeout_ms: 8000 }),
        });
        // Treat empty string as success too — many games (ARK SaveWorld)
        // reply with no body but exit cleanly.
        const out = r.output || "";
        const ok = !r.error && (gameAction.confirmIf ? gameAction.confirmIf(out) : true);
        return { ok, output: out, error: r.error };
      };
      break;
  }

  // Filter targets sensibly: don't try to start a running server, etc.
  const skip = new Set();
  if (actionID === "start")     for (const i of targets) if (i.status === "running") skip.add(i.instance_id);
  if (actionID === "stop")      for (const i of targets) if (i.status !== "running" && i.status !== "starting") skip.add(i.instance_id);
  if (actionID === "restart")   for (const i of targets) if (i.status !== "running") skip.add(i.instance_id);
  if (gameAction)               for (const i of targets) if (i.status !== "running") skip.add(i.instance_id);
  const queue = targets.filter(i => !skip.has(i.instance_id));
  if (!queue.length) { toast(`Nothing to ${label.toLowerCase()} — every visible server is already in the right state.`, "err"); return; }

  // In-app confirm dialog. Combined input + list + grace hint.
  const messageVerb = label.toLowerCase();
  const confirmTitle = gameAction
    ? `${label} on ${queue.length} server${queue.length === 1 ? "" : "s"}`
    : `${label} ${queue.length} server${queue.length === 1 ? "" : "s"}`;
  const message = gameAction
    ? `Send <strong>${esc(label)}</strong> to ${queue.length} ${queue.length === 1 ? "server" : "servers"} below.`
    : `About to ${esc(messageVerb)} ${queue.length} ${queue.length === 1 ? "server" : "servers"}.`;
  const confirmRes = await bulkConfirm({
    title: confirmTitle,
    message,
    html: true,
    list: queue.map(i => ({ label: i.instance_id })),
    listHeader: `Affects ${queue.length} server${queue.length === 1 ? "" : "s"}:`,
    grace: queue.length > 1 ? `Sequential — ${(graceMs/1000).toFixed(0)}s grace between each. Use Cancel in the bulk bar to stop mid-queue.` : null,
    okLabel: gameAction ? `Send ${label}` : `${label} all`,
    okStyle: actionID === "stop" ? "danger" : "primary",
    input: (gameAction && gameAction.rconPrompt) ? {
      label: gameAction.rconPrompt.prompt,
      placeholder: "Type your message…",
      default: "",
    } : null,
  });
  if (!confirmRes.confirmed) return;
  if (gameAction && gameAction.rconPrompt) {
    promptValue = (confirmRes.value || "").trim();
    if (!promptValue) { toast("Cancelled — empty input.", "err"); return; }
    // Re-resolve workFn now that we have the prompt value (the earlier
    // assignment captured the empty string template).
    const cmdTemplate = gameAction.rconPrompt.template;
    const cmd = cmdTemplate.replace("{msg}", promptValue.replace(/"/g, '\\"'));
    workFn = async (i) => {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/rcon`, {
        method: "POST", headers: {"Content-Type": "application/json"},
        body: JSON.stringify({ command: cmd, timeout_ms: 8000 }),
      });
      const out = r.output || "";
      const ok = !r.error && (gameAction.confirmIf ? gameAction.confirmIf(out) : true);
      return { ok, output: out, error: r.error };
    };
  }

  // Immediate visible ack so the operator knows the click registered —
  // the per-server toasts come in over the next several seconds, but
  // this fires the moment we leave the confirm dialog.
  toast(`${label} batch queued — ${queue.length} server${queue.length === 1 ? "" : "s"}, ${(graceMs/1000).toFixed(0)}s grace between each.`, "ok");

  _batchInFlight = { action: actionID, label, total: queue.length, done: 0, ok: 0, fail: 0, cancel: false, statusLine: `${label} 0/${queue.length}…` };
  _renderBatchBar();

  for (let idx = 0; idx < queue.length; idx++) {
    if (_batchInFlight.cancel) { toast(`${label} cancelled — ${_batchInFlight.done}/${queue.length} done.`, "err"); break; }
    const i = queue[idx];
    _batchInFlight.statusLine = `${label} ${idx + 1}/${queue.length}: ${i.instance_id}…`;
    _renderBatchBar();
    try {
      const result = await workFn(i);
      const ok = (result && typeof result === "object" && "ok" in result) ? result.ok : true;
      const out = (result && result.output) ? String(result.output).trim() : "";
      if (ok) {
        _batchInFlight.ok++;
        toast(`${label} ✓ ${i.instance_id}${out ? " — " + (out.length > 80 ? out.slice(0, 77) + "…" : out) : ""}`, "ok");
      } else {
        _batchInFlight.fail++;
        toast(`${label} ⚠ ${i.instance_id} — ${result.error || "unexpected response"}`, "err");
      }
    } catch (err) {
      _batchInFlight.fail++;
      toast(`${label} ✗ ${i.instance_id} — ${err.message}`, "err");
    }
    _batchInFlight.done++;
    if (idx < queue.length - 1 && !_batchInFlight.cancel && graceMs > 0) {
      _batchInFlight.statusLine = `Waiting ${(graceMs/1000).toFixed(0)}s before next…`;
      _renderBatchBar();
      await new Promise(r => setTimeout(r, graceMs));
    }
  }

  const done = _batchInFlight.done, ok = _batchInFlight.ok, fail = _batchInFlight.fail;
  _batchInFlight = null;
  _renderBatchBar();
  toast(`${label} batch finished — ${ok}/${done} ok${fail ? `, ${fail} failed` : ""}`, fail ? "err" : "ok");
}

function _applyServerFilter() {
  const grid = $("#svr-grid"); if (!grid) return;
  const q = (serverFilter.search || "").trim().toLowerCase();
  let visible = 0;
  // Card elements may live directly in #svr-grid (flat mode) OR inside
  // per-section .svr-group-grid containers (grouped mode). The querySelector
  // covers both.
  grid.querySelectorAll("[data-card-id]").forEach(el => {
    const id = el.dataset.cardId;
    const i = allInstances.find(x => x.instance_id === id);
    if (!i) { el.style.display = "none"; return; }
    let show = true;
    if (serverFilter.status === "running") show = i.status === "running";
    else if (serverFilter.status === "stopped") show = i.status === "stopped";
    else if (serverFilter.status === "issues") show = /^(crashed|error|delete_failed|install_failed)/i.test(i.status || "");
    if (show && serverFilter.agent !== "all" && i.agent_id !== serverFilter.agent) show = false;
    if (show && serverFilter.module !== "all" && i.module_id !== serverFilter.module) show = false;
    if (show && q) {
      const hay = ((i.instance_id || "") + " " + (i.module_id || "") + " " + (i.agent_id || "")).toLowerCase();
      if (!hay.includes(q)) show = false;
    }
    el.style.display = show ? "" : "none";
    if (show) visible++;
  });
  // Hide any per-agent section that has zero visible cards so the layout
  // doesn't show empty headers.
  grid.querySelectorAll(".svr-agent-section").forEach(sec => {
    const anyVisible = !!sec.querySelector("[data-card-id]:not([style*='display: none'])");
    sec.style.display = anyVisible ? "" : "none";
  });
  // Empty state under the grid (sibling node so it doesn't fight the
  // section layout in grouped mode).
  let empty = grid.parentElement.querySelector(":scope > .svr-empty");
  if (visible === 0) {
    if (!empty) {
      empty = document.createElement("div");
      empty.className = "svr-empty";
      grid.insertAdjacentElement("afterend", empty);
    }
    if (allInstances.length === 0) {
      empty.innerHTML = `<div class="big">${EMPTY_ICONS.server}</div>No servers yet<span class="hint">Click <b>+ New server</b> in the sidebar to spin one up.</span>`;
    } else {
      empty.innerHTML = `<div class="big">${EMPTY_ICONS.search}</div>No servers match these filters<span class="hint">Try clicking <b>All</b> above, or clearing the search.</span>`;
    }
  } else if (empty) {
    empty.remove();
  }
  // Visible-set may have shrunk/grown — refresh the bulk action bar so
  // the per-button counts and game-specific actions match what's on screen.
  _renderBatchBar();
}

function _renderAgentsDrawer() {
  const body = $("#agents-drawer-body");
  const sum  = $("#agents-drawer-summary");
  if (!body || !sum) return;
  const offline = allAgents.filter(a => !a.connected).length;
  // Orphan instances — referenced agent_ids that aren't registered anymore.
  const knownAgentIds = new Set(allAgents.map(a => a.agent_id));
  const orphanIds = new Set();
  for (const i of allInstances) if (!knownAgentIds.has(i.agent_id)) orphanIds.add(i.agent_id || "(unknown)");
  sum.textContent = `${allAgents.length} agent${allAgents.length === 1 ? "" : "s"}${offline ? ` · ${offline} offline` : ""}${orphanIds.size ? ` · ${orphanIds.size} orphan` : ""}`;
  const realAgents = allAgents.map(a => ({
    agent_id: a.agent_id, label: a.label, hostname: a.hostname,
    os: a.os, arch: a.arch, version: a.version, last_seen: a.last_seen,
    connected: a.connected, _orphan: false,
  }));
  const orphanAgents = Array.from(orphanIds).map(aid => ({
    agent_id: aid, label: aid + " (orphaned)", hostname: "—",
    os: "?", arch: "?", version: "?", last_seen: null, connected: false, _orphan: true,
  }));
  const combined = realAgents.concat(orphanAgents);
  const html = combined.map(a => {
    const lab = a.label || a.agent_id;
    const showId = a.label && a.label !== a.agent_id && !a._orphan;
    const meta = `<span class="mono">${esc(a.hostname || "—")}</span> · ${esc(a.os || "")}/${esc(a.arch || "")} · v${esc(a.version || "?")}${a.connected ? "" : (a._orphan ? " · orphaned" : " · offline")}${a.last_seen ? ` · last seen ${esc(fmtTime(a.last_seen))}` : ""}`;
    const actions = a._orphan ? "" : `
      <button class="small primary" data-agent-new-server="${esc(a.agent_id)}" title="Create a new server on this agent">＋</button>
      <button class="small" data-agent-edit="${esc(a.agent_id)}">Edit</button>
      <button class="small danger" data-agent-del="${esc(a.agent_id)}" title="Remove this agent from the panel">✕</button>
    `;
    return `<div class="agent-mini-card ${a.connected ? "" : "offline"}">
      <div class="agent-icon-mini">${a.connected ? "🖥️" : "⚠"}</div>
      <div class="info">
        <div class="label">${esc(lab)}${showId ? ` <span class="mono dim" style="font-size:10.5px;font-weight:400">${esc(a.agent_id)}</span>` : ""}</div>
        <div class="meta">${meta}</div>
      </div>
      <div class="actions">${actions}</div>
    </div>`;
  }).join("");
  // Dirty-check — agents change rarely; skip the rebuild when identical.
  if (html === _renderAgentsDrawer._lastHTML) return;
  _renderAgentsDrawer._lastHTML = html;
  body.innerHTML = html;
}

// Backwards-compat alias — SSE live-update path + topbar search use this.
function renderServerCards() { renderAgentsAndServers(); }

// New "glass-tile" card. Same data-* attributes as before so all the
// click delegation, SSE patches, and starting/stopping overlays keep
// working untouched. CSS is fresh — full-bleed art layer, frosted-glass
// info panel at the bottom, status badge top-right, agent chip top-left,
// quick actions revealed on hover.
// ── 7DTD cluster membership → color-coded card badge ───────────────────────
// clusterMap maps instance_id → {id, name, master}. Refreshed by pollInstances
// from /api/7dtd/clusters (cheap; only re-renders cards when it actually
// changes). The badge color is derived from the cluster id so each cluster is
// visually distinct without any hardcoded palette.
let clusterMap = {};
let _clustersJSON = "";

// Stable, well-distributed color from a cluster id → an HSL string. Same id
// always yields the same color. A raw `hash % 360` clusters similar hashes into
// the same hue band (three near-identical greens — useless for telling clusters
// apart), so we map the hash through the golden-angle (137.5°): successive
// values land far apart on the wheel. Lightness/saturation also wobble slightly
// by hash so two hues that happen to land close still read distinct.
function clusterColor(clusterId) {
  let h = 0;
  for (let k = 0; k < clusterId.length; k++) h = (h * 31 + clusterId.charCodeAt(k)) >>> 0;
  const hue = Math.round((h * 137.508) % 360);
  const sat = 68 + (h % 18);          // 68–85%
  const light = 58 + ((h >> 3) % 12); // 58–69%
  return `hsl(${hue} ${sat}% ${light}%)`;
}

// Short, human label for a cluster (its name, else a trimmed id).
function clusterLabel(c) {
  const n = (c && c.name || "").trim();
  if (n) return n;
  return c && c.id ? c.id.replace(/^cl_/, "").slice(0, 6) : "cluster";
}

async function refreshClusterMap() {
  try {
    const r = await fetchJSON("/api/7dtd/clusters");
    const clusters = r.clusters || [];
    const j = JSON.stringify(clusters);
    if (j === _clustersJSON) return false; // unchanged — skip rebuild
    _clustersJSON = j;
    const map = {};
    for (const c of clusters) {
      for (const inst of (c.members || [])) {
        map[inst] = { id: c.id, name: c.name, master: c.master === inst };
      }
    }
    clusterMap = map;
    return true; // changed — caller re-renders
  } catch { return false; }
}

// ── WI-14: "Update available" badges ────────────────────────────────────────
// updateCheckMap maps instance_id → last stored update-check row from
// /api/update-checks. Refreshed once at boot and after every manual
// "Check for updates" — checks are operator-initiated, so there's nothing
// to poll for in between. CHECK-ONLY: the badge never triggers an update.
let updateCheckMap = {};
async function refreshUpdateCheckMap() {
  try {
    const r = await fetchJSON("/api/update-checks");
    const map = {};
    for (const c of (r.checks || [])) map[c.instance_id] = c;
    updateCheckMap = map;
  } catch {}
}

// Card title: prefer the operator-set server name (what shows in the game
// browser) over the raw instance_id slug, so a card reads "Refuge Gaming 3.0
// PVE" instead of "v3-test". Modules key the name differently, so check the
// common ones in priority order; fall back to the slug when nothing's set.
function instanceDisplayName(i) {
  const cv = i.config_values || {};
  const name = cv.server_name || cv.SERVER_NAME || cv.ServerName ||
               cv.SESSION_NAME || cv.session_name || "";
  const trimmed = (name || "").trim();
  return trimmed || i.instance_id;
}

function buildCard(i) {
  const el = document.createElement("div");
  el.className = "card svr-tile";   // keep .card so legacy queries still hit
  el.dataset.cardId = i.instance_id;
  el.dataset.inst = i.instance_id;
  el.dataset.mod = i.module_id;
  el.dataset.agent = i.agent_id;
  // Overhauled card: NO click-to-flip strip. The art is a compact banner;
  // info (status/name/meta/bars) is ALWAYS visible; a clear action row sits
  // at the bottom at all times — a labeled primary button (Start/Stop) plus
  // secondary icon buttons (Restart / Console / Files / Manage) and a "More"
  // menu for the rare/dangerous actions (Force-kill / Backup / Delete).
  // Every data-* hook the action delegation relies on is preserved, just
  // relocated, so Start/Stop/Restart/Force-kill/Backup/Manage all keep
  // firing through the same handlers.
  const idA = esc(i.instance_id);
  el.innerHTML = `
    <div class="svr-card-banner">
      <div class="svr-tile-art" data-tile-art></div>
      <div class="svr-tile-art-fallback" data-tile-fallback style="display:none">🎮</div>
      <span class="svr-tile-agent" data-tile-agent></span>
      <span class="svr-tile-status" data-pill></span>
      <span class="svr-tile-cluster" data-cluster-badge></span>
      <span class="svr-tile-update" data-update-badge>⬆ Update available</span>
    </div>
    <div class="svr-tile-body">
      <div class="svr-tile-row">
        <span class="svr-tile-name" data-title></span>
      </div>
      <div class="svr-tile-meta">
        <span class="module" data-sub></span>
        <span class="sep" data-port-sep>·</span>
        <span class="port" data-port></span>
        <span class="sep" data-players-sep>·</span>
        <span class="players empty" data-hero-players>—</span>
        <span class="svr-tile-uptime empty" data-uptime>—</span>
      </div>
      <div class="svr-tile-bars">
        <div class="bar-row">
          <span class="lbl">CPU</span>
          <div class="bar"><div class="fill" data-fill-cpu></div></div>
          <span class="val" data-val-cpu>—</span>
        </div>
        <div class="bar-row">
          <span class="lbl">RAM</span>
          <div class="bar"><div class="fill" data-fill-ram></div></div>
          <span class="val" data-val-ram>—</span>
        </div>
      </div>
    </div>
    <div class="svr-card-actions">
      <button class="svr-act-primary" data-primary-btn type="button" title="Start">
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.74-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z"/></svg>
        <span data-primary-label>Start</span>
      </button>
      <button class="svr-act-icon" data-restart-btn type="button" title="Restart" aria-label="Restart">
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 7.74 10h-2.08a6 6 0 1 1-5.66-8c1.57 0 3 .57 4.07 1.51L13 11h7V4l-2.35 2.35z"/></svg>
      </button>
      <button class="svr-act-icon svr-act-manage" data-open="${idA}" type="button" title="Manage (console, files, settings, schedules)" aria-label="Manage">
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.5.5 0 0 0 .12-.61l-1.92-3.32a.5.5 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54A.5.5 0 0 0 13.91 2H10.07a.5.5 0 0 0-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.5.5 0 0 0-.59.22L2.74 8.47a.5.5 0 0 0 .12.61l2.03 1.58c-.05.3-.09.62-.09.94 0 .31.02.64.07.94l-2.03 1.58a.5.5 0 0 0-.12.61l1.92 3.32a.5.5 0 0 0 .59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96a.5.5 0 0 0 .59-.22l1.92-3.32a.5.5 0 0 0-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z"/></svg>
      </button>
      <div class="svr-act-more-wrap">
        <button class="svr-act-icon svr-act-more" data-card-more="${idA}" type="button" title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false">
          <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
        </button>
        <div class="svr-act-menu" data-card-menu hidden role="menu">
          <button class="svr-menu-item" data-open-tab="${idA}|console" type="button" role="menuitem">
            <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
            <span>Open console</span>
          </button>
          <button class="svr-menu-item" data-open-tab="${idA}|files" type="button" role="menuitem">
            <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>
            <span>Browse files</span>
          </button>
          <button class="svr-menu-item" data-card-backup="${idA}" type="button" role="menuitem">
            <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4zm-5 16a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm3-10H5V5h10v4z"/></svg>
            <span>Backup &amp; restore</span>
          </button>
          <div class="svr-menu-sep"></div>
          <button class="svr-menu-item danger" data-force-kill-btn type="button" role="menuitem" style="display:none">
            <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M13 2L4.09 12.97a1 1 0 0 0 .76 1.63H10v7l8.91-10.97a1 1 0 0 0-.76-1.63H13V2z"/></svg>
            <span>Force kill</span>
          </button>
          <button class="svr-menu-item danger" data-del="${idA}" type="button" role="menuitem">
            <svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M6 7h12l-1 14H7L6 7zm3-3h6l1 2H8l1-2z"/></svg>
            <span>Delete server…</span>
          </button>
        </div>
      </div>
    </div>
  `;
  return el;
}

function updateCard(el, i) {
  const app = appearanceFor(i.module_id);
  // When the instance is stopped/crashed, ignore any stats sample left
  // over from before — the container is gone and the bars should read
  // empty. Catches the polling path (state arrives via /api/instances)
  // even if the SSE clear at instance_state time missed.
  const isDead = i.status === "stopped" || i.status === "crashed";
  const stats = isDead ? {} : (latestStats[i.instance_id] || {});
  const appSt = isDead ? {} : (latestAppState[i.instance_id] || {});
  const cpuF = fmtCpu(stats);
  const cpuPct = cpuF.barPct;
  // RAM bar scaled to 20 GB so a 12 GB ARK reads as a meaningful ~60%
  // instead of the host-wide 62 GB making it look near-idle.
  const CARD_RAM_MAX_MB = 20480;
  const memPct = stats.memMB > 0 ? Math.min(100, (stats.memMB / CARD_RAM_MAX_MB) * 100) : 0;
  const isRunning = i.status === "running";
  const mod = (allModules.find(m => m.id === i.module_id) || {});
  const moduleName = mod.name || i.module_id;

  // Art layer — set background-image (URL or gradient as fallback).
  // We only touch backgroundImage; setting style.background = "" would
  // wipe the entire shorthand including the image we just assigned —
  // that was the "no game pictures" bug.
  const artEl = el.querySelector("[data-tile-art]");
  const fbEl  = el.querySelector("[data-tile-fallback]");
  const url = artURLFor(app);
  if (url) {
    if (artEl.dataset.artUrl !== url) {
      // Layer the URL over the gradient so a 404'd image still shows
      // something coherent rather than the raw bg-color.
      artEl.style.backgroundImage = `url("${url}"), ${app.grad || ""}`.replace(/, $/, "");
      artEl.dataset.artUrl = url;
    }
    if (fbEl) fbEl.style.display = "none";
  } else {
    if (artEl.dataset.artUrl !== "_grad") {
      artEl.style.backgroundImage = app.grad || "";
      artEl.dataset.artUrl = "_grad";
    }
    if (fbEl) {
      const e = app.emoji || "🎮";
      if (fbEl.dataset.emoji !== e) { fbEl.textContent = e; fbEl.dataset.emoji = e; }
      fbEl.style.display = "";
    }
  }

  setText(el, "[data-title]", instanceDisplayName(i));
  // Hover always reveals the full name even when the 2-line clamp cuts it.
  const fullTitle = instanceDisplayName(i);
  if (el.title !== fullTitle) el.title = fullTitle;
  setText(el, "[data-sub]", moduleName);

  // Top-left chip — game abbreviation (e.g. "7DTD", "ARKASA", "VRising").
  // The agent identity used to live here but it's redundant: the agents
  // drawer + filter pills already cover that, and operators care more
  // about "what game is this" at a glance than "which host runs it".
  const agEl = el.querySelector("[data-tile-agent]");
  if (agEl) {
    const abbr = _moduleAbbr(i.module_id);
    if (agEl.textContent !== abbr) agEl.textContent = abbr;
    agEl.classList.remove("offline"); // no longer an agent indicator
    agEl.title = `${moduleName} · ${i.agent_id || ""}`;
  }

  // Cluster badge — color-coded chip showing which cluster this server is in.
  // Only members of a 7DTD cluster show it; everything else stays hidden.
  const clEl = el.querySelector("[data-cluster-badge]");
  if (clEl) {
    const c = clusterMap[i.instance_id];
    if (c) {
      const color = clusterColor(c.id);
      const label = clusterLabel(c);
      clEl.style.setProperty("--cluster-color", color);
      clEl.classList.add("is-member");
      clEl.title = `Cluster: ${label}${c.master ? " (master)" : ""}`;
      const html = `<span class="cdot"></span>${c.master ? '<span class="cmaster">★</span>' : ""}<span class="clbl">${esc(label)}</span>`;
      if (clEl.dataset.cl !== c.id + "|" + (c.master ? "m" : "")) {
        clEl.innerHTML = html;
        clEl.dataset.cl = c.id + "|" + (c.master ? "m" : "");
      }
    } else if (clEl.classList.contains("is-member")) {
      clEl.classList.remove("is-member");
      clEl.removeAttribute("data-cl");
      clEl.innerHTML = "";
    }
  }

  // "Update available" badge — amber pill lit when the last manual check
  // (Updates tab → Check for updates) found installed != latest buildid.
  const updEl = el.querySelector("[data-update-badge]");
  if (updEl) {
    const uc = updateCheckMap[i.instance_id];
    const show = !!(uc && uc.update_available);
    updEl.classList.toggle("is-stale", show);
    if (show) updEl.title = `Installed build ${uc.installed_buildid} → latest ${uc.latest_buildid} (${uc.branch})`;
  }

  // Port — main game port (override > module default).
  const portTxt = primaryPortFor(i);
  const portEl = el.querySelector("[data-port]");
  const portSep = el.querySelector("[data-port-sep]");
  if (portEl) {
    if (portTxt) {
      portEl.textContent = portTxt;
      portEl.style.display = "";
      if (portSep) portSep.style.display = "";
    } else {
      portEl.style.display = "none";
      if (portSep) portSep.style.display = "none";
    }
  }

  // Status badge — precedence: deleting > installing/updating > ready
  // > starting > stopping > log-confirm > backend status.
  // Backend "updating" maps to either "installing" (first-time download
  // when the instance has never run) or "updating" (subsequent fetches);
  // the only difference is the label, both use the same pulsing yellow
  // pill style.
  const pill = el.querySelector("[data-pill]");
  let effectiveStatus = i.status;
  let pillLabel = null; // optional pill label override (e.g. cluster "setting up")
  const needLogConfirm = READY_LOG_ONLY.has(i.module_id) && i.status === "running" && !isConfirmedReady(i);
  if (instanceDeleting.has(i.instance_id)) effectiveStatus = "deleting";
  else if (instanceInstalling.has(i.instance_id)) effectiveStatus = "installing";
  else if (i.status === "updating") effectiveStatus = (i.detail && /install/i.test(i.detail)) ? "installing" : "updating";
  else if (instanceReady.has(i.instance_id)) effectiveStatus = "ready";
  else if (instanceStarting.has(i.instance_id) && (i.status === "running" || i.status === "stopped")) effectiveStatus = "starting";
  else if (instanceStopping.has(i.instance_id)) effectiveStatus = "stopping";
  else if (needLogConfirm) effectiveStatus = "starting";
  // 7DTD cluster auto-provision: a fresh world is still aligning to the cluster
  // canonical (the agent holds this detail until the .canon-provisioned marker
  // exists). Show an amber "setting up" pill instead of green "running" so a
  // refresh makes clear the server isn't ready yet — don't restart it.
  else if (i.status === "running" && i.detail && /finishing cluster setup|aligning decorations to (the )?cluster/i.test(i.detail)) { effectiveStatus = "starting"; pillLabel = "setting up"; }
  if (pillLabel === null) pillLabel = effectiveStatus;
  if (pill && pill.dataset.status !== pillLabel) {
    pill.className = "svr-tile-status " + effectiveStatus;
    pill.innerHTML = `<span class="dot"></span>${esc(pillLabel)}`;
    pill.dataset.status = pillLabel;
  }

  // CPU + RAM bars
  const fcpu = el.querySelector("[data-fill-cpu]");
  if (fcpu) { fcpu.style.width = cpuPct + "%"; fcpu.style.background = barColor(cpuPct); }
  setText(el, "[data-val-cpu]", stats.cpu !== undefined ? cpuF.text : "—");
  const cpuValEl = el.querySelector("[data-val-cpu]");
  if (cpuValEl) cpuValEl.title = stats.cpu !== undefined ? cpuF.title : "";
  const fram = el.querySelector("[data-fill-ram]");
  if (fram) { fram.style.width = memPct + "%"; fram.style.background = barColor(memPct); }
  setText(el, "[data-val-ram]", stats.memMB !== undefined ? (stats.memMB >= 1024 ? (stats.memMB / 1024).toFixed(1) + "G" : stats.memMB.toFixed(0) + "M") : "—");

  // Players
  const hp = el.querySelector("[data-hero-players]");
  if (hp) {
    const online = (appSt.players_online !== undefined) ? appSt.players_online : null;
    const max    = (appSt.players_max    !== undefined) ? appSt.players_max    : null;
    if (online === null || max === null) {
      hp.textContent = "—";
      hp.classList.add("empty");
    } else {
      hp.textContent = `${online} / ${max}`;
      hp.classList.remove("empty"); // show even when 0/N — the count is informative
    }
  }
  // Hide the players-sep "·" if players is empty so the meta line never
  // shows "module · :7777 · · " with a stranded dot. (Port-sep is
  // already toggled inline above where portTxt is resolved.)
  const playersShown = hp && !hp.classList.contains("empty");
  const playersSep = el.querySelector("[data-players-sep]");
  if (playersSep) playersSep.style.display = playersShown ? "" : "none";

  // Uptime — computed from row.updated_at when running.
  const up = el.querySelector("[data-uptime]");
  if (up) {
    if (isRunning && i.updated_at) {
      const updatedAt = Date.parse(i.updated_at);
      if (!isNaN(updatedAt)) {
        up.textContent = _fmtCompactDuration(Date.now() - updatedAt);
        up.classList.remove("empty");
      } else { up.textContent = "—"; up.classList.add("empty"); }
    } else { up.textContent = "—"; up.classList.add("empty"); }
  }

  // Primary action — Start (▶) when stopped, Stop (■) when running.
  // Inline SVG so the glyph renders identically on every OS instead of
  // depending on emoji-font availability. We swap only the <svg> (via
  // outerHTML) + the label span, never the whole button, so the
  // `svr-act-primary` class and the data-* hooks survive the mode flip.
  // Include fill="currentColor" inline as a belt-and-braces guard
  // so the SVG always picks up the button's text color even if a class
  // ever drops.
  const pbtn = el.querySelector("[data-primary-btn]");
  if (pbtn) {
    const setLabel = (txt) => { const s = pbtn.querySelector("[data-primary-label]"); if (s && s.textContent !== txt) s.textContent = txt; };
    if (isRunning) {
      if (pbtn.dataset.mode !== "stop") {
        pbtn.className = "svr-act-primary act-stop";
        pbtn.querySelector("svg").outerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1.5"/></svg>`;
        setLabel("Stop");
        pbtn.title = "Stop";
        pbtn.dataset.stop = i.instance_id; delete pbtn.dataset.start;
        pbtn.dataset.mode = "stop";
      }
    } else {
      if (pbtn.dataset.mode !== "start") {
        pbtn.className = "svr-act-primary act-start";
        pbtn.querySelector("svg").outerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.74-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z"/></svg>`;
        setLabel("Start");
        pbtn.title = "Start";
        pbtn.dataset.start = i.instance_id; delete pbtn.dataset.stop;
        pbtn.dataset.mode = "start";
      }
    }
    pbtn.disabled = false;
  }
  // Restart only when running.
  const rbtn = el.querySelector("[data-restart-btn]");
  if (rbtn) {
    rbtn.style.display = isRunning ? "" : "none";
    if (isRunning) rbtn.dataset.restart = i.instance_id; else delete rbtn.dataset.restart;
    rbtn.disabled = false;
  }
  // Force-kill button: shown whenever the engine is in any non-stopped
  // state — including "crashed", "starting", "updating", "running". This
  // is intentionally MORE permissive than the Stop button, because the
  // exact case this button exists for is "engine reports crashed/dead but
  // the container's still hogging RAM" (acekorneya watchdog wedged). It
  // hits docker stop with grace=0 → near-instant SIGKILL.
  const fbtn = el.querySelector("[data-force-kill-btn]");
  if (fbtn) {
    const showForce = i.status !== "stopped";
    fbtn.style.display = showForce ? "" : "none";
    if (showForce) fbtn.dataset.forceKill = i.instance_id; else delete fbtn.dataset.forceKill;
    fbtn.disabled = false;
  }
  const delBtn = el.querySelector("[data-del]"); if (delBtn) delBtn.disabled = false;
}

function _fmtCompactDuration(ms) {
  if (ms < 60_000) return Math.floor(ms / 1000) + "s";
  if (ms < 3_600_000) return Math.floor(ms / 60_000) + "m";
  if (ms < 86_400_000) {
    const h = Math.floor(ms / 3_600_000);
    const m = Math.floor((ms % 3_600_000) / 60_000);
    return m === 0 ? h + "h" : h + "h " + m + "m";
  }
  const d = Math.floor(ms / 86_400_000);
  const h = Math.floor((ms % 86_400_000) / 3_600_000);
  return h === 0 ? d + "d" : d + "d " + h + "h";
}

function setText(root, sel, v) {
  const el = root.querySelector(sel);
  const next = v == null ? "" : String(v);
  if (el.textContent !== next) el.textContent = next;
}

function barColor(pct) {
  if (pct > 85) return "linear-gradient(90deg,#ff4f44,#f85149)";
  if (pct > 60) return "linear-gradient(90deg,#f4c13d,#e3b341)";
  return "linear-gradient(90deg,#3fb950,#4fcf63)";
}

// Called from SSE when per-instance stats/app_state arrive — keeps the bars
// breathing between polling ticks without touching the whole card's DOM.
function updateCardInPlace(instanceID) {
  // The grid lives at #svr-grid in the new layout; older code referenced
  // #cards-wrap. Try the document-level lookup so we don't depend on
  // either container id.
  const el = document.querySelector(`[data-card-id="${CSS.escape(instanceID)}"]`);
  const i = allInstances.find(x => x.instance_id === instanceID);
  if (el && i) updateCard(el, i);
}

function syncInstancePickers() {
  const ids = allInstances.map(i => i.instance_id);
  syncPicker("#s-instance", ids, "— pick a server —");
}
function syncAgentPicker() {
  syncPicker("#f-agent", allAgents.map(a => a.agent_id), "— pick an agent —");
}
function syncModulePicker() {
  // Hidden input keeps the rest of the form logic unchanged; the visible
  // UI is a grid of clickable tiles with game art. Name only — no version
  // suffix, tiles are for humans picking a game, not inspecting releases.
  //
  // `demo` and `steamcmd-test` are dev-only fixtures — hide from the
  // picker so end users only see real games.
  const HIDDEN_FROM_PICKER = new Set(["demo", "steamcmd-test"]);
  const hidden = $("#f-module");
  const grid = $("#f-module-grid");
  if (!grid) return;
  const prev = hidden.value;
  const visibleModules = allModules.filter(m => !HIDDEN_FROM_PICKER.has(m.id));
  const tiles = visibleModules.map(m => {
    const app = m.appearance || moduleAppearance[m.id]; // WI-07: manifest appearance wins
    const art = artURLFor(app);
    const bg = art ? `background-image:url(${art});` : '';
    const grad = (app && app.grad) ? `background-color: transparent; background-image: ${art ? `url(${art})` : app.grad};` : bg;
    const name = esc(m.name || m.id);
    return `<div class="module-tile" role="button" tabindex="0" data-module-id="${esc(m.id)}" style="${grad}" title="${name}">
      <div class="mt-check">✓</div>
      <div class="mt-name">${name}</div>
    </div>`;
  }).join("");
  grid.innerHTML = tiles || `<div class="dim" style="grid-column:1/-1">No modules available yet.</div>`;
  // Re-apply previous selection if the module is still there.
  if (prev && visibleModules.some(m => m.id === prev)) {
    const t = grid.querySelector(`.module-tile[data-module-id="${prev}"]`);
    if (t) t.classList.add("selected");
  } else {
    hidden.value = "";
  }
}
// Delegate tile click/keyboard selection to the grid (idempotent — bind once).
document.addEventListener("click", e => {
  const tile = e.target.closest("#f-module-grid .module-tile");
  if (!tile) return;
  const grid = tile.parentElement;
  grid.querySelectorAll(".module-tile.selected").forEach(t => t.classList.remove("selected"));
  tile.classList.add("selected");
  $("#f-module").value = tile.dataset.moduleId || "";
  renderModuleCreateOptions(tile.dataset.moduleId || "");
});

// MODULE_CREATE_OPTIONS: per-module fields shown on the new-server form
// AFTER the user picks a game. Populates a config_values map that the
// create POST forwards to the agent. Each entry is rendered into the
// `#f-extras` row.
//
// To extend: add a key (module id) → array of fields. Each field:
//   - prop: env var / config_values key
//   - label: human-readable label
//   - type: "select" | "string" | "number" | "bool"
//   - options (for select): [[value, label], …] OR plain ["v","v2"]
//   - default: initial value
//   - hint: small text under the input
const MODULE_CREATE_OPTIONS = {
  // Canonical ARK: Survival Ascended map list. Source: ARK wiki +
  // Wildcard's official map IDs. Community maps that don't ship with
  // the dedicated server (Ragnarok / Astraeos in their fork form) are
  // marked so operators know they need extra mod IDs after create.
  "ark-sa": [{
    prop: "MAP_NAME",
    label: "Map",
    type: "select",
    default: "TheIsland_WP",
    hint: "Vanilla maps need no mods. Community maps (★) require mod IDs in the Mods tab AFTER first boot.",
    options: [
      ["TheIsland_WP",       "The Island"],
      ["TheCenter_WP",       "The Center"],
      ["ScorchedEarth_WP",   "Scorched Earth"],
      ["Aberration_WP",      "Aberration"],
      ["Extinction_WP",      "Extinction"],
      ["LostColony_WP",      "Lost Colony"],
      ["Astraeos_WP",        "Astraeos ★"],
      ["Ragnarok_WP",        "Ragnarok ★"],
      ["Valguero_WP",        "Valguero ★"],
      ["BobsMissions_WP",    "Club ARK (BobsMissions)"],
    ],
  }],
  // 7DTD: bundled-mod install option. Single checkbox today (RefugeBot
  // is the only mod the panel ships embedded), but the create-form
  // already has the bool-checkbox machinery so adding more is just
  // another entry here. Mod install runs AFTER the agent has created
  // the container — frontend kicks the /install-mod endpoint once
  // status settles.
  "7dtd": [
    {
      // Game VERSION / Steam branch → module.yaml update_provider (agent's
      // pickProviderFromConfigValues reads config_values.provider_id). v2_6 is
      // the DEFAULT and a FROZEN branch (buildid 22422094) so a server can
      // never auto-drift onto the moving public branch when it flips to 3.0 on
      // 2026-06-29 — this is the version lock. Branch list confirmed live via
      // app_info_print 294420 on 2026-06-14. See module.yaml + memory plan doc.
      prop: "provider_id",
      label: "Game version",
      type: "select",
      default: "v2_6",
      hint: "Locked branches (v2.6, v2.5…) pin the server to a frozen build so a Steam update can never corrupt your world. 2.6 is current. Pick 3.0 Experimental only to test the new build on a throwaway world — it auto-updates and is NOT save-compatible with 2.6. 'Public (auto-updates)' is unlocked and WILL move to 3.0 on June 29.",
      options: [
        ["v2_6",                "2.6 Stable — current · 🔒 locked (recommended)"],
        ["latest_experimental", "3.0 Experimental — Dead Hot Summer · ⚠ beta, auto-updates"],
        ["current",             "Public / latest stable · ⚠ unlocked (moves to 3.0 on Jun 29)"],
        ["v2_5",                "2.5 Stable · 🔒 locked"],
        ["v2_4",                "2.4 Stable · 🔒 locked"],
        ["v2_3",                "2.3 Stable · 🔒 locked"],
        ["v2_0",                "2.0 Stable · 🔒 locked"],
        ["v1_4",                "1.4 Stable · 🔒 locked"],
      ],
    },
    {
      prop: "server_name",
      label: "Server name",
      type: "text",
      default: "Refuge 7DTD",
      hint: "Shown in the in-game server browser (serverconfig ServerName).",
    },
    {
      prop: "world_name",
      label: "World",
      type: "select",
      default: "RWG",
      hint: "RWG = randomly-generated world (uses the seed + map size below). Navezgane is the handcrafted map. PREGEN* are TFP's prebuilt random maps.",
      options: [
        ["RWG", "Random Gen (RWG)"],
        ["Navezgane", "Navezgane (handcrafted)"],
        ["PREGEN01", "PREGEN01"],
        ["PREGEN02", "PREGEN02"],
        ["PREGEN03", "PREGEN03"],
        ["PREGEN4k_01", "PREGEN4k_01"],
        ["PREGEN4k_02", "PREGEN4k_02"],
        ["PREGEN4k_03", "PREGEN4k_03"],
      ],
    },
    {
      prop: "world_seed",
      label: "World seed (RWG only)",
      type: "text",
      default: "",
      hint: "Any text — seeds the random world generation. Leave blank for the panel default. Ignored for Navezgane / PREGEN maps.",
    },
    {
      prop: "world_size",
      label: "Map size (RWG only)",
      type: "select",
      default: "8192",
      hint: "RWG world width/height. Bigger = more to explore but slower first-gen and more RAM. Ignored for Navezgane / PREGEN.",
      options: [
        ["6144", "6144 (small)"],
        ["8192", "8192 (default)"],
        ["10240", "10240 (large)"],
      ],
    },
    {
      prop: "_install_refugebot",
      label: "Install RefugeBot mod",
      type: "bool",
      default: false,
      hint: "Drops the bundled RefugeBot DLL + assets into Mods/RefugeBot/. Runs once after create. Loads on first server start.",
      cap: "refugebot", // only shown when the controller was built with -tags refugebot
    },
  ],
  // Conan Exiles: pick UE5 (Enhanced) or UE4 (Legacy beta branch) at
  // create time. Drives BOTH the initial Steam download (agent's
  // pickProviderFromConfigValues maps EDITION → matching update_provider)
  // AND the Mods tab's Workshop search filter (only Enhanced-tagged
  // mods load on UE5 servers; Legacy-tagged mods on UE4). See
  // modules/conan-exiles/module.yaml config_values block for the canonical
  // copy and panel/memory/changelog_2026-05-08.md for the rationale.
  "conan-exiles": [{
    prop: "EDITION",
    label: "Edition",
    type: "select",
    default: "enhanced",
    hint: "Enhanced (UE5) is Funcom's current build — pick this unless you specifically need the older UE4 mods. Switching after creation is supported but UE5→UE4 may not load Enhanced-era saves cleanly.",
    options: [
      ["enhanced", "Enhanced (UE5, default)"],
      ["legacy",   "Legacy (UE4 — Steam beta branch)"],
    ],
  }],
  // Minecraft + Valheim etc. ready when wanted:
  // "minecraft-java": [{prop:"DIFFICULTY", label:"Difficulty", type:"select", default:"normal", options:["peaceful","easy","normal","hard"]}],
};

async function renderModuleCreateOptions(moduleID) {
  const wrap = $("#f-extras");
  const labelEl = $("#f-extras-label");
  if (!wrap || !labelEl) return;
  // Start from the static options for this module, then layer dynamic
  // extras (like the ARK cluster dropdown that depends on the current
  // cluster list). Returning [] hides the section entirely.
  let fields = MODULE_CREATE_OPTIONS[moduleID] ? [...MODULE_CREATE_OPTIONS[moduleID]] : [];
  // Drop fields gated on a build-tag capability the controller wasn't
  // compiled with (GET /api/capabilities, loaded at boot into PANEL_CAPS).
  fields = fields.filter(f => !f.cap || (window.PANEL_CAPS || {})[f.cap]);
  // ARK cluster picker — only show if there's at least one cluster
  // already set up. The first ARK on a fresh agent has nothing to join.
  // Always fetch fresh: a cached value from the modal-open path can
  // be stale if the operator just set up a cluster on another server
  // and hit Add immediately. No-op if the API errors (network blip).
  if (moduleID === "ark-sa") {
    let clusters = [];
    try {
      const r = await fetchJSON("/api/ark/clusters");
      clusters = r.clusters || [];
    } catch {}
    window._arkClustersForCreate = clusters;
    if (clusters.length > 0) {
      fields.push({
        prop: "_ark_cluster_id",     // underscore prefix → stripped before going into config_values
        label: "Cluster",
        type: "select",
        default: "",
        hint: "Joining a cluster mounts its shared transfer dir at create time. Pick standalone if this server runs solo.",
        options: [["", "Standalone (no cluster)"]].concat(
          clusters.map(c => [c.id, `${c.name || c.id} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? "" : "s"}`])
        ),
      });
    }
  }
  // 7DTD cluster picker — only show if there's at least one cluster
  // already set up. The first 7DTD on a fresh agent has nothing to join.
  // Always fetch fresh: a cached value from the modal-open path can
  // be stale if the operator just set up a cluster on another server
  // and hit Add immediately. No-op if the API errors (network blip).
  if (moduleID === "7dtd") {
    let clusters = [];
    try {
      const r = await fetchJSON("/api/7dtd/clusters");
      clusters = r.clusters || [];
    } catch {}
    window._sdtdClustersForCreate = clusters;
    if (clusters.length > 0) {
      fields.push({
        prop: "_seven_dtd_cluster_id",   // underscore prefix → stripped before going into config_values
        label: "Cluster",
        type: "select",
        default: "",
        hint: "Join a 7DTD cluster to share player progression with its other servers. Standalone runs solo. A new cluster world auto-aligns its decorations to the cluster on its first start (a one-time auto-restart) — watch the Console for “aligning decorations to cluster…”.",
        options: [["", "Standalone (no cluster)"]].concat(
          clusters.map(c => [c.id, `${c.name || c.id} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? "" : "s"}`])
        ),
      });
      fields.push({
        prop: "_seven_dtd_seed_mods",   // underscore prefix → dedicated request field, not config_values
        label: "Seed mods from the cluster master",
        type: "bool",
        default: true,
        hint: "Only applies if you pick a cluster above: clones the master server's exact mod set onto this new server so it plays identically (your own teleport homes stay separate). Ignored for standalone.",
      });
    }
  }
  if (!fields.length) {
    wrap.innerHTML = "";
    wrap.style.display = "none";
    labelEl.style.display = "none";
    return;
  }
  labelEl.style.display = "";
  wrap.style.display = "";
  wrap.innerHTML = fields.map(f => {
    const id = "f-extra-" + f.prop;
    // Bool fields render as a clear native checkbox + label inline,
    // NOT wrapped in another label heading. Without the explicit
    // checkbox the field looked like a static description block —
    // operators reported they couldn't see how to opt in/out.
    if (f.type === "bool") {
      const checked = f.default ? " checked" : "";
      return `<div style="margin-bottom:14px;padding:10px 12px;border:1px solid var(--border-soft);border-radius:var(--radius);background:rgba(95,178,255,.04)">
        <label for="${id}" style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;user-select:none">
          <input type="checkbox" id="${id}" data-extra-prop="${esc(f.prop)}"${checked} style="margin-top:3px;width:18px;height:18px;cursor:pointer;flex:0 0 auto">
          <div style="flex:1;min-width:0">
            <div style="font-weight:600;font-size:13px">${esc(f.label)}</div>
            ${f.hint ? `<div class="form-hint" style="margin-top:3px">${f.hint}</div>` : ""}
          </div>
        </label>
      </div>`;
    }
    let ctrl = "";
    if (f.type === "select") {
      const opts = (f.options || []).map(o => {
        const [val, lab] = Array.isArray(o) ? o : [o, o];
        const sel = (val === f.default) ? " selected" : "";
        return `<option value="${esc(val)}"${sel}>${esc(lab)}</option>`;
      }).join("");
      ctrl = `<select id="${id}" data-extra-prop="${esc(f.prop)}" style="width:100%">${opts}</select>`;
    } else {
      ctrl = `<input id="${id}" data-extra-prop="${esc(f.prop)}" value="${esc(f.default || "")}" style="width:100%">`;
    }
    return `<div style="margin-bottom:10px">
      <label for="${id}" style="display:block;font-size:12.5px;font-weight:600;margin-bottom:4px">${esc(f.label)}</label>
      ${ctrl}
      ${f.hint ? `<div class="form-hint">${f.hint}</div>` : ""}
    </div>`;
  }).join("");
}

// Snapshot the current extras into a config_values map at submit time.
function readModuleCreateOptions() {
  const out = {};
  document.querySelectorAll("#f-extras [data-extra-prop]").forEach(el => {
    const key = el.dataset.extraProp;
    if (!key) return;
    let v;
    if (el.type === "checkbox") v = el.checked ? "true" : "false";
    else v = (el.value || "").trim();
    if (v !== "") out[key] = v;
  });
  return out;
}
document.addEventListener("keydown", e => {
  if (e.key !== "Enter" && e.key !== " ") return;
  const tile = e.target.closest && e.target.closest("#f-module-grid .module-tile");
  if (!tile) return;
  e.preventDefault();
  tile.click();
});
function syncPicker(sel, ids, placeholder) {
  const el = $(sel); if (!el) return;
  const prev = el.value;
  el.innerHTML = `<option value="">${placeholder}</option>` + ids.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join("");
  if (prev && ids.includes(prev)) el.value = prev;
  else if (ids.length === 1) el.value = ids[0];
}

// renderAgents() used to render a flat agent list below the servers grid.
// That section was collapsed into Agents-wrap-servers, so this is just an
// alias that re-runs the combined renderer.
function renderAgents() { renderAgentsAndServers(); }

// ================ schedules ================
function renderSchedules(schedules) {
  const wrap = $("#schedules-wrap");
  // Main-page Schedules section was removed; renderSchedules survives only
  // to avoid touching every call site. When its DOM target is absent, bail
  // out — the per-instance modal has its own renderer (loadInstanceSchedules).
  if (!wrap) return;
  const countEl = $("#sched-count");
  if (countEl) countEl.textContent = `${schedules.length} total`;
  if (!schedules.length) {
    wrap.innerHTML = `<div class="empty"><div class="big">${EMPTY_ICONS.clock}</div>No schedules<span class="hint">Click "Add schedule" to automate things (auto-backup, idle shutdown, crash alerts)</span></div>`;
    return;
  }
  wrap.innerHTML = `<table>
    <thead><tr><th>Server</th><th>When</th><th>Action</th><th>Last fired</th><th>On</th><th></th></tr></thead>
    <tbody>${schedules.map(s => {
      let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = s.action; }
      const when = s.trigger_kind === "event"
        ? humanizeEvent(s.event_spec) + (s.event_sustained_seconds ? ` for ${Math.round(s.event_sustained_seconds/60)}m` : "")
        : humanizeCron(s.cron_spec);
      const fired = s.last_fired_at ? fmtTime(s.last_fired_at) : "—";
      const statusDot = s.last_status === "ok"   ? '<span class="dot ok"></span>'
                      : s.last_status === "error" ? '<span class="dot err"></span>'
                      : '<span class="dot"></span>';
      return `<tr>
        <td><strong>${esc(s.instance_id)}</strong>${s.description ? `<br><span class="dim">${esc(s.description)}</span>` : ""}</td>
        <td>${esc(when)}</td>
        <td>${esc(humanizeAction(act))}</td>
        <td>${statusDot}<span class="dim">${esc(fired)}</span>${s.last_detail ? `<br><span class="dim" style="font-size:11px">${esc(String(s.last_detail).slice(0,60))}</span>` : ""}</td>
        <td><label style="display:inline-flex;align-items:center;gap:6px"><input type="checkbox" data-toggle="${esc(s.id)}" ${s.enabled ? "checked":""}></label></td>
        <td style="text-align:right"><button class="small" data-schedit="${esc(s.id)}" title="Edit cron / action">✏️ Edit</button> <button class="small danger" data-schdel="${esc(s.id)}">Remove</button></td>
      </tr>`;
    }).join("")}</tbody>
  </table>`;
}
function humanizeCron(spec) {
  const known = { "0 */15 * * * *":"Every 15 minutes","0 0 * * * *":"Every hour","0 0 */6 * * *":"Every 6 hours",
                  "0 0 */12 * * *":"Every 12 hours","0 0 3 * * *":"Daily at 3am","0 0 4 * * 0":"Weekly (Sun 4am)" };
  return known[spec] || "Cron: " + spec;
}
function humanizeEvent(cel) {
  if (!cel) return "—";
  if (/players_online == 0/.test(cel)) return "When no players";
  if (/instance_status == "crashed"/.test(cel)) return "When server crashes";
  if (/player_kind == "KIND_JOIN"/.test(cel)) return "When a player joins";
  if (/player_kind == "KIND_LEAVE"/.test(cel)) return "When a player leaves";
  if (/OutOfMemoryError/.test(cel)) return "When OOM in logs";
  return "Custom: " + cel.slice(0, 40);
}
function humanizeAction(a) {
  if (!a || !a.type) return "—";
  if (a.type === "rcon") return a.command === "save-all" ? "Save the world" : `RCON: ${a.command}`;
  if (a.type === "instance") {
    const base = `${a.op} server${a.grace_seconds ? ` (grace ${a.grace_seconds}s)` : ""}`;
    const preSave = a.pre_save_rcon ? ` (after ${esc(a.pre_save_rcon)} +${a.pre_save_delay_sec || 10}s)` : "";
    const warn = a.warn_lead_minutes ? ` · ⚠ warn ${a.warn_lead_minutes}m` : "";
    return (a.backup_before_start ? `${base}${preSave} + 💾 clean backup` : `${base}${preSave}`) + warn;
  }
  if (a.type === "backup") return `💾 Backup${a.description ? ` (${a.description})` : ""}`;
  if (a.type === "webhook") return `POST → ${(a.url || "").slice(0, 40)}…`;
  return a.type;
}

// ================ card actions ================
document.addEventListener("click", async e => {
  // Button actions win over card-open. Find the closest element with any data-*.
  const btn = e.target.closest("[data-start],[data-stop],[data-restart],[data-force-kill],[data-del],[data-open],[data-open-tab],[data-schdel],[data-schedit],[data-agent-edit],[data-agent-del],[data-agent-new-server],[data-card-backup],[data-card-more]");
  if (!btn) return;
  const ds = btn.dataset;
  // Prevent card-open from firing when an action button inside the card was clicked.
  if ((ds.start || ds.stop || ds.restart || ds.forceKill || ds.del || ds.cardBackup) && btn !== e.target && btn.matches("[data-open]") === false) {
    e.stopPropagation();
  }
  if (ds.start)     { _closeCardMenus(); return doInstanceAction(ds.start, "start", btn); }
  if (ds.stop)      { _closeCardMenus(); return doInstanceAction(ds.stop, "stop",  btn); }
  if (ds.forceKill) { _closeCardMenus(); return doForceKill(ds.forceKill, btn); }
  if (ds.restart)   { _closeCardMenus(); return doInstanceRestart(ds.restart, btn); }
  if (ds.del)     { _closeCardMenus(); return doDelete(ds.del, btn); }
  if (ds.cardBackup) { e.stopPropagation(); _closeCardMenus(); return openCardBackupModal(ds.cardBackup); }
  if (ds.openTab) { e.stopPropagation(); _closeCardMenus(); const [oid, otab] = ds.openTab.split("|"); return openInstanceModal(oid, otab || "console"); }
  if (ds.cardMore) {
    e.stopPropagation();
    const wrap = btn.closest(".svr-act-more-wrap");
    const menu = wrap && wrap.querySelector("[data-card-menu]");
    const willOpen = menu && menu.hasAttribute("hidden");
    _closeCardMenus();
    if (willOpen) {
      menu.removeAttribute("hidden");
      btn.setAttribute("aria-expanded", "true");
      // Flip the menu above the button if it would overflow the viewport bottom.
      const r = btn.getBoundingClientRect();
      menu.classList.toggle("drop-up", r.bottom + 160 > window.innerHeight);
    }
    return;
  }
  if (ds.open)  { e.stopPropagation(); _closeCardMenus(); return openInstanceModal(ds.open); }
  if (ds.agentNewServer) {
    e.stopPropagation();
    showModal("#new-server-modal");
    // Pre-select the agent in the create form.
    const sel = $("#f-agent"); if (sel) sel.value = ds.agentNewServer;
    return;
  }
  if (ds.schdel) {
    if (!confirm("Remove this schedule?")) return;
    try { await fetchJSON(`/api/schedules/${encodeURIComponent(ds.schdel)}`, { method: "DELETE" }); toast("Schedule removed", "ok"); pollSchedules(); if (instanceModalState.id) loadInstanceSchedules(); }
    catch (err) { toast("Remove failed: " + err.message, "err"); }
  }
  if (ds.schedit) {
    // Edit a schedule in place. Server-side scheduler unregisters + re-registers
    // the cron entry so a new spec takes effect immediately — no controller restart.
    try {
      const all = await fetchJSON("/api/schedules");
      const cur = (all.schedules || []).find(x => x.id === ds.schedit);
      if (!cur) { toast("Schedule not found", "err"); return; }
      const newCron = prompt(
        `Cron spec for "${cur.description || cur.id}":\n` +
        `(6-field cron with seconds — e.g. "0 0 */6 * * *" = every 6 h)`,
        cur.cron_spec || ""
      );
      if (newCron === null) return;
      const r = await fetch(`/api/schedules/${encodeURIComponent(ds.schedit)}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cron_spec: newCron })
      });
      if (!r.ok) throw new Error(await r.text());
      toast("Schedule updated — re-registered live", "ok");
      pollSchedules();
      if (instanceModalState && instanceModalState.id) loadInstanceSchedules();
    } catch (err) { toast("Edit failed: " + err.message, "err"); }
  }
  if (ds.agentEdit) openAgentEdit(ds.agentEdit);
  if (ds.agentDel) {
    if (!confirm(`Remove agent "${ds.agentDel}" from the panel? It'll re-register on next connect if still running.`)) return;
    try { await fetchJSON(`/api/agents/${encodeURIComponent(ds.agentDel)}`, { method: "DELETE" }); toast("Agent removed", "ok"); pollAgents(); }
    catch (err) { toast("Remove failed: " + err.message, "err"); }
  }
});
// Force-kill — SIGKILL the container almost immediately. For the case
// where the game engine has wedged but the supervisor inside the
// container is keeping it alive (acekorneya ARK watchdog respawning a
// runaway engine, RAM climbing past sane limits, panel's normal Stop
// politely waiting 30s for a graceful shutdown that's never coming).
// Goes through the existing /stop endpoint with grace=0 — the agent's
// runtime.Stop becomes "SIGTERM, then immediate SIGKILL".
async function doForceKill(id, btn) {
  if (!confirm(`Force kill ${id}?\n\nThis sends SIGKILL to the container almost immediately (grace=0). Use ONLY when the server is wedged or its RAM is runaway and Stop didn't take. Any unsaved game state since the last auto-save will be lost.`)) return;
  if (!_lifecycleAcquire(id, "force-kill")) return;
  markStopping(id);
  try {
    await runAction(btn, async () => {
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}/stop?grace=0`, { method: "POST" });
      pollInstances();
    }, { loadingLabel: "Force-killing", successLabel: "Killed", errorLabel: "Force kill failed" }).catch(() => {
      clearStopping(id);
    });
  } finally { _lifecycleRelease(id); }
}

// Per-instance lifecycle lock. runAction disables only the button that
// was clicked, but operators can fire multiple lifecycle commands for
// the same instance from different surfaces (card button, modal,
// keyboard, batch dialog) — without this gate, a Restart while a Stop
// is in flight queues a second stop+start on the agent, which doubles
// the wait. Deduping at the instance level keeps the agent from
// processing redundant lifecycle commands and gives the operator
// immediate feedback that the click was redundant.
const _lifecycleInFlight = new Set();
function _lifecycleAcquire(id, label) {
  if (_lifecycleInFlight.has(id)) {
    if (typeof toast === "function") toast(`${id}: a lifecycle command is already in flight — give it a few seconds`, "info");
    return false;
  }
  _lifecycleInFlight.add(id);
  return true;
}
function _lifecycleRelease(id) { _lifecycleInFlight.delete(id); }

async function doInstanceAction(id, action, btn) {
  if (!_lifecycleAcquire(id, action)) return;
  const labels = {
    start: { loading: "Starting",  success: "Started" },
    stop:  { loading: "Stopping",  success: "Stopped" },
  }[action] || { loading: action, success: "Done" };
  // Optimistic UI: flip the pill BEFORE the RPC fires. The previous
  // version only updated the pill when the agent's InstanceState event
  // landed, leaving the card in a stale "running" pill for 1-3s after
  // the click — operators read this as "did my click register?"
  if (action === "start") markStarting(id);
  else if (action === "stop") { clearStarting(id); markStopping(id); }
  try {
    await runAction(btn, async () => {
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}/${action}`, { method: "POST" });
      pollInstances();
    }, { loadingLabel: labels.loading, successLabel: labels.success, errorLabel: `${action[0].toUpperCase()+action.slice(1)} failed` }).catch(() => {
      // RPC failed — drop the optimistic overlay so the real status shows.
      if (action === "start") clearStarting(id);
      else if (action === "stop") clearStopping(id);
    });
  } finally { _lifecycleRelease(id); }
}

// ── Shared per-instance SSE hub ──────────────────────────────────────────
// One EventSource per instance id, shared by every consumer (start/stop/
// install overlays, the console pane, backup progress). Before this hub each
// consumer opened its OWN /api/events?instance= connection — up to five
// concurrent streams for a single instance, each separately parsing the same
// event payloads. Subscribers receive the already-parsed event object; the
// connection closes when the last subscriber unsubscribes. EventSource's
// built-in auto-reconnect is preserved (none of the old sites had custom
// onerror logic beyond a silent no-op).
const _instSSEHub = new Map(); // instance_id -> { es, subs: Set<fn> }
function sseSubscribeInstance(id, fn) {
  let hub = _instSSEHub.get(id);
  if (!hub) {
    hub = { es: new EventSource(`/api/events?instance=${encodeURIComponent(id)}`), subs: new Set() };
    hub.es.onmessage = e => {
      let ev;
      try { ev = JSON.parse(e.data); } catch { return; }
      for (const sub of hub.subs) { try { sub(ev); } catch {} }
    };
    _instSSEHub.set(id, hub);
  }
  hub.subs.add(fn);
  let done = false;
  return () => {
    if (done) return;
    done = true;
    hub.subs.delete(fn);
    if (hub.subs.size === 0 && _instSSEHub.get(id) === hub) {
      _instSSEHub.delete(id);
      try { hub.es.close(); } catch {}
    }
  };
}

// Same shape as markStarting/clearStarting but watches for the stop to
// settle. Without this, clicking Stop leaves the pill on "running" until
// the next 2s poll lands the new agent-side status.
function markStopping(id) {
  if (instanceStopping.has(id)) return;
  const armedAt = Date.now();
  const unsub = sseSubscribeInstance(id, ev => {
    try {
      if (!ev.instance_state) return;
      const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase();
      if (s === "stopped" || s === "crashed") {
        if (Date.now() - armedAt < 800) return; // ignore replay echo
        clearStopping(id);
      }
    } catch {}
  });
  // 90s grace — graceful stop is configurable but defaults to 30s. If
  // the agent doesn't ack within 90s the operator probably wants to know
  // something's wrong; let the pill drop and the actual status (still
  // running) reasserts on the next poll.
  const timer = setTimeout(() => clearStopping(id), 90000);
  instanceStopping.set(id, { unsub, timer });
  paintStartingUI();
}
function clearStopping(id) {
  const rec = instanceStopping.get(id);
  if (!rec) return;
  try { rec.unsub(); } catch {}
  clearTimeout(rec.timer);
  instanceStopping.delete(id);
  paintStartingUI();
  if (typeof renderServerCards === "function") renderServerCards();
}

// Client-side restart: stop → poll until stopped → start. No backend
// endpoint needed; just chains the two existing ones.
//
// Subtle: markStarting() sets up an SSE listener that auto-clears the
// "starting" overlay on any `instance_state=stopped` event — including the
// one the agent emits during OUR stop phase. If we armed it before calling
// Stop, the overlay would evaporate between Stop and Start and the card
// would snap to "running" instantly when Start landed (the bug the
// operator just observed). Instead we keep the yellow pulse visible via a
// lightweight class-only override during Stop, then call markStarting
// AFTER the stop has actually landed so its SSE listener only sees the
// events from the new process.
async function doInstanceRestart(id, btn) {
  if (!_lifecycleAcquire(id, "restart")) return;
  try {
  await runAction(btn, async () => {
    // Phase 1 — keep the yellow pulse on via CSS class only; no SSE yet.
    _paintRestartingCard(id, true);
    try {
      // Server-side restart: the controller awaits the agent's stop ack
      // (sent only after the container actually exited) before dispatching
      // Start. The old client-side stop→poll→start chain raced — the poll
      // could fire Start while the graceful stop was still draining, and
      // the stop then killed the freshly-started server. It also stranded
      // the server stopped if the page was refreshed mid-chain. The POST
      // blocks for the stop duration (seconds to ~grace) and returns once
      // Start is dispatched.
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}/restart?grace=30`, { method: "POST" });
      // Phase 2 — stop landed, start dispatched. Arm the proper starting
      // overlay; its SSE subscription only sees events from the new run.
      _paintRestartingCard(id, false);
      markStarting(id);
    } finally {
      // Defensive — if the POST threw before we got to markStarting,
      // we'd leave the pulse class stuck on the card. Make sure it's gone.
      if (!instanceStarting.has(id)) _paintRestartingCard(id, false);
    }
    pollInstances();
  }, { loadingLabel: "Restarting", successLabel: "Restarted", errorLabel: "Restart failed" }).catch(() => {});
  } finally { _lifecycleRelease(id); }
}

// Paint/unpaint the yellow pulse class on a specific card without going
// through the full Starting-overlay SSE machinery. Used during the Stop
// phase of Restart so the transient `instance_state=stopped` event the
// agent emits doesn't prematurely clear the overlay.
function _paintRestartingCard(id, on) {
  document.querySelectorAll(`.card[data-card-id="${CSS.escape(id)}"]`).forEach(card => {
    card.classList.toggle("is-starting", !!on);
    const pill = card.querySelector("[data-pill]");
    if (pill && on) {
      pill.className = "status-pill starting";
      pill.textContent = "restarting";
      pill.dataset.status = "restarting";
    }
  });
}

// ============ Starting state (client-side "starting" pill) ============
//
// Backend state machine is creating → stopped → running (docker-level).
// But "docker says running" ≠ "game server is joinable" — MC needs ~30s,
// 7DTD 1-2 min, ARK multiple minutes, Empyrion (Wine + Unity + world gen)
// 1-2 min. So we overlay a client-only "starting" pill from the moment
// the user clicks Start until we see a per-game "ready" signal:
//
//   • app_state events (agent emits these when RCON poll succeeds — best
//     signal for RCON-enabled modules).
//   • a module-specific "ready" log-line regex (the only path for Empyrion
//     since it has no RCON adapter yet).
//   • backend status flips to stopped/crashed (something went wrong).
//   • 15-minute failsafe in case a game never emits a recognisable signal.
const instanceStarting   = new Map();  // id → { sse, timer }
const instanceInstalling = new Map();  // id → { sse, timer } — create auto-kicks SteamCMD update; hold "installing" pill until "update complete"
const instanceReady      = new Map();  // id → timer        — short flash after install completes, before normal "stopped" pill takes over
const instanceStopping   = new Map();  // id → { sse, timer } — pill flips immediately on Stop click; cleared when agent confirms stopped/crashed
const instanceDeleting   = new Set();  // id                 — fade-out class on card while delete is in flight
// instanceLocallyDeleted: ids whose delete the user JUST initiated. The
// card was optimistically pulled from the dashboard, and pollInstances
// must NOT reintroduce them until the backend's delete actually
// completes (often 30-90s when a steamcmd sidecar needs cleanup).
// pollInstances filters these out; the create-server form refuses to
// reuse the id until it's gone. Cleared by the delete background
// watcher when the DB row truly disappears.
const instanceLocallyDeleted = new Set();
// Confirmed-joinable: ids whose READY_PATTERNS match has been seen this
// session. Source of truth for log-only modules where the backend's
// STATUS_RUNNING fires too early. Persisted to localStorage keyed by the
// instance's current updated_at — so a page refresh remembers what we've
// already seen, and a server restart (which bumps updated_at) properly
// invalidates the stored flag. Cleared on stop/crash/delete via
// resetReadyOnStop().
const _READY_STORAGE_KEY = "panel.ready.v1";
function _loadConfirmedReady() {
  try {
    const raw = sessionStorage.getItem(_READY_STORAGE_KEY) || localStorage.getItem(_READY_STORAGE_KEY);
    return raw ? JSON.parse(raw) : {};
  } catch { return {}; }
}
function _saveConfirmedReady(map) {
  try { localStorage.setItem(_READY_STORAGE_KEY, JSON.stringify(map)); } catch {}
}
// In-memory map: instance_id -> updated_at timestamp at the moment ready
// was confirmed. Hydrated from localStorage on script load.
const _instanceReadyMap = _loadConfirmedReady();
// Set-like API kept for the rest of the codebase.
const instanceConfirmedReady = {
  has(id) {
    if (!_instanceReadyMap[id]) return false;
    // If the instance's current updated_at differs from what we stored,
    // it's a different run — drop the stale entry.
    const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null;
    if (inst && inst.updated_at && inst.updated_at !== _instanceReadyMap[id]) {
      delete _instanceReadyMap[id];
      _saveConfirmedReady(_instanceReadyMap);
      return false;
    }
    return true;
  },
  add(id) {
    const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null;
    _instanceReadyMap[id] = (inst && inst.updated_at) || String(Date.now());
    _saveConfirmedReady(_instanceReadyMap);
  },
  delete(id) {
    delete _instanceReadyMap[id];
    _saveConfirmedReady(_instanceReadyMap);
  },
};
// True when this instance's pill should be allowed to read inst.status
// directly. For non-log-only modules, always. For log-only modules,
// only after we've seen the joinable log line OR the instance has been
// up for >5 minutes (boot lines are long gone from the live stream).
// 5 min covers ARK's 3-6 minute boot; sub-1-minute games already get
// confirmed via live SSE inside that window so the fallback only kicks
// in for "I just opened the dashboard, server has been up for ages".
function isConfirmedReady(i) {
  if (!i) return false;
  if (!READY_LOG_ONLY.has(i.module_id)) return true;
  if (instanceConfirmedReady.has(i.instance_id)) return true;
  if (i.status === "running" && i.updated_at) {
    const updatedAt = Date.parse(i.updated_at);
    if (!isNaN(updatedAt) && (Date.now() - updatedAt) > 5 * 60 * 1000) return true;
  }
  return false;
}
// On stop / crash / delete, drop the confirmed flag so the next start
// has to re-prove joinability via the log line.
function resetReadyOnStop(id) { instanceConfirmedReady.delete(id); }
// ---- WI-07: manifest-driven module metadata -----------------------------
// GET /api/modules now carries ready_pattern / appearance / ports /
// browseable_roots straight from each module.yaml (the controller's own
// ./modules copy — no agent round-trip). The helpers below read the API
// payload first (cached in allModules by pollModules) and fall back to
// the legacy inline maps for any module missing the new fields, so
// behavior is unchanged for modules that haven't been migrated.
function _modMeta(moduleID) {
  if (!moduleID) return null;
  return (allModules || []).find(m => m.id === moduleID) || null;
}
// Compile a manifest ready_pattern string. Two forms, matching how the
// values were moved out of READY_PATTERNS verbatim:
//   "/source/flags" — JS regex literal form, used when flags are needed
//   "source"        — bare regex source, no flags
const _readyReCache = new Map();
function readyPatternFor(moduleID) {
  const meta = _modMeta(moduleID);
  const s = meta && meta.ready_pattern;
  if (s) {
    let re = _readyReCache.get(s);
    if (re === undefined) {
      const m = /^\/([\s\S]+)\/([a-z]*)$/.exec(s);
      try { re = m ? new RegExp(m[1], m[2]) : new RegExp(s); }
      catch { re = null; } // malformed manifest regex → inline fallback
      _readyReCache.set(s, re);
    }
    if (re) return re;
  }
  return READY_PATTERNS[moduleID] || null;
}
// Legacy inline map — FALLBACK ONLY. The source of truth for every
// packaged module moved to its module.yaml `ready_pattern` (WI-07);
// entries here cover modules without a manifest dir (ark, cs2, tf2,
// gmod) and any manifest missing the field. Read via readyPatternFor().
const READY_PATTERNS = {
  "7dtd":           /StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i,
  "minecraft-java": /Done \([\d.]+s\)! For help/,
  // ARK: Survival Ascended — the actually-joinable log line is
  //   "Server has completed startup and is now advertising for join."
  // emitted ~20-60s after the earlier "Server: <name> has successfully
  // started!" marker. That earlier line is misleading: the server process
  // is up, but BattlEye / Steam auth / world streaming isn't done, and
  // RCON won't accept AUTH until this "advertising for join" line lands.
  // We dropped the "successfully started" fallback — it was flipping
  // cards to RUNNING minutes before the server was actually joinable.
  "ark-sa":         /Server has completed startup and is now advertising/i,
  "empyrion":       /Server started listening on port|Dedi is ready|Ready for connections/i,
  // Native-Linux bucket. Patterns taken from AMPTemplates' Console.AppReadyRegex
  // where available, plus boot-sequence tail lines when no explicit "ready"
  // string exists. Verify + tighten on first real boot.
  "valheim":        /Game server connected|Dedicated server started/i,
  "palworld":       /Running Palworld dedicated server|Setting breakpad minidump AppID/i,
  "factorio":       /Hosting game at IP ADDR|changing state from\(CreatingGame\) to\(InGame\)/i,
  "satisfactory":   /IpNetDriver listening on port \d+|Server startup time elapsed/i,
  "barotrauma":     /Server started|Listening to \w+ connections/i,
  "enshrouded":     /\[Session\] 'HostOnline' \(up\)!/i,
  "terraria":       /Server started|Listening on port \d+/i,
  "project-zomboid":/\*+ SERVER STARTED \*+/,
  "core-keeper":    /Started session with info/i,  // verified live 2026-07-14
  "soulmask":       /Starting up WSServer|Network connection saved/i,
  // V Rising: verified from a live server log on 2026-04-29. The actual
  // ready line is "[Server] Startup Completed - Disabling Scene Loading
  // Systems" — fires after world streaming + persistence loading finish
  // and the server starts accepting clients. Earlier markers (chunk
  // streams, PersistenceV2 - Finished Loading) fire mid-boot.
  "v-rising":       /\[Server\]\s*Startup Completed/i,
  "sons-of-the-forest":/Dedicated server loaded|\[Self-Tests\] Please restart the server/i,
  // Windrose: UE5 server. Needs to fire when the world is joinable, NOT on
  // early boot markers. Two traps this regex explicitly dodges:
  //   1. `"InviteCode": "…"` is printed verbatim out of ServerDescription.json
  //      at tick [0] — before anything is loaded. Previous version matched
  //      this and flipped "ready" within 1s of click.
  //   2. `LogGlobalStatus: UEngine::LoadMap Load map complete /Game/Maps/Lobby/
  //      R5ServerLobby` fires at tick [0] too (the lobby map loads instantly
  //      on a warm start). We need the Genlandia / main-map complete line
  //      that fires after actual world streaming.
  // Preferred signal: "Host server is ready for owner to connect" — the
  // CoopProxy server's explicit ready-to-join log line, fires after world
  // init. Secondary: a Load-map-complete for any path EXCEPT /Lobby/.
  "windrose":       /Host server is ready for owner to connect|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete\s+\/Game\/Maps\/(?!Lobby)/i,
  // Conan Exiles: AMP's AppReadyRegex — fires when match state flips to
  // InProgress, meaning players can actually join.
  "conan-exiles":   /LogGameState: Match State Changed from WaitingToStart to InProgress/i,
  // Rust: Facepunch's well-known "Server startup complete" after world gen.
  "rust":           /Server startup complete|Listening on 0\.0\.0\.0:28015/i,
  // Dragonwilds: UE5 server, no canonical ready string documented. Match
  // broad UE5 startup markers + map-complete; tighten after first real boot.
  "dragonwilds":    /LogInit: Display: Engine is initialized|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete|LogLoad: Game Engine Initialized|LogNet: GameNetDriver/i,
  // DayZ: Bohemia's BattlEye-protected server. Common markers at end of
  // mission init + when the persistence DB finishes loading.
  "dayz":           /Mission read from bank|BattlEye Server: Initialized|Server World: Initialized|NetServer: (:\d+)?started|Creating a new world/i,
  // VEIN: the dedicated server has registered with Steam and is advertising.
  // Verified against the live 2026-06 build on Ivy: the canonical line is
  // "LogRamjetNetworking: Steamworks server initialized with SteamID <n>"
  // (AMP's template regex never fires on this build). Fallback: the
  // "Heartbeating with IP <ip>:<port>" line that confirms reachability.
  "vein":           /LogRamjetNetworking: Steamworks server initialized with SteamID \d+|LogRamjetNetworking: Heartbeating with IP /i,
};
// Modules where the agent's `app_state` event (= RCON poll succeeded)
// is NOT a sufficient ready signal — the RCON port can be open and
// answering queries minutes before the world is actually streamable
// or accepting joins. For these modules, only the log-line regex in
// READY_PATTERNS clears the "starting" overlay. ARK-SA is the
// canonical case: the acekorneya image's RCON binds during early
// boot (often <30s) but the "Server has completed startup and is
// now advertising for join." line lands ~3-6 minutes later. Without
// this gate the card flipped to RUNNING far too soon — every time
// the operator complained.
//
// To extend: add a module id here AND make sure READY_PATTERNS[modId]
// is set to a verified joinable-state log line.
const READY_LOG_ONLY = new Set([
  "ark-sa",
  "windrose",     // RCON-less; same flag applies
  "empyrion",     // RCON binds during world gen, well before joinable
  "conan-exiles", // match-state flip is the canonical signal
  "rust",         // server "online" but EAC handshake still pending
  // 7DTD: container reports running as soon as Unity boots, but the
  // game isn't joinable until 30-60s later when "StartGame finished"
  // hits stdout. Without this gate the card flipped to RUNNING the
  // instant docker said the process was up — operators tried to join
  // immediately and got refused. READY_PATTERNS["7dtd"] matches the
  // joinable line; the pill rides amber until that fires.
  "7dtd",
  // V Rising: BootStrap_Server logs "CharacterManager.StartServer" early
  // in boot, then a few seconds later RCON answers — but the world isn't
  // accepting clients until "Server started" hits stdout. Without this
  // gate the pill flipped to RUNNING within ~5s of clicking Start, and
  // operators connecting that quickly got "server not responding" timeouts.
  "v-rising",
]);
function markStarting(id) {
  if (instanceStarting.has(id)) return;
  const inst = allInstances.find(x => x.instance_id === id);
  const modId = inst && inst.module_id;
  const pattern = readyPatternFor(modId);
  const logOnly = READY_LOG_ONLY.has(modId);
  // Armed-at timestamp — the agent's StreamLogs opens with Tail: "400"
  // so a Start always replays the previous run's last ~5s of chatter,
  // which typically INCLUDES the ready-regex match from the earlier run
  // (e.g. Windrose's "LogGlobalStatus: Load map complete"). Without a
  // guard the replay would clear the overlay instantly on restart and
  // the card would snap to "running" before the server has actually
  // booted. We reject:
  //   • any log/app_state event whose `at` timestamp predates arming
  //   • any event received within the first 4s (safety net for events
  //     that arrive with no timestamp or with client/server clock skew)
  const armedAt = Date.now();
  const isStale = (ev) => {
    if (Date.now() - armedAt < 4000) return true;
    const t = ev && ev.log && ev.log.at;
    if (t) {
      const evAt = Date.parse(t);
      if (!isNaN(evAt) && evAt < armedAt) return true;
    }
    return false;
  };
  const unsub = sseSubscribeInstance(id, ev => {
    try {
      // app_state — RCON poll succeeded. For most modules this means
      // "joinable enough" (player count is reachable, server is up).
      // For READY_LOG_ONLY modules, RCON answering does NOT mean the
      // world is ready, so we ignore this and wait for the log line.
      if (ev.app_state && !isStale(ev) && !logOnly) { clearStarting(id); return; }
      if (ev.log && pattern && pattern.test(ev.log.line || "") && !isStale(ev)) {
        // Record confirmed-ready here too. This used to be the GLOBAL SSE
        // handler's job, but the dashboard stream now excludes log lines —
        // this per-instance stream is the only live scanner left. Without
        // the add, READY_LOG_ONLY cards would keep pulsing "starting" until
        // the 5-minute uptime fallback.
        if (logOnly) instanceConfirmedReady.add(id);
        clearStarting(id);
        return;
      }
      if (ev.instance_state) {
        const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase();
        // Don't clear on the intermediate "stopping" or early "stopped"
        // events the agent fires during the brief lifecycle gap of a
        // restart — only an actual crash or a settled stop should kill
        // the overlay. The 4s stale window above catches those too.
        if ((s === "stopped" || s === "crashed") && !isStale(ev)) clearStarting(id);
      }
    } catch {}
  });
  const timer = setTimeout(() => clearStarting(id), 15 * 60 * 1000);
  instanceStarting.set(id, { unsub, timer, armedAt, logOnly });
  paintStartingUI();
}
function clearStarting(id) {
  const rec = instanceStarting.get(id);
  if (!rec) return;
  try { rec.unsub(); } catch {}
  clearTimeout(rec.timer);
  instanceStarting.delete(id);
  paintStartingUI();
  // Full card repaint so the "running" pill can show up without waiting
  // for the next poll tick.
  if (typeof renderServerCards === "function") renderServerCards();
}
function paintStartingUI() {
  document.querySelectorAll(".card[data-card-id]").forEach(card => {
    const id = card.dataset.cardId;
    if (!id) return;
    const installing = instanceInstalling.has(id);
    const starting   = instanceStarting.has(id);
    const ready      = instanceReady.has(id);
    const stopping   = instanceStopping.has(id);
    const deleting   = instanceDeleting.has(id);
    // For log-only modules: even if the backend says "running", show
    // "starting" pulse until we've actually seen the joinable log
    // line (or the instance has been up for >10 min). Console is
    // the source of truth — ignore docker-running-says-RCON-up.
    const inst = allInstances.find(x => x.instance_id === id);
    const backendSaysRunning = inst && inst.status === "running";
    const needLogConfirm = inst && READY_LOG_ONLY.has(inst.module_id) && backendSaysRunning && !isConfirmedReady(inst);
    // Border pulse — gives clear feedback that the card is in a transient
    // state even if the user hasn't looked at the pill.
    card.classList.toggle("is-installing", installing);
    card.classList.toggle("is-starting", (starting || needLogConfirm) && !installing);
    // is-ready is a brief flash when the server first reports joinable — the
    // animation itself is ~1.6s and plays once, but we keep the class on for
    // the ready-timer window (~4s) so the green glow is visible before the
    // normal "running" pill takes over.
    card.classList.toggle("is-ready", ready && !installing && !starting);
    card.classList.toggle("is-stopping", stopping && !installing && !starting);
    card.classList.toggle("is-deleting", deleting);
    const pill = card.querySelector("[data-pill]");
    if (!pill) return;
    // Priority: deleting > installing > ready > starting > stopping >
    // log-confirm-pending. The last beats backend-supplied "running" so
    // a click feels immediate even before the agent's event lands AND
    // ARK's premature "running" doesn't lie to the operator.
    let effective = null;
    if (deleting) effective = "deleting";
    else if (installing) effective = "installing";
    else if (ready)  effective = "ready";
    else if (starting) effective = "starting";
    else if (stopping) effective = "stopping";
    else if (needLogConfirm) effective = "starting";
    if (effective && pill.dataset.status !== effective) {
      // Match updateCard's pill format: .svr-tile-status with a dot
      // <span> inside, NOT the old .status-pill class. Mismatched
      // class meant the pill was effectively unstyled after click —
      // why "starting" only showed up after a full page refresh.
      pill.className = "svr-tile-status " + effective;
      pill.innerHTML = `<span class="dot"></span>${esc(effective)}`;
      pill.dataset.status = effective;
    }
  });
  if (instanceModalState.id) {
    const id = instanceModalState.id;
    const pill = $("#im-status");
    const inst = allInstances.find(x => x.instance_id === id);
    const needLogConfirm = inst && READY_LOG_ONLY.has(inst.module_id) && inst.status === "running" && !isConfirmedReady(inst);
    let effective = null;
    if (instanceDeleting.has(id)) effective = "deleting";
    else if (instanceInstalling.has(id)) effective = "installing";
    else if (instanceReady.has(id)) effective = "ready";
    else if (instanceStarting.has(id)) effective = "starting";
    else if (instanceStopping.has(id)) effective = "stopping";
    else if (needLogConfirm) effective = "starting";
    if (pill && effective) { pill.className = "status-pill " + effective; pill.textContent = effective; }
    const sb = $("#im-start"); if (sb) sb.disabled = !!effective;
    // Install banner above the console — visible while installing.
    const banner = document.getElementById("im-c-install-banner");
    if (banner) {
      const isInstalling = instanceInstalling.has(id);
      banner.style.display = isInstalling ? "" : "none";
      if (isInstalling) {
        const sub = document.getElementById("im-c-install-step");
        if (sub && (!sub.textContent || sub.textContent === "Preparing…")) {
          // Initial copy until the first log line lands.
          const detail = (inst && inst.detail) || "";
          sub.textContent = detail && detail !== "installing" ? detail : "Setting up — first log line will appear here as soon as the agent starts the work…";
        }
      }
    }
    const modeEl = $("#im-c-mode");
    if (modeEl) {
      // The "im-c-mode" line under the console is the RCON-readiness
      // chip. Game-aware copy: log-only modules need a particular log
      // line to flip joinable; everything else is ready as soon as
      // RCON answers. The class triggers a yellow accent + soft pulse.
      const inst = allInstances.find(x => x.instance_id === id);
      const modId = inst && inst.module_id;
      const logOnly = READY_LOG_ONLY.has(modId);
      modeEl.classList.remove("rcon-warn");
      if (instanceInstalling.has(id)) {
        modeEl.textContent = "⏳ Installing — downloading game files. ARK / Empyrion / Conan can take 15-30 min on first boot.";
        modeEl.classList.add("rcon-warn");
      } else if (instanceStarting.has(id)) {
        if (inst && inst.detail && /finishing cluster setup|aligning decorations to (the )?cluster/i.test(inst.detail)) {
          // 7DTD cluster auto-provision: a fresh world is being remapped to the
          // cluster canonical (one-time auto-restart). Surface it so the operator
          // sees what's happening instead of a generic "starting".
          modeEl.textContent = "⏳ Finishing cluster setup — aligning this new world's decorations to the cluster. It restarts itself once and goes green when ready; don't restart it.";
        } else if (logOnly) {
          modeEl.textContent = "⏳ Starting — RCON not ready. Waiting for the joinable log line before flipping to running.";
        } else {
          modeEl.textContent = "⏳ Starting — RCON not ready. Waiting for the server to answer.";
        }
        modeEl.classList.add("rcon-warn");
      } else if (inst && inst.status === "running") {
        modeEl.textContent = "● Live server output — RCON ready.";
      } else if (inst && (inst.status === "stopped" || inst.status === "crashed")) {
        modeEl.textContent = "Server is stopped.";
      } else {
        modeEl.textContent = "Live server output";
      }
    }
  }
}

// Kicks off the "installing" overlay. Called right after Create succeeds
// (and by pollInstances when the agent emits detail=installing for operators
// who didn't click Create themselves).
//
// Clear triggers:
//   • sidecar updater log says "update complete" (panel-managed install)
//   • sidecar updater log says "update failed" / "steamcmd exited N"
//   • module's READY_PATTERNS regex matches the live log — this is what
//     ends the overlay for image-managed modules (ark-sa, etc.) where
//     the container's bundled entrypoint handles both download AND start
//     internally; docker-level status=running lands way before the game
//     actually binds, so we can only know "really done" from its log.
//   • instance_state transitions to crashed — install/start failed
//   • 30-min safety timer (large SteamCMD runs like ARK ~17GB take 20+ min).
function markInstalling(id, opts) {
  if (instanceInstalling.has(id)) return;
  const inst = allInstances.find(x => x.instance_id === id);
  const modId = inst && inst.module_id;
  const readyPattern = readyPatternFor(modId);
  const armedAt = Date.now();
  const autoStart = !!(opts && opts.autoStart);
  const unsub = sseSubscribeInstance(id, ev => {
    try {
      // Instance-state events (lifecycle changes from the agent)
      if (ev.instance_state) {
        const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase();
        if (s === "crashed") { clearInstalling(id, /*flashReady*/false); return; }
      }
      if (!ev.log) return;
      const line = ev.log.line || "";
      // Updater sidecar signals (panel-managed install path).
      if (/update complete/i.test(line)) { clearInstalling(id, /*flashReady*/true); return; }
      if (/update failed|steamcmd exited [1-9]|steamcmd: exhausted/i.test(line)) { clearInstalling(id, /*flashReady*/false); return; }
      // Module ready-regex (image-managed install path). Same stale-replay
      // guard as markStarting: the agent tails the last 400 log lines on
      // every stream open, so old "ready" matches would otherwise instantly
      // clear this overlay on an agent restart / reload.
      if (readyPattern && readyPattern.test(line)) {
        if (Date.now() - armedAt < 4000) return;
        const t = ev.log.at ? Date.parse(ev.log.at) : NaN;
        if (!isNaN(t) && t < armedAt) return;
        clearInstalling(id, /*flashReady*/true);
      }
    } catch {}
  });
  // Failsafe: 30 minutes. Large SteamCMD downloads (ARK/Empyrion ~17 GB)
  // can run 20+ min on slow links; cap at 30.
  const timer = setTimeout(() => clearInstalling(id, false), 30 * 60 * 1000);
  instanceInstalling.set(id, { unsub, timer, autoStart });
  paintStartingUI();
}
function clearInstalling(id, flashReady) {
  const rec = instanceInstalling.get(id);
  if (!rec) return;
  try { rec.unsub(); } catch {}
  clearTimeout(rec.timer);
  instanceInstalling.delete(id);
  if (flashReady) {
    const t = setTimeout(() => { instanceReady.delete(id); paintStartingUI(); if (typeof renderServerCards === "function") renderServerCards(); }, 4000);
    instanceReady.set(id, t);
    // Auto-start handoff: when create-flow armed `autoStart`, the
    // moment install completes (and the readyFlash starts) we kick a
    // start RPC. Image-managed modules (ARK acekorneya) often start
    // themselves from inside the entrypoint, so we tolerate the start
    // returning "already running" — backend will respond, we just don't
    // surface a redundant error.
    if (rec.autoStart) {
      // Wait until the agent's instance_state has actually settled to
      // "stopped" (= install completed and the container exited the
      // updater leg). Some modules go installing → stopped → start
      // already on their own; for those we skip our auto-start so we
      // don't double-start and trip "container already running" errors.
      setTimeout(async () => {
        try {
          const { instances } = await fetchJSON("/api/instances");
          const cur = instances.find(x => x.instance_id === id);
          if (!cur) return; // deleted in the meantime
          if (cur.status === "running" || cur.status === "starting") return;
          await fetchJSON(`/api/instances/${encodeURIComponent(id)}/start`, { method: "POST" });
          markStarting(id);
          pollInstances();
        } catch (err) {
          // Don't toast — the user can retry from the modal. The card
          // will reflect the actual state on the next poll.
        }
      }, 1500);
    }
  }
  paintStartingUI();
  if (typeof renderServerCards === "function") renderServerCards();
}
// doDelete shows a rich confirmation modal that ALWAYS reads the current
// instance from allInstances (via id) — so even if the button's data
// attribute somehow drifted, the modal shows the truth (name, module,
// agent). The type-to-confirm gate prevents muscle-memory mis-deletes.
const _delState = { id: null, btn: null };
// ================ card-level backup modal ================
//
// Opened from the 💾 button on each card. Shows a focused list of
// recent backups + "Back up now" + restore actions without the user
// having to open the full instance modal and switch to the Backups
// tab. The full tab still exists for power users.
const _cardBackupState = { id: null };
async function openCardBackupModal(id) {
  const inst = allInstances.find(x => x.instance_id === id);
  if (!inst) { toast("Instance not found", "err"); return; }
  _cardBackupState.id = id;
  $("#cb-instance").textContent = id;
  // Per-game backup notes — pulled from the manifest later. For now
  // hard-coded copy that mirrors what's in the module manifest's
  // `backup.notes` field. TODO: expose `backup.notes` via /api/modules.
  const notesByMod = {
    "ark-sa": "Saves SavedArks/ + Config/ + Logs/ (~250 MB typical). Game install + Proton runtime are preserved by SteamCMD on next start.",
    "minecraft-java": "world + plugins + mods + configs (~50-500 MB typical). Server jar + libraries are fetched fresh on next start.",
  };
  $("#cb-notes").textContent = notesByMod[inst.module_id] || "Whole-instance backup. The agent tarballs the entire data directory — could be large depending on the game.";
  await refreshCardBackupList();
  showModal("#card-backup-modal");
}
async function refreshCardBackupList() {
  const id = _cardBackupState.id;
  const wrap = $("#cb-list");
  if (!id || !wrap) return;
  wrap.innerHTML = loadingState("Loading…");
  try {
    const { backups } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`);
    $("#cb-count").textContent = backups && backups.length ? `${backups.length} backup${backups.length === 1 ? "" : "s"}` : "";
    if (!backups || !backups.length) {
      wrap.innerHTML = `<div class="empty" style="padding:24px"><div class="big" style="opacity:.4">${EMPTY_ICONS.disk}</div>No backups yet<span class="hint">Click "Back up now" to create one</span></div>`;
      return;
    }
    wrap.innerHTML = backups.map(b => `
      <div class="cb-row" data-bkp="${esc(b.id)}" style="display:flex;align-items:center;gap:10px;padding:10px 6px;border-bottom:1px solid var(--border-soft)">
        <div style="flex:1;min-width:0">
          <div style="font-weight:500;font-size:13px">${esc(fmtTime(b.created_at))}</div>
          <div class="dim" style="font-size:11px;font-family:var(--mono);margin-top:2px">${esc(b.id)} · ${esc(fmtBytes(b.size_bytes))}</div>
          ${b.description ? `<div class="dim" style="font-size:11.5px;margin-top:2px">${esc(b.description)}</div>` : ""}
        </div>
        <button class="small primary" data-cb-restore="${esc(b.id)}" title="Stop server first; this overwrites save data">↩ Restore</button>
        <button class="small danger" data-cb-del="${esc(b.id)}" title="Remove from list (tarball stays on disk)">×</button>
      </div>`).join("");
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load backups: ${err.message}`);
  }
}
document.addEventListener("DOMContentLoaded", () => {
  const cbNow = $("#cb-now");
  const cbRefresh = $("#cb-refresh");
  const cbList = $("#cb-list");
  if (!cbNow) return;
  cbNow.addEventListener("click", async () => {
    const id = _cardBackupState.id;
    if (!id) return;
    cbNow.disabled = true;
    cbNow.textContent = "Backing up…";
    toast(`Backing up ${id}…`, "ok");
    try {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ description: "" }),
      });
      toast(`Backup complete — ${fmtBytes(r.size_bytes)}`, "ok");
      await refreshCardBackupList();
    } catch (err) {
      toast(`Backup failed: ${err.message}`, "err");
    } finally {
      cbNow.disabled = false;
      cbNow.textContent = "⏺ Backup now";
    }
  });
  if (cbRefresh) cbRefresh.addEventListener("click", () => refreshCardBackupList());
  if (cbList) cbList.addEventListener("click", async e => {
    const id = _cardBackupState.id;
    if (!id) return;
    const restoreID = e.target.dataset.cbRestore;
    const delID = e.target.dataset.cbDel;
    if (restoreID) {
      if (!confirm(`Restore "${id}" from backup ${restoreID}?\n\nThe server will be stopped (if running) and its save data will be overwritten with the backup. Game install files are preserved.`)) return;
      e.target.disabled = true;
      toast(`Restoring ${id}…`, "ok");
      try {
        const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(restoreID)}/restore`, { method: "POST" });
        toast(`Restored ${fmtBytes(r.bytes_restored)}`, "ok");
        await refreshCardBackupList();
      } catch (err) {
        toast(`Restore failed: ${err.message}`, "err");
      } finally {
        e.target.disabled = false;
      }
    }
    if (delID) {
      if (!confirm("Remove this backup from the list?\nThe tarball file stays on disk; only the panel record is deleted.")) return;
      try {
        await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(delID)}`, { method: "DELETE" });
        await refreshCardBackupList();
      } catch (err) {
        toast(`Remove failed: ${err.message}`, "err");
      }
    }
  });
});

async function doDelete(id, btn) {
  // Look up live instance from the canonical array. If it isn't there
  // (race with poll), abort with a toast — never let the user confirm a
  // delete against a missing record.
  const inst = allInstances.find(x => x.instance_id === id);
  if (!inst) {
    toast("Instance not found in list — refresh and try again.", "err");
    return;
  }
  _delState.id = id;
  _delState.btn = btn || null;
  // Tile preview (re-uses the same appearance map as cards).
  const app = appearanceFor(inst.module_id) || {};
  const tile = $("#del-tile");
  if (tile) {
    tile.style.background = app.grad || "var(--card-bg-2)";
    const url = artURLFor(app);
    tile.style.backgroundSize = "cover";
    tile.style.backgroundPosition = "center";
    if (url) {
      tile.style.backgroundImage = `${app.grad ? app.grad + ", " : ""}url("${url}")`;
      tile.textContent = "";
    } else {
      tile.style.backgroundImage = app.grad || "";
      tile.textContent = app.emoji || "🎮";
    }
  }
  // Module display name from allModules if available, else fallback to id.
  const mod = allModules.find(m => m.id === inst.module_id);
  const moduleLabel = (mod && mod.name) || inst.module_id;
  $("#del-name").textContent = id;
  $("#del-meta").innerHTML =
    `<span class="mono" style="color:var(--accent)">${esc(inst.module_id)}</span> · ${esc(moduleLabel)} · agent <span class="mono">${esc(inst.agent_id)}</span>`;
  $("#del-id-hint").textContent = id;
  const conf = $("#del-confirm");
  conf.value = "";
  conf.classList.remove("ok");
  // Reset the button's label too — without this, opening the modal AFTER
  // a previous confirm leaves "Deleting…" text on the button (which is
  // a shared DOM element across modal opens). User reported this exact
  // symptom: opened a fresh server's delete modal and saw "Deleting…"
  // before they'd confirmed.
  const goBtn = $("#del-go");
  goBtn.disabled = true;
  goBtn.textContent = "Delete this server";
  $("#del-purge").checked = true;
  showModal("#delete-instance-modal");
  setTimeout(() => conf.focus(), 80);
}
// Wire confirm field + go button once on page load. The handler reads
// from _delState which is set fresh every doDelete() call.
document.addEventListener("DOMContentLoaded", () => {
  const conf = $("#del-confirm");
  const go = $("#del-go");
  if (!conf || !go) return; // older builds — defensive
  conf.addEventListener("input", () => {
    const ok = conf.value === _delState.id;
    go.disabled = !ok;
    conf.classList.toggle("ok", ok);
  });
  conf.addEventListener("keydown", e => {
    if (e.key === "Enter" && !go.disabled) { e.preventDefault(); go.click(); }
  });
  go.addEventListener("click", async () => {
    const id = _delState.id;
    if (!id || conf.value !== id) return;
    const purge = !!$("#del-purge").checked;
    go.disabled = true;
    go.textContent = "Deleting…";
    instanceDeleting.add(id);
    paintStartingUI();
    // Auto-promote orphans to force-delete: if this instance's agent
    // isn't in allAgents, the normal delete would 404 with "agent_offline".
    // We already showed the user the orphaned pane explaining the agent
    // is gone, so the second confirmation isn't needed — go straight to
    // ?force=true to scrub the DB row.
    const inst = allInstances.find(x => x.instance_id === id);
    const isOrphan = inst && !allAgents.some(a => a.agent_id === inst.agent_id);
    const qs = isOrphan ? `?force=true&purge=${purge}` : `?purge=${purge}`;
    try {
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}${qs}`, { method: "DELETE" });
      toast(`Deleting ${id}…`, "ok");
      hideModal("#delete-instance-modal");
      go.disabled = false;
      go.textContent = "Delete this server";
      // Optimistic remove: drop the row from allInstances NOW so the
      // card visually disappears within a second instead of sitting
      // on "Deleting…" while the backend's stop+rm+volume-rm chain
      // runs (30-90s for a SteamCMD-mid-download delete). The id
      // joins instanceLocallyDeleted — the next pollInstances filters
      // it out so the row can't pop back, and the create-server form
      // refuses to reuse it until the backend confirms it's gone.
      instanceLocallyDeleted.add(id);
      const idx = allInstances.findIndex(x => x.instance_id === id);
      if (idx >= 0) {
        allInstances.splice(idx, 1);
        instanceDeleting.delete(id);
        instanceStarting.delete(id);
        instanceStopping.delete(id);
        instanceInstalling.delete(id);
        renderServerCards();
        refreshHeroStats();
      }
      // Background watcher: confirms the DB row really does disappear.
      (async () => {
        const deadline = Date.now() + 120000;
        let confirmed = false;
        while (Date.now() < deadline) {
          await new Promise(r => setTimeout(r, 2500));
          try {
            const { instances } = await fetchJSON("/api/instances");
            if (!instances.find(x => x.instance_id === id)) { confirmed = true; break; }
          } catch {}
        }
        instanceLocallyDeleted.delete(id);  // cleared either way
        if (confirmed) toast(`Deleted ${id}`, "ok");
        else toast(`${id} delete still in progress on the agent — recreate blocked until it finishes`, "err", 9000);
        pollInstances();
      })();
    } catch (err) {
      toast(`Delete failed: ${err.message}`, "err");
      instanceDeleting.delete(id);
      paintStartingUI();
      go.disabled = false;
      go.textContent = "Delete this server";
    }
  });
});

// ================ create server ================
const INSTANCE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/;
function slugifyInstanceID(s) {
  // Replace anything that isn't a valid char with "-", collapse runs of "-",
  // strip a leading non-alphanumeric, and truncate to 48 chars.
  return s.trim().toLowerCase()
    .replace(/[^a-z0-9_.\-]+/g, "-")
    .replace(/-{2,}/g, "-")
    .replace(/^[^a-z0-9]+/, "")
    .slice(0, 48);
}
$("#new-server").addEventListener("click", async () => {
  // Reset the form fields each time so it feels fresh.
  $("#f-display").value = "";
  $("#f-instance").value = "";
  $("#f-id-preview").textContent = "—";
  $("#f-id-edit-row").style.display = "none";
  // Refresh ARK cluster list so the dropdown shows current state — and
  // appears at all. Cached on window so successive opens don't re-fetch.
  try {
    const r = await fetchJSON("/api/ark/clusters");
    window._arkClustersForCreate = r.clusters || [];
  } catch { window._arkClustersForCreate = []; }
  try {
    const r = await fetchJSON("/api/7dtd/clusters");
    window._sdtdClustersForCreate = r.clusters || [];
  } catch { window._sdtdClustersForCreate = []; }
  // Re-render extras for whatever module is currently selected (if any).
  // Default state with no module = no extras.
  renderModuleCreateOptions($("#f-module").value || "");
  showModal("#new-server-modal");
  setTimeout(() => $("#f-display").focus(), 80);
});

// The friendly "Display name" field — user types naturally (spaces,
// caps, whatever), we auto-mirror a valid server ID into the preview +
// the hidden #f-instance field. They can click "edit manually" to
// override the id if they really want something specific.
const manualIDState = { overridden: false };
$("#f-display").addEventListener("input", e => {
  if (manualIDState.overridden) return;
  const slug = slugifyInstanceID(e.target.value);
  $("#f-instance").value = slug;
  const prev = $("#f-id-preview");
  prev.textContent = slug || "—";
  prev.style.color = slug ? "" : "var(--muted)";
});
$("#f-edit-id").addEventListener("click", () => {
  manualIDState.overridden = true;
  $("#f-id-edit-row").style.display = "";
  $("#f-edit-id").textContent = "auto from name";
  $("#f-edit-id").onclick = () => {
    manualIDState.overridden = false;
    $("#f-id-edit-row").style.display = "none";
    $("#f-edit-id").textContent = "edit manually";
    // Re-mirror from display name.
    $("#f-display").dispatchEvent(new Event("input"));
  };
  setTimeout(() => $("#f-instance").focus(), 50);
});
$("#f-instance").addEventListener("input", e => {
  // When in manual-override mode, still slug the manual input so the
  // preview is always what gets sent.
  const slug = slugifyInstanceID(e.target.value);
  $("#f-id-preview").textContent = slug || "—";
});
$("#f-instance").addEventListener("blur", e => {
  e.target.value = slugifyInstanceID(e.target.value);
  $("#f-id-preview").textContent = e.target.value || "—";
});

// Game ids whose first update_provider requires_steam_login. The
// canonical source is the module manifest (`update_providers[0].requires_steam_login`),
// but we mirror it here for the create-flow toast — checking the
// manifest server-side would add a round-trip and the list is small
// + slow-changing. Defined BEFORE the create handler so the `const`
// is in scope when the handler executes.
const STEAM_LOGIN_REQUIRED = new Set(["dayz"]);

$("#create-btn").addEventListener("click", async (ev) => {
  const btn = ev.currentTarget;
  const agent = $("#f-agent").value, module = $("#f-module").value;
  let inst = slugifyInstanceID($("#f-instance").value || $("#f-display").value);
  $("#f-instance").value = inst;
  $("#f-id-preview").textContent = inst || "—";
  if (!agent) { toast("Pick an agent first", "err"); return; }
  if (!module) { toast("Pick a game", "err"); return; }
  if (!inst) { toast("Give the server a name", "err"); $("#f-display").focus(); return; }
  if (!INSTANCE_ID_RE.test(inst)) { toast("Server ID has invalid characters", "err"); return; }
  // Block reuse of an id whose delete the operator just kicked off but
  // the agent hasn't finished tearing down yet. The container/volumes
  // still exist — recreating now would race and crash. Tell the user
  // to wait or pick a different name.
  if (instanceLocallyDeleted.has(inst)) {
    toast(`"${inst}" is still being deleted — pick a different name or wait ~30s`, "err", 8000);
    return;
  }
  // Defensive: if a previous per-instance modal is somehow still open
  // (user left it underneath the new-server-modal), force-close it
  // BEFORE the create. Without this, if our auto-open later returns
  // early (e.g. allInstances hasn't refreshed yet), the user is left
  // staring at the OLD modal's console and reasonably assumes the
  // create dropped them into the wrong instance.
  if (instanceModalState.id) {
    try { hideModal("#instance-modal"); } catch {}
  }

  // Snapshot module-specific create options (e.g. ARK map). Map values
  // become initial config_values that the controller persists + forwards
  // to the agent. Empty object on modules with no extras — the field
  // omits via omitempty on the backend.
  const extraConfig = readModuleCreateOptions();
  // Underscore-prefixed keys are sentinel fields the controller knows
  // about as DEDICATED request fields, not as config_values. Strip
  // them from the env-bound map and shape the payload accordingly.
  const arkClusterID = extraConfig["_ark_cluster_id"] || "";
  delete extraConfig["_ark_cluster_id"];
  const sdtdClusterID = extraConfig["_seven_dtd_cluster_id"] || "";
  delete extraConfig["_seven_dtd_cluster_id"];
  const sdtdSeedMods = (extraConfig["_seven_dtd_seed_mods"] === "true");
  delete extraConfig["_seven_dtd_seed_mods"];
  const installRefugebot = (extraConfig["_install_refugebot"] === "true");
  delete extraConfig["_install_refugebot"];

  await runAction(btn, async () => {
    await fetchJSON("/api/instances", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({
        agent_id: agent,
        instance_id: inst,
        module_id: module,
        config_values: extraConfig,
        ark_cluster_id: arkClusterID,
        seven_dtd_cluster_id: sdtdClusterID,
        seven_dtd_seed_mods: sdtdSeedMods,
      }),
    });
    // For Steam-required modules, the agent's auto-update is skipped
    // (no creds yet) and auto-start is suppressed too. There's no real
    // install in flight, so showing the "installing" pulse is a lie.
    // Instead jump straight to opening the modal with the Steam banner.
    // For all other modules, kick the installing pill + chain auto-start
    // so the create flow lands on a running server in one gesture.
    if (!STEAM_LOGIN_REQUIRED.has(module)) {
      // 7DTD is created stopped (auto_start_on_create=false) so the operator can
      // finish world / cluster setup before booting it — don't chain a frontend
      // auto-start for it (the agent suppresses its own auto-start too).
      markInstalling(inst, { autoStart: module !== "7dtd" });
    }
    // Await pollInstances so allInstances actually has the new row
    // before we try to open it. Without the await, the auto-open ran
    // at +800ms while pollInstances was still in flight, openInstanceModal
    // returned early because the instance wasn't in the array, AND the
    // previous instance modal sometimes flickered with stale state.
    await pollInstances();
    setTimeout(() => hideModal("#new-server-modal"), 400);
    // Then open with up-to-3s wait for the new row to actually be in
    // allInstances. Belt-and-suspenders: even if pollInstances above
    // finished before the controller's UpsertInstance lit up, we'll
    // catch it on the second tick.
    (async () => {
      const deadline = Date.now() + 3000;
      while (Date.now() < deadline) {
        if (allInstances.find(x => x.instance_id === inst)) break;
        await new Promise(r => setTimeout(r, 200));
        // Re-fetch in case the periodic 2s poll hasn't run yet.
        try { await pollInstances(); } catch {}
      }
      try { openInstanceModal(inst); } catch {}
      try { maybePromptSteamLoginAfterCreate(inst, module); } catch {}
      // Post-create mod install. The instance's volumes have to exist
      // before FsWrite lands files in them, so wait until status is
      // past "creating" first. installModWhenReady polls + retries.
      if (installRefugebot) installModWhenReady(inst, "refugebot");
    })();
  }, { loadingLabel: "Creating", successLabel: "Created ✓", errorLabel: "Create failed" });
});

// installModWhenReady — polls the instance until status leaves
// "creating", then POSTs /install-mod. Used both by the create-form
// "install RefugeBot" checkbox path and by the manual install button
// in the Settings tab. Surfaces toasts so the operator sees progress.
async function installModWhenReady(instanceID, modID) {
  toast(`${modID} install queued…`, "ok");
  const deadline = Date.now() + 60000;
  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, 1500));
    try {
      const { instances } = await fetchJSON("/api/instances");
      const cur = (instances || []).find(x => x.instance_id === instanceID);
      if (cur && cur.status !== "creating") break;
    } catch {}
  }
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/install-mod`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ mod_id: modID }),
    });
    toast(`${modID} installed: ${r.files} files (${fmtBytes(r.bytes)}). ${r.restart_after ? "Restart the server to load." : ""}`, "ok", 9000);
  } catch (err) {
    toast(`${modID} install failed: ${err.message}`, "err", 9000);
  }
}

async function maybePromptSteamLoginAfterCreate(instanceID, moduleID) {
  if (!STEAM_LOGIN_REQUIRED.has(moduleID)) return;
  // Cheap probe — /api/steam/accounts returns a usernames array. Empty
  // means no cached creds → operator must sign in before the install
  // can fetch from Workshop.
  let cached = false;
  try {
    const r = await fetchJSON("/api/steam/accounts");
    cached = !!(r && Array.isArray(r.usernames) && r.usernames.length > 0);
  } catch {}
  if (cached) return;
  // Big visible toast that nudges the user to Updates → Sign in. 10s
  // (longer than default) so the user can read it before it fades.
  toast(`${moduleID.toUpperCase()} needs a Steam login to download. Open the Updates tab to sign in — install resumes automatically.`, "warn", 10000);
}

// ================ schedules ================
// #new-sched was the top-level "Add schedule" button on the main page.
// Schedules moved entirely into the per-instance modal so the button is
// gone; guard the listener so the module still loads when the element is
// missing. The modal's own "＋ Add schedule for this server" button uses
// #im-s-add and is wired elsewhere.
const newSchedBtn = $("#new-sched");
if (newSchedBtn) newSchedBtn.addEventListener("click", () => showModal("#sched-modal"));
$("#s-when").addEventListener("change", () => {
  const v = $("#s-when").value, wrap = $("#s-custom-wrap");
  $("#s-custom-cron").style.display = v === "cron-custom" ? "" : "none";
  $("#s-custom-event").style.display = v === "event-custom" ? "" : "none";
  $("#s-custom-sustain").style.display = v === "event-custom" ? "" : "none";
  wrap.style.display = (v === "cron-custom" || v === "event-custom") ? "block" : "none";
});
$("#s-action").addEventListener("change", () => {
  const v = $("#s-action").value, wrap = $("#s-action-custom-wrap");
  $("#s-action-rcon").style.display = v === "rcon-custom" ? "" : "none";
  $("#s-action-webhook-url").style.display = v === "webhook-custom" ? "" : "none";
  $("#s-action-webhook-body").style.display = v === "webhook-custom" ? "" : "none";
  // Backup-before option only makes sense when the server is going
  // offline as part of the action (stop or restart). The combined
  // "instance-maintenance" preset always backs up so we hide the
  // explicit toggle for it.
  const backupApplies = (v === "instance-restart" || v === "instance-stop");
  $("#s-backup-wrap").style.display = backupApplies ? "" : "none";
  $("#s-backup-label").style.display = backupApplies ? "" : "none";
  if (!backupApplies) {
    const cb = $("#s-backup-before");
    if (cb) cb.checked = false;
  }
  // Warning cascade only meaningful when the server is going offline AND the
  // module has RCON (we don't gate on RCON here — the dispatch logs failures
  // and still does the stop, which is the right fallback).
  const warnApplies = (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance");
  $("#s-warn-wrap").style.display = warnApplies ? "flex" : "none";
  $("#s-warn-label").style.display = warnApplies ? "" : "none";
  if (!warnApplies) {
    const wb = $("#s-warn-enable");
    if (wb) { wb.checked = false; $("#s-warn-detail").style.display = "none"; }
  }
  // Show a small note for the maintenance preset describing the full
  // sequence and (when the picked module has one) the pre-save command.
  let hint = $("#s-maintenance-hint");
  if (v === "instance-maintenance") {
    if (!hint) {
      hint = document.createElement("div");
      hint.id = "s-maintenance-hint";
      hint.className = "row";
      hint.style.cssText = "grid-column: 1 / -1; padding: 10px 14px; border-radius:var(--radius); background: rgba(95,178,255,.08); border: 1px solid rgba(95,178,255,.28); font-size: 12px; color: var(--text); line-height: 1.55;";
      $("#s-action").parentElement.parentElement.parentElement.insertBefore(hint, $("#s-action-custom-wrap").nextSibling);
    }
    const inst = allInstances.find(x => x.instance_id === $("#s-instance").value);
    const modID = inst && inst.module_id;
    const saveAction = modID && (BATCH_GAME_ACTIONS[modID] || []).find(a => a.id === "save");
    const sequence = [
      saveAction ? `<strong>${esc(saveAction.rcon)}</strong> over RCON (10s for the world to flush)` : null,
      `Graceful <strong>stop</strong> (waits for the agent to confirm the container actually exited — no naive sleep)`,
      `<strong>Clean backup</strong> (saves + configs only, per the module manifest)`,
      `<strong>Start</strong> the server back up`,
    ].filter(Boolean).map((s, i) => `<li>${s}</li>`).join("");
    hint.innerHTML = `🛠 Sequence:<ol style="margin: 6px 0 0 20px; padding: 0">${sequence}</ol>`;
    hint.style.display = "";
  } else if (hint) {
    hint.style.display = "none";
  }
  // Bloodmoon-aware: only for 7dtd stop/restart/maintenance actions.
  const inst7 = allInstances.find(x => x.instance_id === $("#s-instance").value);
  const is7dtd = inst7 && inst7.module_id === "7dtd";
  const bmApplies = is7dtd && (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance");
  $("#s-bmaware-wrap").style.display = bmApplies ? "flex" : "none";
  $("#s-bmaware-label").style.display = bmApplies ? "" : "none";
  wrap.style.display = (v === "rcon-custom" || v === "webhook-custom") ? "block" : "none";
});
// When the operator changes the target instance, re-evaluate the
// bloodmoon-aware visibility against the new module_id.
$("#s-instance") && $("#s-instance").addEventListener("change", () => {
  const v = $("#s-action").value;
  const inst7 = allInstances.find(x => x.instance_id === $("#s-instance").value);
  const is7dtd = inst7 && inst7.module_id === "7dtd";
  const bmApplies = is7dtd && (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance");
  $("#s-bmaware-wrap").style.display = bmApplies ? "flex" : "none";
  $("#s-bmaware-label").style.display = bmApplies ? "" : "none";
});
// Reveal the warning detail block when the toggle flips.
const _warnToggle = $("#s-warn-enable");
if (_warnToggle) {
  _warnToggle.addEventListener("change", () => {
    $("#s-warn-detail").style.display = _warnToggle.checked ? "" : "none";
  });
}

// shiftCronEarlier subtracts `mins` minutes from a 6-field robfig cron spec.
// We only touch the minute field; if it's a literal int (the common case for
// our preset "Every 6 hours" = "0 0 */6 * * *") we wrap-around mod 60 and
// also shift the hour by -1 when we wrap. For step / list / range minute
// fields we wrap the whole expression in a "(orig)-N min" approach: pre-pend
// an "@every"-style note in the description but leave the cron as-is. The
// dispatch's warning loop is independent of how we got there; if the operator
// uses a custom cron the warnings just won't span the gap and that's their
// choice.
function shiftCronEarlier(spec, mins) {
  if (!spec || !mins || mins <= 0) return spec;
  const parts = spec.trim().split(/\s+/);
  if (parts.length !== 6) return spec; // 5-field or unknown — leave alone
  const minTok = parts[1], hrTok = parts[2];
  const minNum = parseInt(minTok, 10);
  if (isNaN(minNum) || String(minNum) !== minTok) return spec; // not a literal
  let m = minNum - mins;
  let hrDelta = 0;
  while (m < 0) { m += 60; hrDelta += 1; }
  parts[1] = String(m);
  if (hrDelta > 0) {
    const hrNum = parseInt(hrTok, 10);
    if (!isNaN(hrNum) && String(hrNum) === hrTok) {
      let h = hrNum - hrDelta;
      while (h < 0) h += 24;
      parts[2] = String(h);
    }
    // If hour is */6 etc., we leave it alone — the schedule will fire
    // mins-minutes-early on every interval, which is what we want.
  }
  return parts.join(" ");
}

$("#s-add").addEventListener("click", async () => {
  const instance = $("#s-instance").value;
  if (!instance) { toast("Pick a server", "err"); return; }
  const when = $("#s-when").value, actionSel = $("#s-action").value, grace = parseInt($("#s-grace").value || "30", 10);
  let trigger_kind, cron_spec, event_spec, event_sustained_seconds = 0;
  if (when === "cron-custom") { trigger_kind = "cron"; cron_spec = $("#s-custom-cron").value.trim(); if (!cron_spec) { toast("Cron spec required", "err"); return; } }
  else if (when === "event-custom") { trigger_kind = "event"; event_spec = $("#s-custom-event").value.trim(); event_sustained_seconds = parseInt($("#s-custom-sustain").value||"0",10); if (!event_spec) { toast("CEL expression required","err"); return; } }
  else if (when.startsWith("cron:")) { trigger_kind = "cron"; cron_spec = when.slice(5); }
  else if (when.startsWith("event:")) { trigger_kind = "event"; const rest = when.slice(6); const i = rest.lastIndexOf(":"); event_spec = rest.slice(0,i); event_sustained_seconds = parseInt(rest.slice(i+1),10)||0; }
  let action;
  if (actionSel === "rcon-custom") { const cmd = $("#s-action-rcon").value.trim(); if (!cmd) { toast("RCON command required","err"); return; } action = { type:"rcon", command: cmd }; }
  else if (actionSel === "webhook-custom") { const url = $("#s-action-webhook-url").value.trim(); if (!url.startsWith("http")) { toast("Enter a valid URL","err"); return; } action = { type:"webhook", url, method:"POST", body: $("#s-action-webhook-body").value.trim() || '{"event":"panel"}' }; }
  else if (actionSel.startsWith("rcon:")) action = { type:"rcon", command: actionSel.slice(5) };
  else if (actionSel === "instance-restart"||actionSel === "instance-stop"||actionSel === "instance-start") {
    action = { type:"instance", op: actionSel.split("-")[1], grace_seconds: grace };
    // "Clean backup" only applies to stop/restart (server actually goes
    // offline). Set when the operator ticked the checkbox.
    if ((actionSel === "instance-restart" || actionSel === "instance-stop") && $("#s-backup-before") && $("#s-backup-before").checked) {
      action.backup_before_start = true;
    }
  }
  else if (actionSel === "instance-maintenance") {
    // Combined "save + stop + backup + start" preset. Always restart-with-backup;
    // the only thing that varies per game is whether we have a save command to
    // run first. Pulled from BATCH_GAME_ACTIONS so the same source-of-truth
    // drives both the dashboard's bulk Save World button and the scheduler.
    action = { type:"instance", op:"restart", grace_seconds: grace, backup_before_start: true };
    const inst = allInstances.find(x => x.instance_id === instance);
    const modID = inst && inst.module_id;
    const saveAction = modID && (BATCH_GAME_ACTIONS[modID] || []).find(a => a.id === "save");
    if (saveAction && saveAction.rcon) {
      action.pre_save_rcon = saveAction.rcon;
      action.pre_save_delay_sec = 10;
    }
  }
  // Warning cascade: applies to any instance-* action. Shifts cron earlier
  // so the actual restart still lands on the displayed time.
  const warnEnable = $("#s-warn-enable") && $("#s-warn-enable").checked;
  const isInstanceAction = (actionSel === "instance-restart" || actionSel === "instance-stop" || actionSel === "instance-maintenance");
  if (warnEnable && isInstanceAction && action && action.type === "instance") {
    const lead = Math.max(1, Math.min(60, parseInt($("#s-warn-lead").value || "15", 10)));
    action.warn_lead_minutes = lead;
    const tmpl = $("#s-warn-template").value.trim();
    if (tmpl) action.warn_template = tmpl;
    const extra = $("#s-warn-extra-template").value.trim();
    if (extra) action.warn_extra_template = extra;
    action.warn_extra_minutes = [2, 1];
    if (trigger_kind === "cron") cron_spec = shiftCronEarlier(cron_spec, lead);
  }
  // Bloodmoon-aware flag: only attach for 7dtd stop/restart/maintenance.
  if (isInstanceAction && action && action.type === "instance") {
    const inst = allInstances.find(x => x.instance_id === instance);
    if (inst && inst.module_id === "7dtd" && $("#s-bmaware") && $("#s-bmaware").checked) {
      action.bloodmoon_aware = true;
    }
  }
  const payload = { instance_id: instance, trigger_kind, description: $("#s-desc").value.trim(), action };
  if (trigger_kind === "cron") payload.cron_spec = cron_spec; else { payload.event_spec = event_spec; payload.event_sustained_seconds = event_sustained_seconds; }
  try { await fetchJSON("/api/schedules", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(payload) }); toast("Schedule added", "ok"); $("#s-desc").value = ""; hideModal("#sched-modal"); pollSchedules(); }
  catch (err) { toast(`Add failed: ${err.message}`, "err"); }
});

document.addEventListener("change", async e => {
  const t = e.target.dataset.toggle;
  if (t) {
    const v = e.target.checked ? "enable" : "disable";
    try { await fetchJSON(`/api/schedules/${encodeURIComponent(t)}/${v}`, { method: "POST" }); pollSchedules(); }
    catch (err) { toast(v + " failed: " + err.message, "err"); pollSchedules(); }
  }
});

// ================ pair agent ================
$("#pair-agent").addEventListener("click", () => {
  $("#pair-tok-form").style.display = "block";
  $("#pair-tok-result").style.display = "none";
  showModal("#pair-modal");
});
$("#pair-gen").addEventListener("click", async () => {
  const desc = $("#pair-desc").value.trim();
  const mins = parseInt($("#pair-expires").value, 10);
  try {
    const r = await fetchJSON("/api/admin/pair-tokens", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ description: desc, expires_minutes: mins }),
    });
    $("#pair-tok-val").textContent = r.token;
    $("#pair-tok-exp").textContent = `Expires: ${new Date(r.expires_at).toLocaleString()} (${mins} min)`;
    const host = location.hostname + ":8080";
    $("#pair-cmd").textContent =
      `# on the new agent host:\n` +
      `./agent \\\n` +
      `  --pair-token ${r.token} \\\n` +
      `  --pair-url http://${host} \\\n` +
      `  --agent-id <some-name> \\\n` +
      `  --cert-dir ./data/certs\n\n` +
      `# then start it normally (no --insecure, no --pair-token):\n` +
      `./agent --agent-id <some-name> --modules-dir ./modules`;
    $("#pair-tok-form").style.display = "none";
    $("#pair-tok-result").style.display = "block";
  } catch (err) { toast("Token failed: " + err.message, "err"); }
});
$("#pair-close").addEventListener("click", () => hideModal("#pair-modal"));

// ================ edit agent ================
function openAgentEdit(id) {
  const a = allAgents.find(x => x.agent_id === id); if (!a) return;
  $("#ae-id").textContent = id;
  $("#ae-label").value = a.label || "";
  $("#ae-notes").value = a.notes || "";
  // Port range — empty inputs = no override, allocator falls back to default.
  $("#ae-port-start").value = a.port_range_start ? String(a.port_range_start) : "";
  $("#ae-port-end").value   = a.port_range_end   ? String(a.port_range_end)   : "";
  // LAN IP — used by opnfwd integration as the forward target. The /api/agents
  // endpoint surfaces this as `lan_ip`; absent on older controllers, treat as "".
  $("#ae-lan-ip").value = a.lan_ip || "";
  $("#ae-save").dataset.id = id;
  showModal("#agent-edit-modal");
}
$("#ae-port-clear").addEventListener("click", () => {
  $("#ae-port-start").value = "";
  $("#ae-port-end").value = "";
});
$("#ae-save").addEventListener("click", async () => {
  const id = $("#ae-save").dataset.id;
  // Validate port range — both empty = clear, or both set with start<=end.
  const sRaw = $("#ae-port-start").value.trim();
  const eRaw = $("#ae-port-end").value.trim();
  let portStart = 0, portEnd = 0;
  if (sRaw || eRaw) {
    portStart = parseInt(sRaw || "0", 10) | 0;
    portEnd   = parseInt(eRaw || "0", 10) | 0;
    if (!portStart || !portEnd || portStart < 1 || portEnd > 65535 || portStart > portEnd) {
      toast("Port range must be 1-65535 with start ≤ end (or both empty for default)", "err");
      return;
    }
  }
  try {
    await fetchJSON(`/api/agents/${encodeURIComponent(id)}`, {
      method: "PATCH", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({
        label: $("#ae-label").value,
        notes: $("#ae-notes").value,
        port_range_start: portStart,
        port_range_end:   portEnd,
      }),
    });
    // LAN IP is a separate endpoint (its own validator). Fire only if changed
    // or being cleared — saves a no-op DB write on every Save click.
    const lanIP = $("#ae-lan-ip").value.trim();
    const a = allAgents.find(x => x.agent_id === id);
    const priorLAN = (a && a.lan_ip) || "";
    if (lanIP !== priorLAN) {
      try {
        await fetchJSON(`/api/agents/${encodeURIComponent(id)}/lan-ip`, {
          method: "PATCH", headers: {"Content-Type":"application/json"},
          body: JSON.stringify({ lan_ip: lanIP }),
        });
      } catch (lanErr) {
        toast("LAN IP save failed: " + lanErr.message, "err");
        return;
      }
    }
    toast("Agent updated", "ok");
    hideModal("#agent-edit-modal");
    pollAgents();
  } catch (err) { toast("Save failed: " + err.message, "err"); }
});

// ================ file helpers ================
function iconForFile(name) {
  const l = name.toLowerCase();
  if (/\.(jar|class)$/.test(l)) return "☕";
  if (/\.(yml|yaml|toml|ini|conf|cfg|properties)$/.test(l)) return "⚙️";
  if (/\.(json|xml)$/.test(l)) return "📋";
  if (/\.(log|txt|md)$/.test(l)) return "📄";
  if (/\.(sh|bat|cmd|py|js|ts)$/.test(l)) return "📜";
  if (/\.(zip|gz|tar|bz2|7z|rar)$/.test(l)) return "📦";
  if (/\.(png|jpg|jpeg|gif|webp|bmp)$/.test(l)) return "🖼️";
  if (/\.(dat|db|sqlite)$/.test(l)) return "💾";
  return "📄";
}
function looksBinary(s) {
  const sample = s.slice(0, 2048); let n = 0;
  for (let i = 0; i < sample.length; i++) { const c = sample.charCodeAt(i); if (c === 0) return true; if (c < 9 || (c > 13 && c < 32)) n++; }
  return n > sample.length * 0.1;
}
function downloadFile(path, name, content) {
  const blob = new Blob([content], { type: "application/octet-stream" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a"); a.href = url; a.download = name;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
  toast(`Downloaded ${name}`, "ok");
}

// ================ editor (file) ================
async function openFileInEditor(sub, name) {
  const id = instanceModalState.id; if (!id) return;
  const wirePath = buildFilePath(sub);
  try {
    const q = new URLSearchParams({ path: wirePath });
    const { content, size } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/read?${q}`);
    if (looksBinary(content)) {
      if (confirm(`"${name || sub}" looks like a binary file. Download instead of editing?`)) {
        downloadFile(sub, name || sub.split("/").pop(), content); return;
      }
    }
    $("#e-path").textContent = wirePath;  // show full container path in editor header
    $("#e-path").dataset.sub = sub;        // remember subpath for save
    $("#e-size").textContent = fmtBytes(size) + " · " + content.split("\n").length + " lines";
    $("#e-body").value = content;
    // Reset the in-editor search so a stale query/highlight from a prior
    // file doesn't carry over into the new one.
    const _es = $("#e-search"); if (_es) _es.value = "";
    if (typeof _editorFindCompute === "function") _editorFindCompute();
    if (typeof _editorHideFindHighlight === "function") _editorHideFindHighlight();
    showModal("#editor-modal"); setTimeout(() => $("#e-body").focus(), 50);
  } catch (err) { toast(`Open failed: ${err.message}`, "err"); }
}
$("#e-close").addEventListener("click", () => hideModal("#editor-modal"));
$("#e-save").addEventListener("click", async () => {
  const id = instanceModalState.id; if (!id) return;
  const wirePath = $("#e-path").textContent;
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ path: wirePath, content: $("#e-body").value }) });
    $("#e-size").textContent = fmtBytes(r.bytes) + " · saved"; toast(`Saved ${wirePath.split("/").pop()}`, "ok"); loadInstanceFiles();
  } catch (err) { toast("Save failed: " + err.message, "err"); }
});
$("#e-download").addEventListener("click", () => { const path = $("#e-path").textContent; downloadFile(path, path.split("/").pop(), $("#e-body").value); });
document.addEventListener("keydown", e => {
  if ((e.ctrlKey || e.metaKey) && e.key === "s" && $("#editor-modal").style.display === "flex") { e.preventDefault(); $("#e-save").click(); }
  // Ctrl/Cmd+F focuses the in-editor search when the modal's open. We
  // suppress the browser's native find dialog because it doesn't know
  // about the textarea's content layout — Ctrl+G / F3 also route through.
  if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F") && $("#editor-modal").style.display === "flex") {
    e.preventDefault();
    const sb = $("#e-search"); if (sb) { sb.focus(); sb.select(); }
  }
});

// =============== EDITOR FIND (in-textarea search) ===============
// Scans for occurrences of the query and on prev/next does:
//   1. Smooth-scroll the textarea so the matched line lands ~30% from the top.
//   2. Drop an animated highlight overlay div on the matched line —
//      RIDES the scroll via a scroll listener so the highlight stays glued
//      to the line whether the user navigates or scrolls manually.
//   3. Keep focus in the search input the whole time. We deliberately do
//      NOT call ta.focus() / setSelectionRange — those caused stray
//      keystrokes to overwrite selected matches in the textarea.
const _editorFind = { matches: [], idx: -1, q: "", currentLine: -1, scrollHooked: false };

function _editorFindCompute() {
  const ta = $("#e-body");
  const inp = $("#e-search");
  const cnt = $("#e-search-count");
  const prev = $("#e-search-prev");
  const next = $("#e-search-next");
  if (!ta || !inp || !cnt) return;
  const q = inp.value;
  _editorFind.q = q;
  if (!q) {
    _editorFind.matches = []; _editorFind.idx = -1;
    cnt.textContent = "—"; cnt.className = "es-count";
    prev.disabled = true; next.disabled = true;
    _editorHideFindHighlight();
    return;
  }
  const hay = ta.value.toLowerCase();
  const needle = q.toLowerCase();
  const matches = [];
  let from = 0;
  while (from <= hay.length - needle.length) {
    const at = hay.indexOf(needle, from);
    if (at < 0) break;
    matches.push({ start: at, end: at + needle.length });
    from = at + Math.max(1, needle.length);
  }
  _editorFind.matches = matches;
  if (!matches.length) {
    _editorFind.idx = -1;
    cnt.textContent = "0/0"; cnt.className = "es-count no-match";
    prev.disabled = true; next.disabled = true;
    _editorHideFindHighlight();
    return;
  }
  // First match — don't auto-jump (and don't pretend cursor matters; we
  // never moved the cursor). User pages with Enter / ↓.
  _editorFind.idx = -1;
  cnt.textContent = `0/${matches.length}`;
  cnt.className = "es-count has-match";
  prev.disabled = false; next.disabled = false;
}

function _editorFindGoto(delta) {
  const ta = $("#e-body");
  const cnt = $("#e-search-count");
  if (!_editorFind.matches.length) return;
  let idx = _editorFind.idx + delta;
  if (idx < 0) idx = _editorFind.matches.length - 1;
  if (idx >= _editorFind.matches.length) idx = 0;
  _editorFind.idx = idx;
  const m = _editorFind.matches[idx];
  const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 21;
  const padTop = parseFloat(getComputedStyle(ta).paddingTop) || 0;
  const linesBefore = ta.value.slice(0, m.start).split("\n").length - 1;
  // Place the match ~30% from the top of the visible area for context.
  const targetLineY = padTop + linesBefore * lineHeight;
  const margin = ta.clientHeight * 0.30;
  const desiredScroll = Math.max(0, targetLineY - margin);
  // Smooth scroll. The highlight tracks scrollTop via a listener attached
  // below so it stays on the right line through the animation.
  ta.scrollTo({ top: desiredScroll, behavior: "smooth" });
  _editorShowFindHighlight(linesBefore);
  cnt.textContent = `${idx + 1}/${_editorFind.matches.length}`;
  cnt.className = "es-count has-match";
}

function _editorShowFindHighlight(lineNum) {
  const ta = $("#e-body");
  if (!ta) return;
  const wrap = ta.parentElement;
  let hl = wrap.querySelector(".editor-find-hl");
  if (!hl) {
    hl = document.createElement("div");
    hl.className = "editor-find-hl";
    wrap.appendChild(hl);
  }
  _editorFind.currentLine = lineNum;
  // Attach scroll listener once — the highlight follows the textarea's
  // scrollTop so it visually rides the matched line during smooth scroll
  // AND when the user scrolls the textarea by hand later.
  if (!_editorFind.scrollHooked) {
    ta.addEventListener("scroll", _editorPositionFindHighlight);
    _editorFind.scrollHooked = true;
  }
  _editorPositionFindHighlight();
  // Restart the entrance pulse on each navigation.
  hl.classList.remove("pulse");
  void hl.offsetWidth; // force reflow so the animation can re-trigger
  hl.classList.add("pulse");
  hl.style.display = "block";
}

function _editorPositionFindHighlight() {
  const ta = $("#e-body");
  if (!ta || _editorFind.currentLine < 0) return;
  const wrap = ta.parentElement;
  const hl = wrap && wrap.querySelector(".editor-find-hl");
  if (!hl) return;
  const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 21;
  const padTop = parseFloat(getComputedStyle(ta).paddingTop) || 0;
  const taTop = ta.offsetTop;
  const taLeft = ta.offsetLeft;
  const lineY = taTop + padTop + _editorFind.currentLine * lineHeight - ta.scrollTop;
  // Hide when the matched line scrolls off-screen.
  if (lineY + lineHeight < taTop || lineY > taTop + ta.clientHeight) {
    hl.style.display = "none";
    return;
  }
  hl.style.display = "block";
  hl.style.top = lineY + "px";
  hl.style.left = taLeft + "px";
  hl.style.width = ta.clientWidth + "px";
  hl.style.height = lineHeight + "px";
}

function _editorHideFindHighlight() {
  const ta = $("#e-body");
  if (!ta) return;
  const wrap = ta.parentElement;
  const hl = wrap && wrap.querySelector(".editor-find-hl");
  if (hl) { hl.style.display = "none"; hl.classList.remove("pulse"); }
  _editorFind.currentLine = -1;
}

(() => {
  const inp = $("#e-search");
  if (!inp) return;
  inp.addEventListener("input", () => {
    _editorFindCompute();
  });
  inp.addEventListener("keydown", e => {
    // Always preventDefault on Enter so the textarea (or any parent form)
    // never receives the keystroke. Stay focused in the search box —
    // earlier we focused the textarea, which let stray typing replace
    // the selected match and corrupt the file.
    if (e.key === "Enter") {
      e.preventDefault();
      e.stopPropagation();
      _editorFindGoto(e.shiftKey ? -1 : +1);
    } else if (e.key === "Escape") {
      e.preventDefault();
      e.stopPropagation();
      inp.value = ""; _editorFindCompute();
    }
  });
  $("#e-search-prev").addEventListener("click", () => { _editorFindGoto(-1); inp.focus(); });
  $("#e-search-next").addEventListener("click", () => { _editorFindGoto(+1); inp.focus(); });
})();

// ================ INSTANCE MODAL ================
const instanceModalState = {
  id: "",
  tab: "console",
  path: "",
  sseUnsub: null,            // unsubscribe fn for the console's shared per-instance SSE
  consoleAutoscroll: true,
  consoleLines: [],          // cache of rendered lines so we can re-render on "Clear"
};

function onInstanceModalClose() {
  // Tear down the per-instance SSE subscription if still open.
  if (instanceModalState.sseUnsub) { try { instanceModalState.sseUnsub(); } catch {} instanceModalState.sseUnsub = null; }
  if (instanceModalState.playersTimer) { clearInterval(instanceModalState.playersTimer); instanceModalState.playersTimer = null; }
  if (typeof stopDayzModJobPolling === "function") stopDayzModJobPolling();
  if (typeof stopConanModJobPolling === "function") stopConanModJobPolling();
  // EAH pane leftovers: the ban-list countdown interval + the live SSE
  // stream must not outlive the modal.
  if (window._banTimer) { clearInterval(window._banTimer); window._banTimer = null; }
  if (typeof _eahLiveStop === "function") _eahLiveStop();
  // Close the console command drawer + autocomplete.
  const drawer = $("#im-c-helpdrawer"); if (drawer) { drawer.classList.remove("on"); drawer.innerHTML = ""; }
  if (typeof hideConsoleAutocomplete === "function") hideConsoleAutocomplete();
  // Clear the fatal banner + reset its seen set so a different instance's
  // modal doesn't inherit the previous one's banner state.
  const fb = $("#im-c-fatal"); if (fb) fb.style.display = "none";
  if (instanceModalState.fatalSeen) instanceModalState.fatalSeen.clear();
  instanceModalState.id = "";
}

function openInstanceModal(id, tab) {
  const i = allInstances.find(x => x.instance_id === id); if (!i) return;
  instanceModalState.id = id;
  instanceModalState.path = "";
  instanceModalState.rootPath = "";     // reset so a different module's roots get re-picked
  instanceModalState.consoleLines = [];
  refreshInstanceModalHead();
  // Deep-link to a specific tab (Console/Files/Settings/…) when the caller
  // asks for one — the card's Console/Files buttons use this so they land
  // straight on the right surface instead of always opening Console.
  switchTab(tab || "console");
  showModal("#instance-modal");
}

function refreshInstanceModalHead() {
  const id = instanceModalState.id;
  const i = allInstances.find(x => x.instance_id === id); if (!i) return;
  const app = appearanceFor(i.module_id);
  const mod = (allModules.find(m => m.id === i.module_id) || {});
  const agent = i.agent_id || "—";
  // Prefer the bundled square game icon; fall back to the emoji for
  // modules that don't have one packaged. The image inherits the
  // tile's rounded corners via overflow:hidden on .game-tile.
  const tile = $("#im-tile");
  const iconURL = gameIconURLFor(i.module_id);
  if (iconURL) {
    tile.textContent = "";
    tile.style.background = app.grad; // fallback color while img decodes
    tile.dataset.hasIcon = "1";
    let img = tile.querySelector("img");
    if (!img) {
      img = document.createElement("img");
      img.alt = "";
      img.draggable = false;
      tile.appendChild(img);
    }
    if (img.getAttribute("src") !== iconURL) img.src = iconURL;
  } else {
    const old = tile.querySelector("img"); if (old) old.remove();
    tile.textContent = app.emoji;
    tile.style.background = app.grad;
    tile.dataset.hasIcon = "0";
  }
  const head = $("#im-head"), banner = $("#im-banner");
  const bannerURL = artURLFor(app);
  if (bannerURL) {
    banner.style.backgroundImage = `url("${bannerURL}")`;
    head.classList.add("has-banner");
  } else {
    banner.style.backgroundImage = "";
    head.classList.remove("has-banner");
  }
  $("#im-title").textContent = id;
  $("#im-sub").textContent = `${mod.name || i.module_id} · on agent ${agent}`;
  // Live header stats — players / CPU / RAM / uptime. Each chip hides
  // itself when its datum is unavailable (e.g. stopped server, no sample
  // yet) so we never show "— players". Reuses the same latestStats /
  // latestAppState feeds the cards use.
  {
    const dead = i.status === "stopped" || i.status === "crashed";
    const st = dead ? {} : (latestStats[i.instance_id] || {});
    const as = dead ? {} : (latestAppState[i.instance_id] || {});
    const setHStat = (key, valEl, val) => {
      const chip = document.querySelector(`#im-head-stats [data-hstat="${key}"]`);
      const ve = $(valEl);
      if (val == null) { if (chip) chip.hidden = true; return; }
      if (chip) chip.hidden = false;
      if (ve && ve.textContent !== String(val)) ve.textContent = String(val);
    };
    const players = (as.players_online != null && as.players_max != null) ? `${as.players_online}/${as.players_max}` : null;
    setHStat("players", "#im-hs-players", players);
    setHStat("cpu", "#im-hs-cpu", st.cpu != null ? fmtCpu(st).long : null);
    setHStat("ram", "#im-hs-ram", st.memMB != null ? (st.memMB >= 1024 ? `${(st.memMB/1024).toFixed(1)}G` : `${st.memMB.toFixed(0)}M`) : null);
    let upTxt = null;
    if (i.status === "running" && i.updated_at) {
      const t = Date.parse(i.updated_at);
      if (!isNaN(t)) upTxt = _fmtCompactDuration(Date.now() - t);
    }
    setHStat("uptime", "#im-hs-uptime", upTxt);
  }
  const pill = $("#im-status");
  const isInstalling = instanceInstalling.has(i.instance_id);
  const isReadyFlash = instanceReady.has(i.instance_id);
  const isStarting   = instanceStarting.has(i.instance_id);
  const isStopping   = instanceStopping.has(i.instance_id);
  const isDeleting   = instanceDeleting.has(i.instance_id);
  // Same log-confirm override as the cards: docker-running is not
  // joinable for ARK / Empyrion / Conan / windrose / rust. Wait for
  // the actual ready log line (or the long-uptime fallback) before
  // letting the modal pill go green.
  const needLogConfirm = READY_LOG_ONLY.has(i.module_id) && i.status === "running" && !isConfirmedReady(i);
  let effectiveStatus = i.status || "";
  if (isDeleting) effectiveStatus = "deleting";
  else if (isInstalling) effectiveStatus = "installing";
  else if (isReadyFlash) effectiveStatus = "ready";
  else if (isStarting) effectiveStatus = "starting";
  else if (isStopping) effectiveStatus = "stopping";
  else if (needLogConfirm) effectiveStatus = "starting";
  pill.className = "status-pill " + effectiveStatus;
  pill.textContent = effectiveStatus || "—";
  const isRunning = i.status === "running";
  const transient = isInstalling || isStarting || isStopping || isDeleting;
  const needsSteam = (i.detail === "needs_steam_login");
  // While "starting" we intentionally leave the Stop button visible so
  // the user can cancel a misbehaving boot even before it's fully up.
  // While "installing" we hide Start (you can't launch a half-downloaded
  // install) but leave Stop so operators can cancel the install by
  // stopping the container. needs_steam_login: Start is hidden — it
  // would just bounce the empty container; user must Update first.
  $("#im-start").style.display = (isRunning || transient || needsSteam) ? "none" : "";
  $("#im-stop").style.display  = (isRunning || transient) ? "" : "none";
  $("#im-start").disabled = transient || needsSteam;
  $("#im-stop").disabled = isStopping || isDeleting;
  $("#im-restart").disabled = !isRunning || transient;
  $("#im-c-cmd").disabled = !isRunning;
  $("#im-c-send").disabled = !isRunning;
  // Steam-login banner — surface on top of the modal head when the
  // instance was created with a Steam-required update_provider but
  // creds aren't cached yet. Click the banner to jump to the Updates
  // tab where the sign-in lives. Operators reported DayZ creates "did
  // nothing"; this makes it explicit.
  let steamBanner = $("#im-steam-banner");
  if (needsSteam) {
    if (!steamBanner) {
      steamBanner = document.createElement("div");
      steamBanner.id = "im-steam-banner";
      steamBanner.className = "im-steam-banner";
      steamBanner.innerHTML = `<span style="margin-right:8px">🔒</span><strong>Sign in to Steam to install</strong> — this game's downloads need a Steam account that owns the title. <button class="small primary" style="margin-left:auto" id="im-steam-banner-go">Open Updates tab</button>`;
      const body = document.querySelector(".im-body");
      if (body) body.parentNode.insertBefore(steamBanner, body);
      const go = $("#im-steam-banner-go");
      if (go) go.addEventListener("click", e => { e.stopPropagation(); switchTab("updates"); });
    }
    steamBanner.style.display = "";
  } else if (steamBanner) {
    steamBanner.style.display = "none";
  }
  if (isDeleting) {
    $("#im-c-mode").textContent = "Deleting — container removal in flight.";
  } else if (isInstalling) {
    $("#im-c-mode").textContent = "Downloading game files — this can take a while on first boot.";
  } else if (isStarting) {
    $("#im-c-mode").textContent = "Starting the game server — waiting for it to report ready…";
  } else if (isStopping) {
    $("#im-c-mode").textContent = "Stopping the server — graceful shutdown in flight.";
  } else if (isRunning) {
    $("#im-c-mode").textContent = "● Live server output — RCON ready.";
  } else if (needsSteam) {
    $("#im-c-mode").textContent = "Server is stopped — sign in to Steam + click Update to install.";
  } else {
    $("#im-c-mode").textContent = "Server is stopped.";
  }
  // DayZ-only "XML Integration" tab visibility.
  const dayzMod = i.module_id === "dayz";
  $$(".im-tab-dayz").forEach(b => b.style.display = dayzMod ? "" : "none");
  // ARK-only "Cluster" tab visibility.
  const arkMod = i.module_id === "ark-sa";
  $$(".im-tab-ark").forEach(b => b.style.display = arkMod ? "" : "none");
  // 7DTD-only "Mods" tab visibility.
  const sdtdMod = i.module_id === "7dtd";
  $$(".im-tab-7dtd").forEach(b => b.style.display = sdtdMod ? "" : "none");
  // Conan-only "Mods" tab visibility.
  const conanMod = i.module_id === "conan-exiles";
  $$(".im-tab-conan").forEach(b => b.style.display = conanMod ? "" : "none");
  // Empyrion-only "EAH" tab visibility.
  const empyrionMod = i.module_id === "empyrion";
  $$(".im-tab-empyrion").forEach(b => b.style.display = empyrionMod ? "" : "none");
  // Divider before the game-specific tab group — show only when this
  // module actually contributes any game tabs.
  const hasGameTabs = dayzMod || arkMod || sdtdMod || conanMod || empyrionMod;
  $$(".im-tab-divider.im-tab-game").forEach(d => d.style.display = hasGameTabs ? "" : "none");
  // 7DTD bloodmoon indicator: poll the PanelBloodMoonAgent status file
  // when the modal is open on a 7dtd instance. Status file is written
  // every 5s by the mod; we re-fetch every 15s while the modal is up.
  refreshBloodMoonPill(i);
}

// refreshBloodMoonPill reads /game-saves/.panel-bloodmoon-status.json
// via the existing files-read API and shows a small "BLOOD MOON" pill
// next to the status pill when active:true. Silent no-op for non-7DTD
// instances or when the mod hasn't written a file yet (older builds,
// instance stopped, etc.).
async function refreshBloodMoonPill(inst) {
  const pill = $("#im-bloodmoon");
  if (!pill) return;
  if (!inst || inst.module_id !== "7dtd" || inst.status !== "running") {
    pill.style.display = "none";
    return;
  }
  try {
    // Absolute path → safeJoinAny matches it to the /game-saves browseable
    // root for 7dtd module. Endpoint returns {path, content (string), size}.
    const url = `/api/instances/${encodeURIComponent(inst.instance_id)}/files/read?path=` +
                encodeURIComponent("/game-saves/.panel-bloodmoon-status.json");
    const r = await fetch(url, { credentials: "same-origin" });
    if (!r.ok) { pill.style.display = "none"; return; }
    const body = await r.json();
    if (!body || !body.content) { pill.style.display = "none"; return; }
    const status = JSON.parse(body.content);
    if (status && status.active === true) {
      const day = status.in_game_day || "?";
      const hr  = status.in_game_hour || 0;
      const mn  = String(status.in_game_minute || 0).padStart(2, "0");
      pill.title = `Blood moon active · day ${day} ${hr}:${mn} · source=${status.source || "?"} · ends_unix=${status.ends_at_unix || 0}`;
      pill.style.display = "inline-block";
    } else {
      pill.style.display = "none";
      if (status && status.next_horde_day) {
        // Optional: leave a faint tooltip on the status pill for next-horde info.
        const sp = $("#im-status");
        if (sp) sp.title = `Next blood moon: day ${status.next_horde_day}`;
      }
    }
  } catch {
    pill.style.display = "none";
  }
}

// Re-render modal head when polling refreshes allInstances.
const _renderCards = renderServerCards;
renderServerCards = function() {
  _renderCards.apply(this, arguments);
  if (instanceModalState.id) refreshInstanceModalHead();
};

function switchTab(tab) {
  instanceModalState.tab = tab;
  $$(".im-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
  $$(".im-panel").forEach(p => p.classList.toggle("active", p.dataset.panel === tab));
  // Lazy-load tab content.
  if (tab === "console")        ensureConsoleStream();
  else if (tab === "players")   loadInstancePlayers();
  else if (tab === "config")    loadInstanceConfig();
  else if (tab === "files")     loadInstanceFiles();
  else if (tab === "backups")   loadInstanceBackups();
  else if (tab === "schedules") loadInstanceSchedules();
  else if (tab === "updates")   loadInstanceUpdates();
  else if (tab === "dayzmods")  loadDayzModsTab();
  else if (tab === "dayzxml")   loadDayzXmlTab();
  else if (tab === "conanmods") loadConanModsTab();
  else if (tab === "arkmods")    loadArkModsTab();
  else if (tab === "arkcluster") loadArkClusterTab();
  else if (tab === "arkrestore") loadArkRestoreTab();
  else if (tab === "sdtdmods")   loadSdtdModsTab();
  else if (tab === "sdtdcluster") loadSdtdClusterTab();
  else if (tab === "regionhealth") loadRegionHealth();
  else if (tab === "empyrioneah") loadEmpyrionEAHTab();
  else if (tab === "settings")  renderInstanceSettings();

  // Stop the players polling cycle when switching away.
  if (tab !== "players" && instanceModalState.playersTimer) {
    clearInterval(instanceModalState.playersTimer);
    instanceModalState.playersTimer = null;
  }
}
$("#im-tabs").addEventListener("click", e => {
  const t = e.target.closest(".im-tab"); if (!t) return;
  switchTab(t.dataset.tab);
});

// --- quick buttons in header
$("#im-start").addEventListener("click", e => { const id = instanceModalState.id; if (id) doInstanceAction(id, "start", e.target); });
$("#im-stop").addEventListener("click",  e => { const id = instanceModalState.id; if (id) doInstanceAction(id, "stop",  e.target); });
$("#im-restart").addEventListener("click", async e => {
  const id = instanceModalState.id; if (!id) return;
  e.target.disabled = true;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/stop?grace=10`, { method: "POST" });
    toast(`Stopping ${id}…`);
    setTimeout(async () => {
      try {
        await fetchJSON(`/api/instances/${encodeURIComponent(id)}/start`, { method:"POST" });
        toast(`Starting ${id}…`, "ok");
        markStarting(id);
      } catch(err) { toast("Restart failed: " + err.message, "err"); }
    }, 2500);
  }
  catch (err) { toast("Restart failed: " + err.message, "err"); }
  finally { setTimeout(() => e.target.disabled = false, 1500); }
});

// ---------------- CONSOLE tab ----------------
// Patterns that look like repeating progress updates — we collapse runs of
// these into a single line that updates in place, like a real terminal would.
// Key = the line's "identity" (stable across updates).
function progressKey(text) {
  // "Update state (0x61) downloading, progress: 47.95 (...)" → "Update state downloading"
  const m1 = text.match(/^\s*Update state \([^)]+\) (\w+), progress:/);
  if (m1) return "steamcmd:" + m1[1];
  // "[ 64%] Installing ..." / "Progress: 64%"
  if (/^\s*\[\s*\d+%\s*\]/.test(text) || /^\s*Progress:\s*\d/.test(text)) return "progress";
  // LGSM "Starting sdtdserver: <step>" (vinanrra emits many of these per step)
  const m2 = text.match(/^\s*Starting sdtdserver: (.+?)$/);
  if (m2) return "lgsm-start";
  return null;
}

// PERF: this used to append + autoscroll PER LINE — and `scrollTop =
// scrollHeight` reads scrollHeight, which forces a synchronous layout. A
// booting server bursts 50-200 lines/sec → that many forced layouts per
// second inside a blurred glass panel = the "2fps console". Lines now
// queue and flush once per animation frame: one fragment append, one
// trim, ONE autoscroll, regardless of burst size.
const _consoleQueue = [];
let _consoleFlushArmed = false;
// ---- Scroll pinning ----
// The console autoscrolls ONLY while the user is "pinned" to the bottom.
// Scrolling up into history unpins (so streaming stops yanking the view);
// returning to the bottom re-pins automatically. The auto-scroll checkbox
// stays a hard override on top of this. While unpinned, new lines are
// counted into a "N new lines ↓" chip that jumps back to the bottom.
let _consolePinned = true;
let _consoleScrollSquelchUntil = 0;
let _consoleUnseen = 0;
const CONSOLE_MAX_LINES = 2000;
function _consoleTrimTop(box, excess) {
  // Bulk trims go through one Range.deleteContents() — a single mutation
  // instead of N removeChild layout invalidations.
  if (excess > 50) {
    const range = document.createRange();
    range.setStartBefore(box.children[0]);
    range.setEndAfter(box.children[excess - 1]);
    range.deleteContents();
    return;
  }
  for (let k = 0; k < excess; k++) box.removeChild(box.firstChild);
}
// Timestamp memo: toLocaleTimeString costs ~40µs a call, which adds up to
// ~8ms on a 200-line flush. Log bursts share the same wall-clock second,
// so cache the formatted string per second.
let _tsMemoSec = -1, _tsMemoStr = "";
function _fmtConsoleTs(ts) {
  const ms = ts ? new Date(ts).getTime() : Date.now();
  const sec = Math.floor(ms / 1000);
  if (sec !== _tsMemoSec) {
    _tsMemoSec = sec;
    _tsMemoStr = new Date(sec * 1000).toLocaleTimeString([], { hour12: false });
  }
  return _tsMemoStr;
}
// Line skeleton cloned per line — cloneNode(true) + textContent is ~3x
// cheaper than innerHTML parse + esc(), and textContent needs no escaping.
const _consoleLnTpl = (() => {
  const d = document.createElement("div");
  d.className = "ln";
  const ts = document.createElement("span"); ts.className = "ts";
  const tx = document.createElement("span"); tx.className = "txt";
  d.appendChild(ts); d.appendChild(tx);
  return d;
})();
// Per-rAF-pass build limits: cap lines AND wall time so a burst never
// stalls a frame; the remainder re-arms rAF and lands next frame(s).
const FLUSH_LINE_CAP = 250;
const FLUSH_MS_BUDGET = 6;
function _updateConsoleNewChip(box) {
  let chip = document.getElementById("im-c-newchip");
  if (!chip) {
    const wrap = box.closest(".console-wrap");
    if (!wrap) return;
    chip = document.createElement("button");
    chip.id = "im-c-newchip";
    chip.type = "button";
    chip.className = "console-newchip";
    chip.addEventListener("click", () => {
      const b = $("#im-c-out");
      if (b) _consoleRepin(b);
    });
    wrap.appendChild(chip);
  }
  chip.textContent = `${_consoleUnseen} new line${_consoleUnseen === 1 ? "" : "s"} ↓`;
  chip.classList.add("on");
}
function _hideConsoleNewChip() {
  const chip = document.getElementById("im-c-newchip");
  if (chip) chip.classList.remove("on");
}
// Re-pin: run the trim we deferred while the user was reading scrollback,
// jump to the bottom, clear the chip. overflow-anchor goes back to "none"
// while pinned (we control the scroll position ourselves) and "auto" while
// unpinned (browser scroll anchoring keeps the reading position stable if
// anything above the viewport changes).
function _consoleRepin(box) {
  _consolePinned = true;
  _consoleUnseen = 0;
  box.style.overflowAnchor = "none";
  const excess = box.children.length - CONSOLE_MAX_LINES;
  if (excess > 0) _consoleTrimTop(box, excess);
  _hideConsoleNewChip();
  _consoleScrollSquelchUntil = performance.now() + 200;
  box.scrollTop = box.scrollHeight;
  // Belt-and-braces: land the bottom again after layout settles (wrapped
  // lines / late paints can grow scrollHeight after the first set). The
  // scroll handler treats a not-quite-bottom programmatic scroll as a user
  // unpin, so this second pass keeps the chip from flickering back.
  requestAnimationFrame(() => {
    if (_consolePinned) box.scrollTop = box.scrollHeight;
  });
}
function _resetConsolePinState() {
  _consolePinned = true;
  _consoleUnseen = 0;
  const box = $("#im-c-out");
  if (box) box.style.overflowAnchor = "none";
  _hideConsoleNewChip();
}
function appendConsoleLine(entry) {
  _consoleQueue.push(entry);
  // Backstop for hidden tabs (rAF doesn't fire there): keep the queue from
  // growing unbounded — the DOM cap would discard the overflow anyway.
  if (_consoleQueue.length > 2500) _consoleQueue.splice(0, _consoleQueue.length - 2500);
  if (_consoleFlushArmed) return;
  _consoleFlushArmed = true;
  requestAnimationFrame(_flushConsoleQueue);
}
function _flushConsoleQueue() {
  _consoleFlushArmed = false;
  const box = $("#im-c-out");
  if (!box) { _consoleQueue.length = 0; return; }
  if (!_consoleQueue.length) return;
  // Pre-trim: a queue at/above the DOM cap means every existing line will
  // be trimmed anyway — drop the queue's own overflow and wipe the box in
  // one replaceChildren() instead of building nodes destined for removal.
  if (_consoleQueue.length >= CONSOLE_MAX_LINES) {
    _consoleQueue.splice(0, _consoleQueue.length - CONSOLE_MAX_LINES);
    box.replaceChildren();
  }
  const t0 = performance.now();
  const frag = document.createDocumentFragment();
  // Track the effective "last line" across the batch so consecutive
  // progress lines coalesce within the queue too, not just across flushes.
  let lastEl = box.lastElementChild;
  let built = 0, taken = 0;
  for (const { kind, text, ts } of _consoleQueue) {
    taken++;
    const t = _fmtConsoleTs(ts);
    const key = kind === "log" ? progressKey(text) : null;
    if (key && lastEl && lastEl.dataset.progKey === key) {
      lastEl.querySelector(".ts").textContent = t;
      lastEl.querySelector(".txt").textContent = text;
      continue;
    }
    const line = _consoleLnTpl.cloneNode(true);
    line.className = "ln " + (kind || "");
    if (key) line.dataset.progKey = key;
    const prefix = kind === "log" ? "" : ({ cmd: "> ", reply: "", err: "! ", sys: "· " }[kind] || "");
    line.firstChild.textContent = t;
    line.lastChild.textContent = prefix + text;
    frag.appendChild(line);
    lastEl = line;
    built++;
    // Chunk: stop this pass at the line cap or the frame-time budget;
    // the leftover queue re-arms rAF below and continues next frame.
    if (built >= FLUSH_LINE_CAP || performance.now() - t0 > FLUSH_MS_BUDGET) break;
  }
  const appended = taken;
  _consoleQueue.splice(0, taken);
  if (_consoleQueue.length && !_consoleFlushArmed) {
    _consoleFlushArmed = true;
    requestAnimationFrame(_flushConsoleQueue);
  }
  if (frag.childNodes.length) box.appendChild(frag);
  if (_consolePinned && instanceModalState.consoleAutoscroll) {
    const excess = box.children.length - CONSOLE_MAX_LINES;
    if (excess > 0) _consoleTrimTop(box, excess);
    _consoleScrollSquelchUntil = performance.now() + 200;
    box.scrollTop = box.scrollHeight;
  } else {
    // Unpinned: the operator is reading scrollback. No autoscroll, and no
    // top-trim either — removing nodes above the viewport shifts the text
    // under their eyes. The trim is deferred to re-pin. A hard safety cap
    // keeps a very long unpinned stream from growing the DOM unbounded;
    // overflow-anchor:auto (set on unpin) lets the browser keep the
    // reading position stable when that cap trims from the top.
    _consoleUnseen += appended;
    const hardExcess = box.children.length - CONSOLE_MAX_LINES * 2.5;
    if (hardExcess > 0) _consoleTrimTop(box, hardExcess);
    _updateConsoleNewChip(box);
  }
}
// Wire the pin state to real user scrolling. Programmatic scrollTop sets
// from the flush also fire "scroll", but they land at the bottom, where
// atBottom === _consolePinned and the handler no-ops.
(function () {
  const box = $("#im-c-out");
  if (!box) return;
  box.style.overflowAnchor = "none";
  box.addEventListener("scroll", () => {
    const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 8;
    if (atBottom === _consolePinned) return;
    if (atBottom) {
      _consoleRepin(box);
    } else {
      // Ignore not-at-bottom events briefly after our own programmatic
      // scrolls — mid-layout they can read a pixel short of the bottom
      // and would spuriously unpin (the chip-flicker glitch).
      if (performance.now() < _consoleScrollSquelchUntil) return;
      _consolePinned = false;
      box.style.overflowAnchor = "auto";
    }
  });
})();
// Render one wire event into the console. Log-only: state transitions show
// in the status pill, player events live in the Players tab — the console
// stays a pure server-side log stream with no sys/status chatter.
// FATAL_LOG_PATTERNS catches log lines that explain WHY a server died.
// Pattern → { name (short label), guidance (what the operator should do).
// Surfaced as a sticky red banner above the console + in a toast so the
// crash isn't silent. Each is evaluated against every incoming log line;
// a match arms the banner with that line as evidence.
const FATAL_LOG_PATTERNS = [
  {
    re: /FAtlasSaveManager::LoadOperationSql: SQL database is corrupt/i,
    label: "ARK save data corrupted",
    guidance: "ARK's save SQLite database is corrupt — usually from a SIGKILL during a save write. Recovery: open Settings → Maintenance → Delete (with 'Wipe Docker volumes' checked) → recreate. Any unsaved progress is lost. Backups taken before the crash should restore cleanly.",
  },
  {
    re: /BattlEye Server: Failed to load|BattlEye Initialization Failed/i,
    label: "BattlEye init failed",
    guidance: "BattlEye couldn't start under Wine/Proton on this Linux container. ARK SA's BattlEye builds aren't reliable on Linux yet — set BATTLEEYE=FALSE in Config and restart. Players join via direct connect; not visible in BE-protected browser.",
  },
  {
    re: /AssertionFailed:.*Cluster|CreateASAClustersFolderMutex.*FAILED/i,
    label: "ARK cluster mount inaccessible",
    guidance: "The cluster folder mount didn't materialize correctly. Open Cluster tab → Leave cluster → re-Join (or Setup Cluster fresh). The shared volume bind needs to recreate.",
  },
  {
    re: /no space left on device|disk full/i,
    label: "Host disk full",
    guidance: "The host running this agent is out of disk space. Old container layers, ARK saves, or backup tarballs may be the culprit. SSH in and run `docker system df` to find the biggest consumers.",
  },
  {
    re: /steamcmd: exhausted|SteamCMD: install failed.*after/i,
    label: "SteamCMD install failed",
    guidance: "SteamCMD couldn't download game files after multiple retries. Likely a Steam CDN hiccup. Wait 5 min and click Update again — transient errors are auto-retried, but a hard exhaustion needs an operator nudge.",
  },
];

function renderConsoleEvent(ev) {
  if (ev.log) {
    const line = ev.log.line || "";
    appendConsoleLine({ kind: "log", text: line, ts: ev.at });
    // While the install banner is up, mirror the latest pre-container
    // log line into the banner's sub-line so the operator can see what
    // the agent is doing right now (image build step, SteamCMD progress).
    const banner = document.getElementById("im-c-install-banner");
    if (banner && banner.style.display !== "none" && line) {
      const sub = document.getElementById("im-c-install-step");
      if (sub) sub.textContent = line.length > 120 ? line.slice(0, 120) + "…" : line;
    }
    // Fatal-pattern scan — fire ONCE per (instance, pattern) per session.
    // Sets a banner inside the console pane and pops a sticky toast so
    // the operator doesn't have to scroll the log to find why the server
    // is dead. Stored on the modal state so re-opens stay quiet for
    // patterns we've already shown.
    if (instanceModalState.id === ev.log.instance_id) {
      if (!instanceModalState.fatalSeen) instanceModalState.fatalSeen = new Set();
      for (const f of FATAL_LOG_PATTERNS) {
        if (f.re.test(line) && !instanceModalState.fatalSeen.has(f.label)) {
          instanceModalState.fatalSeen.add(f.label);
          showFatalLogBanner(f.label, f.guidance, line);
          toast(`${f.label} — see banner above console`, "err", 12000);
        }
      }
    }
    return true;
  }
  return false;
}
// Renders a sticky banner above the console explaining a known-fatal
// log line. Click X to dismiss; auto-clears when console is cleared
// or modal closes (via #im-c-out innerHTML reset).
function showFatalLogBanner(label, guidance, line) {
  let banner = $("#im-c-fatal");
  if (!banner) {
    banner = document.createElement("div");
    banner.id = "im-c-fatal";
    banner.className = "im-fatal-banner";
    const consoleWrap = document.querySelector(".im-panel[data-panel='console'] .console-wrap");
    const out = $("#im-c-out");
    if (consoleWrap && out) consoleWrap.insertBefore(banner, out);
  }
  banner.innerHTML = `
    <button class="im-fatal-close" title="Dismiss">×</button>
    <div class="im-fatal-head"><span class="im-fatal-icon">⚠</span><span class="im-fatal-label">${esc(label)}</span></div>
    <div class="im-fatal-guidance">${esc(guidance)}</div>
    <div class="im-fatal-line mono">${esc(line.length > 200 ? line.slice(0,200)+"…" : line)}</div>`;
  banner.style.display = "";
  const closeBtn = banner.querySelector(".im-fatal-close");
  if (closeBtn) closeBtn.onclick = () => { banner.style.display = "none"; };
}

async function ensureConsoleStream() {
  const id = instanceModalState.id; if (!id) return;
  // Reset the per-instance stream so we only see this instance's events.
  if (instanceModalState.sseUnsub) { try { instanceModalState.sseUnsub(); } catch {} instanceModalState.sseUnsub = null; }
  // Reset the rendered buffer so history isn't mixed with the previous
  // instance's content.
  const box = $("#im-c-out"); if (box) box.innerHTML = "";
  _resetConsolePinState();

  // 1. Fetch recent ring-buffer history so the pane isn't blank on open.
  //    Pure log stream only — no sys-banner separators, no state/player noise.
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/logs/recent?limit=400`);
    // Also scan history for the ready-signal log line. The live SSE only
    // replays the last ~5s, so if the user opens the console minutes
    // after boot completes, the joinable line is already past — without
    // this scan, the pill would stay yellow until the 10-min uptime
    // fallback. The history scan picks it up immediately.
    const inst = allInstances.find(x => x.instance_id === id);
    const pat = inst && READY_LOG_ONLY.has(inst.module_id) && readyPatternFor(inst.module_id);
    let foundReady = false;
    for (const ev of (r.events || [])) {
      renderConsoleEvent(ev);
      if (pat && !foundReady && ev.log && ev.log.line && pat.test(ev.log.line)) {
        instanceConfirmedReady.add(id);
        foundReady = true;
      }
    }
    if (foundReady) {
      paintStartingUI();
      if (typeof renderServerCards === "function") renderServerCards();
    }
  } catch { /* silent — console just stays blank on history failure */ }

  // 2. Subscribe to the live stream for new events. Silent retry on disconnect
  //    (EventSource already auto-reconnects; no user-facing status line).
  instanceModalState.sseUnsub = sseSubscribeInstance(id, ev => {
    // Head refresh only on non-log events (state/stats/etc.) — pure log
    // lines can arrive 100+/sec and never change the header.
    try { renderConsoleEvent(ev); if (!ev.log) refreshInstanceModalHead(); } catch {}
  });
}
$("#im-c-send").addEventListener("click", sendInstanceRCON);
$("#im-c-cmd").addEventListener("keydown", onConsoleInputKey);
$("#im-c-cmd").addEventListener("input", onConsoleInputChange);
$("#im-c-clear").addEventListener("click", () => {
  $("#im-c-out").innerHTML = "";
  _resetConsolePinState();
  // Wipe the fatal banner + re-arm pattern detection so the next round
  // of crash signatures (e.g. operator restarted after the fix) shows
  // again instead of being dedup'd by the original session's seen set.
  const fb = $("#im-c-fatal"); if (fb) fb.style.display = "none";
  if (instanceModalState.fatalSeen) instanceModalState.fatalSeen.clear();
});
$("#im-c-autoscroll").addEventListener("change", e => {
  instanceModalState.consoleAutoscroll = e.target.checked;
  // Re-checking the box is an explicit "take me back to live" — re-pin.
  if (e.target.checked) { const b = $("#im-c-out"); if (b) _consoleRepin(b); }
});
$("#im-c-help").addEventListener("click", toggleConsoleHelpDrawer);
// Click outside the autocomplete closes it.
document.addEventListener("click", e => {
  if (!e.target.closest("#im-c-cmd") && !e.target.closest("#im-c-autocomplete")) hideConsoleAutocomplete();
});

// ================ Per-game console command reference ================
//
// Each entry describes a module's RCON/console surface — grouped into
// categories for the drawer, flattened for autocomplete matching. Keep
// descriptions terse (one sentence) and args syntax real (angle = required,
// square = optional). Examples render verbatim, copy-ready on click.
//
// To add a new module: mirror an existing entry's shape. Everything else
// (autocomplete, drawer rendering, click-to-insert) picks it up
// automatically.

const consoleCommandRegistry = {
  "vein": {
    protocol: "Stdio (server console)",
    tagline: "VEIN has no network RCON — the panel writes commands to the server process's stdin and the reply appears in the live log tail (not the Send box). Most admin actions happen in-game (admins set by Steam ID in Game.ini); the documented server-console command set is small. Settings live in Game.ini (edit via the Config tab).",
    categories: [
      { title: "Server", icon: "🖥️", commands: [
        { cmd: "PrintAllVeinSettings", desc: "Dump every available VEIN server setting + its current value to the log (useful to discover keys for Game.ini)." },
      ]},
    ],
  },
  "dayz": {
    protocol: "BattlEye RCON",
    tagline: "DayZ uses BattlEye RCON. `#` commands are admin surface; `say -1 <msg>` broadcasts. DayZ has no vanilla admin cheat commands — most server-side tooling comes from the Community-Online-Tools mod (in-game menu, not RCON).",
    categories: [
      { title: "Players — list & moderation", icon: "👥", commands: [
        { cmd: "#players", desc: "List every connected player with ID, name, IP, ping, GUID." },
        { cmd: "#kick", args: "<player-id> [reason]", desc: "Boot a player by their numeric ID from #players.", ex: "#kick 4 griefing" },
        { cmd: "#ban", args: "<player-id> <minutes> [reason]", desc: "Ban a connected player. 0 = permanent.", ex: "#ban 4 0 blatant cheating" },
        { cmd: "#addBan", args: "<guid> <minutes> [reason]", desc: "Ban an OFFLINE player by GUID." },
        { cmd: "#removeBan", args: "<banID>", desc: "Lift a ban by its entry number in #bans." },
        { cmd: "#bans", desc: "Show the current ban list with IDs." },
        { cmd: "#loadBans", desc: "Reload bans.txt from disk (after editing)." },
        { cmd: "#writeBans", desc: "Write the in-memory ban list to bans.txt." },
        { cmd: "#admins", desc: "Show connected RCON admins." },
      ]},
      { title: "Chat", icon: "💬", commands: [
        { cmd: "say -1", args: "<message>", desc: "Broadcast to every connected player.", ex: "say -1 Server restart in 5 minutes" },
        { cmd: "say", args: "<player-id> <message>", desc: "Whisper one player by ID.", ex: "say 4 Private message here" },
      ]},
      { title: "Server — lifecycle", icon: "⚙️", commands: [
        { cmd: "#shutdown", desc: "Graceful shutdown — mission save then exit." },
        { cmd: "#restart", desc: "Restart the server process." },
        { cmd: "#init", desc: "Reload BE config (server.cfg + BE) without a full process restart." },
        { cmd: "#lock", desc: "Lock the server to new connections." },
        { cmd: "#unlock", desc: "Unlock the server." },
        { cmd: "#monitor", args: "<seconds>", desc: "Enable performance monitoring for N seconds (BE log)." },
        { cmd: "#debug", args: "<0|1|2|3>", desc: "Set BE RCON debug verbosity." },
      ]},
      { title: "Mission", icon: "🗺️", commands: [
        { cmd: "#mission", args: "<missionname>", desc: "Load a different mission (restarts world)." },
        { cmd: "#missions", desc: "List available mission files on disk." },
        { cmd: "#missionname", desc: "Show the currently loaded mission." },
        { cmd: "#reassign", desc: "Re-assign player slots after mission change." },
      ]},
    ],
  },

  "ark-sa": {
    protocol: "Source RCON",
    tagline: "ASA admin console. Commands are case-insensitive. `cheat` commands need admin-auth (RCON sessions are auto-admin). This is a curated subset — ASA has hundreds of GM commands; check the ARK wiki for the exhaustive list.",
    categories: [
      { title: "Players — list", icon: "👥", commands: [
        { cmd: "ListPlayers", desc: "Online players with indexes, names, and Steam IDs." },
        { cmd: "GetPlayerIDForSteamID", args: "<steamid>", desc: "Resolve an ARK player-index from a Steam ID." },
        { cmd: "GetSteamIDForPlayerID", args: "<id>", desc: "Reverse lookup." },
        { cmd: "GetTribeIDPlayerList", args: "<tribeID>", desc: "List every player in a tribe." },
        { cmd: "ShowMyAdminManager", desc: "Open the in-game admin manager UI (client-side)." },
      ]},
      { title: "Players — moderation", icon: "🛡️", commands: [
        { cmd: "KickPlayer", args: "<steamid>", desc: "Kick a player by Steam ID.", ex: "KickPlayer 76561197960287930" },
        { cmd: "BanPlayer", args: "<steamid>", desc: "Permanently ban a Steam ID." },
        { cmd: "UnbanPlayer", args: "<steamid>", desc: "Lift a ban." },
        { cmd: "RenamePlayer", args: "\"<current>\" \"<new>\"", desc: "Rename a player." },
        { cmd: "RenameTribe", args: "\"<current>\" \"<new>\"", desc: "Rename a tribe." },
        { cmd: "cheat AllowPlayerToJoinNoCheck", args: "<steamid>", desc: "Whitelist a Steam ID (requires whitelisting turned on)." },
        { cmd: "cheat DisallowPlayerToJoinNoCheck", args: "<steamid>", desc: "Remove from whitelist." },
      ]},
      { title: "Chat", icon: "💬", commands: [
        { cmd: "Broadcast", args: "<message>", desc: "Big yellow screen-filling broadcast.", ex: "Broadcast Restart in 10 minutes" },
        { cmd: "ServerChat", args: "<message>", desc: "Regular SERVER chat line." },
        { cmd: "ServerChatToPlayer", args: "\"<name>\" <message>", desc: "Whisper by name." },
        { cmd: "ServerChatTo", args: "\"<steamid>\" <message>", desc: "Whisper by Steam ID." },
      ]},
      { title: "World — save & lifecycle", icon: "💾", commands: [
        { cmd: "SaveWorld", desc: "Force a world save to disk." },
        { cmd: "DoExit", desc: "Graceful shutdown (SaveWorld first)." },
        { cmd: "ShutDown", desc: "Like DoExit." },
        { cmd: "DestroyWildDinos", desc: "Wipe + respawn all WILD dinos. Tames are safe." },
        { cmd: "DestroyAllEnemies", desc: "Clears most wild + neutral creatures." },
        { cmd: "DestroyStructures", desc: "Destroy every placed structure (!! — catastrophic)." },
      ]},
      { title: "Time & environment", icon: "🕒", commands: [
        { cmd: "cheat settimeofday", args: "HH:MM", desc: "Set the in-game clock.", ex: "cheat settimeofday 12:00" },
        { cmd: "cheat sloomo", args: "<factor>", desc: "Scale game speed. 1.0 = normal, 0 = pause.", ex: "cheat sloomo 0.5" },
        { cmd: "cheat cheat forcetame", desc: "(Typo-safe) alternative alias for ForceTame." },
      ]},
      { title: "Items & inventory", icon: "🎒", commands: [
        { cmd: "cheat GiveItem", args: "\"<blueprint>\" <qty> <qual> <bp?>", desc: "Give YOU an item by blueprint path.", ex: "cheat GiveItem \"Blueprint'/Game/...MetalIngot.MetalIngot'\" 50 0 0" },
        { cmd: "cheat GiveItemNum", args: "<idx> <qty> <qual> <bp?>", desc: "Give yourself an item by numeric ID." },
        { cmd: "cheat GiveItemToPlayer", args: "<playerID> \"<blueprint>\" <qty> <qual> <bp?>", desc: "Give another player an item." },
        { cmd: "cheat GiveItemNumToPlayer", args: "<playerID> <idx> <qty> <qual> <bp?>", desc: "Numeric ID version of GiveItemToPlayer." },
        { cmd: "cheat ClearPlayerInventory", args: "<playerID> <clearInv> <clearEquip> <clearSlots>", desc: "Wipe a player's inventory (three 0/1 flags)." },
        { cmd: "cheat GiveResources", desc: "Dump 50 of every base resource into your inventory." },
        { cmd: "cheat GiveEngrams", desc: "Unlock every engram for you." },
        { cmd: "cheat GiveEngramsTekOnly", desc: "Unlock just the Tek-tier engrams." },
        { cmd: "cheat UnlockEngram", args: "\"<blueprint>\"", desc: "Unlock a specific engram by blueprint path." },
        { cmd: "cheat GiveExpToPlayer", args: "<playerID> <amount> <fromTribe> <preventSharing>", desc: "Award player XP." },
        { cmd: "cheat AddExperience", args: "<amount> <fromTribe> <preventSharing>", desc: "Award yourself XP." },
      ]},
      { title: "Self — god mode & stats", icon: "🩸", commands: [
        { cmd: "cheat God", desc: "Toggle god-mode on yourself (no damage)." },
        { cmd: "cheat InfiniteStats", desc: "Infinite health + food + water + stamina (no damage to stats)." },
        { cmd: "cheat Fly", desc: "Toggle flight on your character." },
        { cmd: "cheat Walk", desc: "Stop flying." },
        { cmd: "cheat Ghost", desc: "Pass through objects (no-clip)." },
        { cmd: "cheat ForceGhost", desc: "Like Ghost but more forceful." },
        { cmd: "cheat GodConsole", args: "true|false", desc: "SERVER-WIDE god-mode toggle — huge impact, use carefully." },
        { cmd: "cheat EnemyInvisible", args: "true|false", desc: "Creatures stop noticing you." },
      ]},
      { title: "Creatures — spawn & tame", icon: "🦖", commands: [
        { cmd: "cheat Summon", args: "<classname>", desc: "Spawn a creature right at you.", ex: "cheat Summon Rex_Character_BP_C" },
        { cmd: "cheat SummonTamed", args: "<classname>", desc: "Same but already tamed to you." },
        { cmd: "cheat GMSummon", args: "\"<classname>\" <level>", desc: "Summon at a specific level — preferred for powerful tames.", ex: "cheat GMSummon \"Giga_Character_BP_C\" 150" },
        { cmd: "cheat SpawnDino", args: "\"<blueprint>\" <spawnDistance> <spawnYOffset> <zOffset> <dinoLevel>", desc: "Controlled dino spawn with placement." },
        { cmd: "cheat ForceTame", desc: "Instantly tame whatever you're looking at." },
        { cmd: "cheat DoTame", desc: "Perfect-tame the target (simulates kibble taming)." },
        { cmd: "cheat ForceTameAOE", args: "<radius>", desc: "Tame everything within radius." },
        { cmd: "cheat DestroyMyTarget", desc: "Kill whatever's under your crosshair." },
        { cmd: "cheat KillAOE", args: "<category> <radius>", desc: "Kill all of a category (pawn/structure/wild) in range.", ex: "cheat KillAOE wild 5000" },
      ]},
      { title: "Teleport", icon: "📍", commands: [
        { cmd: "cheat TeleportPlayerIDToMe", args: "<playerID>", desc: "Yank a player to your position." },
        { cmd: "cheat TeleportToPlayer", args: "<playerID>", desc: "Jump to a player." },
        { cmd: "cheat TeleportToPlayerName", args: "\"<name>\"", desc: "Jump to a player by name." },
        { cmd: "cheat SetPlayerPos", args: "<x> <y> <z>", desc: "Teleport yourself to absolute coords.", ex: "cheat SetPlayerPos 0 0 1000" },
        { cmd: "cheat Teleport", desc: "Teleport in the direction you're facing." },
      ]},
      { title: "Tribe admin", icon: "🏛️", commands: [
        { cmd: "cheat MakeTribeAdmin", desc: "Make yourself tribe admin of the tribe you're looking at." },
        { cmd: "cheat MakeTribeFounder", desc: "Make yourself tribe founder." },
        { cmd: "cheat ForceJoinTribe", desc: "Force your character into the tribe of the player you're looking at." },
        { cmd: "cheat MergeTribe", args: "<tribeID>", desc: "Merge the tribe you're looking at into another." },
      ]},
    ],
  },

  "7dtd": {
    protocol: "Telnet",
    tagline: "7DTD telnet console. Type `help` at any time for the server's live list (includes modlet commands like Prisma Core / CSMM / Starvation). Quote multi-word args.",
    categories: [
      { title: "Introspection", icon: "❓", commands: [
        { cmd: "help", desc: "List every command the server recognises RIGHT NOW — includes any modlet-added commands (Prisma Core, CSMM helpers, etc.)." },
        { cmd: "help", args: "<command>", desc: "Show detailed usage + aliases for one command.", ex: "help giveself" },
        { cmd: "version", desc: "Game + build version + compatibility version." },
        { cmd: "ver", desc: "Alias for version." },
      ]},
      { title: "Players — list", icon: "👥", commands: [
        { cmd: "lp", desc: "List connected players (name, id, steamid, ip, level, tokens)." },
        { cmd: "lpi", desc: "List all KNOWN player IDs ever connected (offline included). Same as listplayerids." },
        { cmd: "listknownplayers", desc: "List every known player (name, steamid, last seen). Alias: lkp." },
        { cmd: "listplayerids", desc: "Alias for lpi." },
        { cmd: "le", args: "[filter]", desc: "List entities (players + zombies + drops). Filter by type.", ex: "le alive" },
        { cmd: "listents", args: "[filter]", desc: "Detailed entity listing." },
      ]},
      { title: "Players — moderation", icon: "🛡️", commands: [
        { cmd: "kick", args: "<name|id|steamid> [reason]", desc: "Kick one player.", ex: "kick \"GrieferBob\" \"breaking rules\"" },
        { cmd: "kickall", args: "[reason]", desc: "Kick every non-admin player at once." },
        { cmd: "ban add", args: "<name|steamid> <duration> [reason]", desc: "Add a ban. Duration: 30 seconds, 5 minutes, 7 days, 1 year, or 0.", ex: "ban add 76561197960000000 30 days cheating" },
        { cmd: "ban remove", args: "<name|steamid>", desc: "Unban." },
        { cmd: "ban list", desc: "Show all current bans." },
        { cmd: "admin add", args: "<steamid> <level 0-1000>", desc: "Grant admin. 0 = owner, 1000 = normal player.", ex: "admin add 76561197960000000 0" },
        { cmd: "admin remove", args: "<steamid>", desc: "Revoke admin." },
        { cmd: "admin list", desc: "List all admins + their perm levels." },
        { cmd: "cp add", args: "<command> <level>", desc: "Set the permission level required to run a command.", ex: "cp add say 1000" },
        { cmd: "cp remove", args: "<command>", desc: "Clear a command's custom permission." },
        { cmd: "cp list", desc: "Show all command-permission overrides." },
        { cmd: "whitelist add", args: "<steamid>", desc: "Add a player to the whitelist." },
        { cmd: "whitelist remove", args: "<steamid>", desc: "Remove from whitelist." },
        { cmd: "whitelist list", desc: "Show whitelisted steam IDs." },
      ]},
      { title: "Chat & Communication", icon: "💬", commands: [
        { cmd: "say", args: "\"<message>\"", desc: "Broadcast a chat message from SERVER.", ex: "say \"Restart in 5 min — save your bases\"" },
        { cmd: "pm", args: "<name> \"<message>\"", desc: "Whisper a single player (private message)." },
      ]},
      { title: "World — time & weather", icon: "🕒", commands: [
        { cmd: "gettime", desc: "Current game day + in-game clock." },
        { cmd: "settime", args: "day|night|<ticks>", desc: "Jump to day, night, or a raw tick count.", ex: "settime day" },
        { cmd: "setworldtime", args: "<ticks>", desc: "Set absolute world tick. 24000 = one full day." },
        { cmd: "shownexthordetime", desc: "Report when the next blood-moon horde fires." },
        { cmd: "bc-size", args: "<N>", desc: "Set blood-moon horde size multiplier (default 1.0)." },
        { cmd: "bc-remove", args: "<entityID>", desc: "Remove an entity from the blood-moon horde." },
        { cmd: "weather", args: "<var> <value>", desc: "Force a weather variable (Rain, Wind, Temperature…).", ex: "weather Rain 1" },
        { cmd: "weathersurvival", args: "on|off", desc: "Toggle whether weather affects survival stats." },
      ]},
      { title: "World — chunks & prefabs", icon: "🌍", commands: [
        { cmd: "chunkcache", desc: "Show the chunk cache status. Alias: cc." },
        { cmd: "chunkreset", args: "<x> <z>", desc: "Regenerate a chunk (loses player edits there)." },
        { cmd: "chunkobserver", args: "<x> <z> [time]", desc: "Force-load a chunk for observation." },
        { cmd: "repairchunkdensity", desc: "Rebuild collision data for damaged chunks." },
        { cmd: "prefab", args: "<subcommand>", desc: "Prefab tools — list, rotate, bounds, etc. Run bare for subcommand list." },
        { cmd: "removelandclaim", args: "<steamid>", desc: "Remove ONE land-claim block (lookup via steamid)." },
        { cmd: "removelandclaims", desc: "Wipe ALL land claims on the server — destructive." },
        { cmd: "visitmap", args: "<x1> <z1> <x2> <z2>", desc: "Mark a region as 'visited' on the player's map." },
        { cmd: "traderarea", args: "[options]", desc: "Inspect / manage trader protection zones." },
      ]},
      { title: "Spawns & Events", icon: "🧟", commands: [
        { cmd: "spawnentity", args: "<player> <entityID>", desc: "Spawn an entity AT a player (by numeric entity class ID)." },
        { cmd: "spawnairdrop", desc: "Trigger an air-drop event now." },
        { cmd: "spawnsupplycrate", desc: "Drop a supply crate at the caller." },
        { cmd: "spawnscouts", desc: "Send a screamer-scout horde (detect a hot spot)." },
        { cmd: "spawnwanderinghorde", desc: "Spawn a wandering horde immediately." },
        { cmd: "ge", desc: "List currently-registered game events (useful for custom modlets)." },
      ]},
      { title: "Self — items & XP", icon: "🎒", commands: [
        { cmd: "giveself", args: "<item> [quality] [count]", desc: "Give yourself an item.", ex: "giveself gunMR10 6" },
        { cmd: "giveselfxp", args: "<amount>", desc: "Award yourself player XP." },
        { cmd: "giveselfskillxp", args: "<skill> <amount>", desc: "Skill-specific XP.", ex: "giveselfskillxp perkMachineGuns 5000" },
        { cmd: "buffplayer", args: "<name> <buff>", desc: "Apply a buff to a player.", ex: "buffplayer @s buffInfectionDeath" },
        { cmd: "debuffplayer", args: "<name> <buff>", desc: "Remove a buff from a player." },
        { cmd: "buff", args: "<buff>", desc: "Apply a buff to yourself." },
        { cmd: "debuff", args: "<buff>", desc: "Remove a buff from yourself." },
        { cmd: "showinventory", args: "<name>", desc: "Print a player's full inventory to the console." },
      ]},
      { title: "Self — state", icon: "🩸", commands: [
        { cmd: "god", desc: "Toggle god-mode on the caller." },
        { cmd: "kill", args: "[name]", desc: "Instantly kill yourself or a named player." },
        { cmd: "killall", desc: "Kill every non-player entity (zombies, animals, bandits) — alive entities only." },
        { cmd: "starve", args: "[name]", desc: "Drain a player's food stat to zero." },
        { cmd: "thirsty", args: "[name]", desc: "Drain a player's water stat to zero." },
        { cmd: "exhausted", args: "[name]", desc: "Drain a player's stamina to zero." },
        { cmd: "resetplayer", args: "<steamid>", desc: "Wipe a player's saved data (forces fresh start)." },
        { cmd: "teleport", args: "<dest>", desc: "Teleport yourself. Dest = player name, \"x y z\", or offset. Alias: tp / tele.", ex: "teleport 0 100 0" },
        { cmd: "teleportplayer", args: "<name> <dest>", desc: "Teleport someone else.", ex: "teleportplayer GrieferBob 0 -70 0" },
        { cmd: "loc", desc: "Print your current world coords." },
      ]},
      { title: "Server — admin / ops", icon: "⚙️", commands: [
        { cmd: "saveworld", desc: "Force a full world save." },
        { cmd: "shutdown", desc: "Graceful shutdown — saves first." },
        { cmd: "settargetfps", args: "<N>", desc: "Cap server FPS (default 30, modders sometimes push higher)." },
        { cmd: "getlogpath", desc: "Show the path of the current server log file." },
        { cmd: "showdlc", desc: "List DLCs recognised by the server." },
        { cmd: "settempunit", args: "c|f", desc: "Switch temperature display between Celsius / Fahrenheit." },
        { cmd: "mem", desc: "Memory usage." },
        { cmd: "memcl", desc: "Memory on the client side (if co-hosted)." },
        { cmd: "memgcall", desc: "Force a full garbage-collection pass." },
        { cmd: "netstats", desc: "Network statistics (bandwidth + packets)." },
        { cmd: "listthreads", desc: "Dump all running server threads." },
      ]},
      { title: "Dev / debug", icon: "🐛", commands: [
        { cmd: "debugmenu", args: "on|off", desc: "Toggle the in-game F1 dev console." },
        { cmd: "creativemenu", args: "on|off", desc: "Toggle the creative menu (U key)." },
        { cmd: "spectator", args: "on|off", desc: "Spectator camera toggle." },
        { cmd: "spectrum", args: "<name>", desc: "Force a rendering spectrum (lighting profile).", ex: "spectrum Blood Moon" },
        { cmd: "showclouds", args: "on|off", desc: "Toggle cloud rendering." },
        { cmd: "showweather", args: "on|off", desc: "Toggle weather particle rendering." },
        { cmd: "enablerendering", args: "on|off", desc: "Toggle world rendering entirely (benchmark use)." },
        { cmd: "fov", args: "<N>", desc: "Set field-of-view in degrees." },
        { cmd: "staticmeshinfo", desc: "Dump info about loaded static meshes." },
        { cmd: "xuireload", desc: "Reload the XUI (HUD / UI) definitions." },
      ]},
    ],
  },

  "minecraft-java": {
    protocol: "Source RCON",
    tagline: "Vanilla Minecraft Java Edition commands. Selectors (@a/@p/@r/@e/@s) + target-selector arguments ([name=…, type=…, distance=..5]) work. Plugins (Paper / Spigot / Fabric-API) add many more — check your server's plugin docs.",
    categories: [
      { title: "Players — list & moderation", icon: "👥", commands: [
        { cmd: "list", desc: "Show connected players." },
        { cmd: "list uuids", desc: "Same, with UUIDs." },
        { cmd: "kick", args: "<player> [reason]", desc: "Boot a player." },
        { cmd: "ban", args: "<player> [reason]", desc: "Ban by name." },
        { cmd: "ban-ip", args: "<ip|player> [reason]", desc: "Ban by IP." },
        { cmd: "banlist", args: "[ips|players]", desc: "Show the ban list (type optional)." },
        { cmd: "pardon", args: "<player>", desc: "Unban a player." },
        { cmd: "pardon-ip", args: "<ip>", desc: "Unban an IP." },
        { cmd: "whitelist add", args: "<player>", desc: "Add to whitelist." },
        { cmd: "whitelist remove", args: "<player>", desc: "Remove from whitelist." },
        { cmd: "whitelist on", desc: "Enforce whitelist." },
        { cmd: "whitelist off", desc: "Disable whitelist." },
        { cmd: "whitelist list", desc: "Show the whitelist." },
        { cmd: "whitelist reload", desc: "Reload whitelist.json from disk." },
        { cmd: "op", args: "<player>", desc: "Grant operator." },
        { cmd: "deop", args: "<player>", desc: "Revoke operator." },
      ]},
      { title: "Chat", icon: "💬", commands: [
        { cmd: "say", args: "<message>", desc: "Server broadcast (every player sees it, prefixed [Server])." },
        { cmd: "tell", args: "<player> <message>", desc: "Whisper. Alias: msg, w." },
        { cmd: "me", args: "<action>", desc: "Server /me action line." },
        { cmd: "tellraw", args: "<target> <json>", desc: "Styled message via raw JSON text.", ex: "tellraw @a {\"text\":\"Hi\",\"color\":\"gold\"}" },
        { cmd: "title", args: "<target> <title|subtitle|actionbar|clear|reset> [json]", desc: "Show a big on-screen title.", ex: "title @a title {\"text\":\"Welcome!\"}" },
      ]},
      { title: "Save & lifecycle", icon: "💾", commands: [
        { cmd: "save-all", args: "[flush]", desc: "Force save. `flush` waits for disk sync." },
        { cmd: "save-on", desc: "Re-enable auto-saving." },
        { cmd: "save-off", desc: "Pause auto-saving (for cold backups)." },
        { cmd: "stop", desc: "Shut down the server." },
        { cmd: "reload", desc: "Reload datapacks + functions (does NOT reload plugins)." },
        { cmd: "seed", desc: "Show the world seed." },
      ]},
      { title: "World & rules", icon: "🌍", commands: [
        { cmd: "difficulty", args: "peaceful|easy|normal|hard", desc: "Change world difficulty." },
        { cmd: "gamerule", args: "<rule> [value]", desc: "Read or set a gamerule.", ex: "gamerule keepInventory true" },
        { cmd: "time set", args: "day|night|noon|midnight|<ticks>", desc: "Set in-world time.", ex: "time set day" },
        { cmd: "time add", args: "<ticks>", desc: "Advance world time by ticks." },
        { cmd: "time query", args: "daytime|gametime|day", desc: "Query time in various formats." },
        { cmd: "weather", args: "clear|rain|thunder [seconds]", desc: "Change weather." },
        { cmd: "gamemode", args: "<mode> [target]", desc: "Switch gamemode.", ex: "gamemode creative @a" },
        { cmd: "defaultgamemode", args: "<mode>", desc: "Set the default gamemode for new players." },
        { cmd: "setworldspawn", args: "[x y z]", desc: "Set world spawn point." },
        { cmd: "spawnpoint", args: "[target] [x y z]", desc: "Set a PLAYER's spawn point." },
        { cmd: "worldborder", args: "<sub>", desc: "World border — set/get/add/center/damage/warning.", ex: "worldborder set 5000" },
        { cmd: "forceload add", args: "<x> <z> [x2 z2]", desc: "Keep chunks loaded even with no player." },
        { cmd: "forceload remove", args: "<x> <z> [x2 z2]", desc: "Stop force-loading chunks." },
      ]},
      { title: "Items & entities", icon: "🎒", commands: [
        { cmd: "give", args: "<target> <item> [count] [nbt]", desc: "Give items to a target.", ex: "give @p minecraft:diamond 64" },
        { cmd: "clear", args: "[target] [item] [count]", desc: "Remove items from a target's inventory." },
        { cmd: "replaceitem", args: "<block|entity> <slot> <item> [count]", desc: "Replace an item in a specific slot." },
        { cmd: "summon", args: "<entity> [x y z] [nbt]", desc: "Spawn an entity.", ex: "summon minecraft:lightning_bolt" },
        { cmd: "kill", args: "[target]", desc: "Kill target entities.", ex: "kill @e[type=zombie,distance=..50]" },
        { cmd: "enchant", args: "<target> <enchant> [level]", desc: "Enchant held item." },
        { cmd: "effect give", args: "<target> <effect> [seconds] [amp] [hideParticles]", desc: "Apply a potion effect." },
        { cmd: "effect clear", args: "[target] [effect]", desc: "Remove effects." },
        { cmd: "xp add", args: "<target> <amount> [points|levels]", desc: "Give experience." },
        { cmd: "xp query", args: "<target> [points|levels]", desc: "Check XP." },
        { cmd: "xp set", args: "<target> <amount> [points|levels]", desc: "Set absolute XP." },
      ]},
      { title: "Teleport & positioning", icon: "📍", commands: [
        { cmd: "tp", args: "<target> <dest|x y z>", desc: "Teleport. Alias for teleport." },
        { cmd: "teleport", args: "<target> <dest|x y z> [facing]", desc: "Full teleport with optional facing." },
        { cmd: "spreadplayers", args: "<x> <z> <spread> <max> <respectTeams> <targets>", desc: "Scatter players randomly." },
        { cmd: "locate", args: "<biome|structure|poi>", desc: "Find nearest biome/structure/poi.", ex: "locate structure minecraft:village" },
      ]},
      { title: "Scripting & data", icon: "📜", commands: [
        { cmd: "execute", args: "<subcommands> run <cmd>", desc: "The universal conditional runner. See wiki for the full subcommand tree." },
        { cmd: "function", args: "<namespace:path>", desc: "Run a datapack function." },
        { cmd: "datapack list", desc: "Show all datapacks (enabled + disabled)." },
        { cmd: "datapack enable", args: "<name>", desc: "Enable a datapack." },
        { cmd: "datapack disable", args: "<name>", desc: "Disable a datapack." },
        { cmd: "data get", args: "<target> [path]", desc: "Read NBT data from an entity or block." },
        { cmd: "data modify", args: "<target> <path> <operation> ...", desc: "Edit NBT." },
        { cmd: "scoreboard objectives add", args: "<name> <criterion>", desc: "Create a scoreboard objective." },
        { cmd: "scoreboard players set", args: "<target> <objective> <score>", desc: "Set a score." },
        { cmd: "tag add", args: "<target> <tag>", desc: "Add a scoreboard tag to an entity." },
        { cmd: "team add", args: "<name>", desc: "Create a team." },
        { cmd: "team join", args: "<team> [targets]", desc: "Add targets to a team." },
      ]},
      { title: "World edit (vanilla)", icon: "🧱", commands: [
        { cmd: "setblock", args: "<x y z> <block> [mode]", desc: "Place a block.", ex: "setblock ~ ~ ~ minecraft:diamond_block" },
        { cmd: "fill", args: "<from> <to> <block> [mode]", desc: "Fill a region with blocks." },
        { cmd: "clone", args: "<from1> <from2> <to> [mode]", desc: "Clone a region." },
      ]},
    ],
  },

  "palworld": {
    protocol: "Source RCON",
    tagline: "Palworld admin RCON. CamelCase commands.",
    categories: [
      { title: "Players", icon: "👥", commands: [
        { cmd: "ShowPlayers", desc: "List online players (CSV: name,uid,steamid)." },
        { cmd: "Info", desc: "Server info (version + player count)." },
        { cmd: "KickPlayer", args: "<steamid>", desc: "Kick a player." },
        { cmd: "BanPlayer", args: "<steamid>", desc: "Ban a player." },
        { cmd: "TeleportToPlayer", args: "<steamid>", desc: "Yank you to a player." },
        { cmd: "TeleportToMe", args: "<steamid>", desc: "Yank a player to you." },
      ]},
      { title: "Chat & Server", icon: "💬", commands: [
        { cmd: "Broadcast", args: "<message>", desc: "Broadcast a message to all players." },
        { cmd: "Save", desc: "Force a world save." },
        { cmd: "DoExit", desc: "Graceful shutdown." },
        { cmd: "Shutdown", args: "<seconds> <message>", desc: "Countdown shutdown with a warning.", ex: "Shutdown 60 Server restarting" },
      ]},
    ],
  },

  "rust": {
    protocol: "WebSocket RCON",
    tagline: "Rust uses a dotted-namespace convar tree: server.*, global.*, env.*, weather.*, user.*, ai.*, craft.*. Type `find <keyword>` to discover convars. Facepunch's list is enormous — this is the curated admin + env subset.",
    categories: [
      { title: "Introspection", icon: "❓", commands: [
        { cmd: "find", args: "<keyword>", desc: "Search ALL convars matching a keyword.", ex: "find server." },
        { cmd: "server.writecfg", desc: "Persist all current convar values to server.cfg." },
        { cmd: "server.version", desc: "Print server build + protocol." },
        { cmd: "sleep 0.5", args: "", desc: "(Built-in delay command, can chain with ;)" },
      ]},
      { title: "Players — list & info", icon: "👥", commands: [
        { cmd: "playerlist", desc: "List connected players (JSON)." },
        { cmd: "users", desc: "Alias for playerlist." },
        { cmd: "status", desc: "Short connected-player summary." },
        { cmd: "sleepers", desc: "List sleeping (logged-off) players still in the world." },
        { cmd: "connections", desc: "Raw network connection list." },
      ]},
      { title: "Players — moderation", icon: "🛡️", commands: [
        { cmd: "kick", args: "\"<name|steamid>\" \"<reason>\"", desc: "Boot a player.", ex: "kick \"SomePlayer\" \"griefing\"" },
        { cmd: "kickall", args: "\"<reason>\"", desc: "Kick every connected player." },
        { cmd: "ban", args: "\"<name|steamid>\" \"<reason>\"", desc: "Ban permanently." },
        { cmd: "banid", args: "<steamid> \"<name>\" \"<reason>\"", desc: "Ban by Steam ID explicitly." },
        { cmd: "unban", args: "<steamid>", desc: "Lift a ban." },
        { cmd: "banlist", desc: "Show all current bans." },
        { cmd: "banlistex", desc: "Extended banlist with reasons + dates." },
        { cmd: "moderatorid", args: "<steamid> \"<name>\" \"<reason>\"", desc: "Grant moderator (kick/ban, not config).", ex: "moderatorid 76561197960287930 \"\" \"\"" },
        { cmd: "ownerid", args: "<steamid> \"<name>\" \"<reason>\"", desc: "Grant owner (full RCON + convars)." },
        { cmd: "removeowner", args: "<steamid>", desc: "Revoke owner." },
        { cmd: "removemoderator", args: "<steamid>", desc: "Revoke moderator." },
        { cmd: "mute", args: "<steamid> [seconds]", desc: "Mute chat." },
        { cmd: "unmute", args: "<steamid>", desc: "Unmute." },
      ]},
      { title: "Chat & server", icon: "💬", commands: [
        { cmd: "say", args: "<message>", desc: "Broadcast a SERVER chat message." },
        { cmd: "notice.popupall", args: "\"<message>\" <seconds>", desc: "Big popup notice for every player." },
        { cmd: "server.save", desc: "Force world save." },
        { cmd: "server.fps", desc: "Show server FPS." },
        { cmd: "server.seed", desc: "Show map seed." },
        { cmd: "server.worldsize", desc: "Show map size." },
        { cmd: "server.hostname", args: "\"<name>\"", desc: "Change server name in the browser." },
        { cmd: "server.description", args: "\"<text>\"", desc: "Change server description." },
        { cmd: "server.url", args: "<url>", desc: "Server website URL." },
        { cmd: "serverrestart", args: "<seconds> \"<message>\"", desc: "Countdown restart.", ex: "serverrestart 300 \"Weekly restart in 5 minutes\"" },
        { cmd: "restart", args: "<seconds>", desc: "Shorter restart command." },
        { cmd: "quit", desc: "IMMEDIATE shutdown (no countdown, no save)." },
      ]},
      { title: "World — env & weather", icon: "🌍", commands: [
        { cmd: "env.time", args: "<hour>", desc: "Set time of day (0-24 decimal).", ex: "env.time 12" },
        { cmd: "env.progresstime", args: "true|false", desc: "Whether time advances naturally." },
        { cmd: "weather.clouds", args: "<0.0-1.0>", desc: "Override cloud cover." },
        { cmd: "weather.rain", args: "<0.0-1.0>", desc: "Override rain intensity." },
        { cmd: "weather.fog", args: "<0.0-1.0>", desc: "Override fog." },
        { cmd: "weather.wind", args: "<0.0-1.0>", desc: "Override wind." },
        { cmd: "weather.lightningchance", args: "<0.0-1.0>", desc: "Thunderstorm lightning frequency." },
        { cmd: "ai.sense_range", args: "<N>", desc: "Tune NPC sense range (default 40)." },
      ]},
      { title: "Entities — cleanup & spawn", icon: "🧹", commands: [
        { cmd: "server.removeDecayingEntities", desc: "Immediately remove all decaying entities." },
        { cmd: "server.stability", desc: "Re-calculate structural stability." },
        { cmd: "entities", desc: "Dump entity counts by type." },
        { cmd: "find_entity", args: "<prefab>", desc: "Search for specific entity prefab." },
        { cmd: "bradley.respawnDelayMinutes", args: "<N>", desc: "Tune APC respawn timer." },
        { cmd: "heli.strafe", desc: "Call a patrol helicopter now." },
      ]},
      { title: "Config & persistence", icon: "💾", commands: [
        { cmd: "server.saveinterval", args: "<seconds>", desc: "Autosave interval (default 600)." },
        { cmd: "server.secure", args: "true|false", desc: "VAC/Anti-cheat on/off." },
        { cmd: "server.maxplayers", args: "<N>", desc: "Slot count." },
        { cmd: "server.pve", args: "true|false", desc: "Toggle PvE mode." },
        { cmd: "decay.scale", args: "<N>", desc: "Structure decay multiplier (0 = off)." },
        { cmd: "craft.instant", args: "true|false", desc: "Instant-craft toggle." },
        { cmd: "event.run", desc: "Trigger a random event (crate drop, APC)." },
      ]},
    ],
  },

  "conan-exiles": {
    protocol: "Source RCON",
    tagline: "Conan Exiles admin RCON.",
    categories: [
      { title: "Players", icon: "👥", commands: [
        { cmd: "listplayers", desc: "List online players." },
        { cmd: "kickplayer", args: "<steamid>", desc: "Kick a player." },
        { cmd: "banplayer", args: "<steamid>", desc: "Ban a player." },
        { cmd: "unbanplayer", args: "<steamid>", desc: "Unban." },
      ]},
      { title: "Chat", icon: "💬", commands: [
        { cmd: "broadcast", args: "<message>", desc: "Broadcast a chat message." },
        { cmd: "servermsg", args: "<message>", desc: "Server system message (popup)." },
      ]},
      { title: "Server", icon: "⚙️", commands: [
        { cmd: "save", desc: "Force a world save." },
        { cmd: "shutdown", desc: "Graceful shutdown." },
        { cmd: "GetServerSetting", args: "<name>", desc: "Read a config value.", ex: "GetServerSetting ServerName" },
      ]},
    ],
  },

  "valheim": {
    protocol: "Stdio (write-only)",
    tagline: "Valheim's dedicated server only accepts write-style commands via stdio. Responses go to the log.",
    categories: [
      { title: "Players", icon: "👥", commands: [
        { cmd: "kick", args: "<name|steamid>", desc: "Kick a player." },
        { cmd: "ban", args: "<name|steamid>", desc: "Ban a player." },
        { cmd: "unban", args: "<name|steamid>", desc: "Unban." },
        { cmd: "banned", desc: "List banned players (logged, not returned here)." },
      ]},
      { title: "Server", icon: "⚙️", commands: [
        { cmd: "save", desc: "Force a world save." },
        { cmd: "ping", desc: "Health ping (logged)." },
        { cmd: "shutdown", desc: "Graceful shutdown." },
      ]},
    ],
  },
};

// Flat-list view used by autocomplete. Rebuilt lazily per module.
let consoleCmdFlat = null;
let consoleCmdFlatModule = "";
function getConsoleCommandFlat(moduleID) {
  if (consoleCmdFlatModule === moduleID && consoleCmdFlat) return consoleCmdFlat;
  consoleCmdFlatModule = moduleID;
  const reg = consoleCommandRegistry[moduleID];
  if (!reg) { consoleCmdFlat = []; return consoleCmdFlat; }
  consoleCmdFlat = [];
  for (const cat of reg.categories || []) {
    for (const c of cat.commands || []) consoleCmdFlat.push({ ...c, cat: cat.title });
  }
  return consoleCmdFlat;
}

let consoleAcIndex = -1;
let consoleAcCurrent = [];

function onConsoleInputChange(e) {
  const v = e.target.value;
  const caret = e.target.selectionStart;
  // Only autocomplete on the FIRST word (prefix-of-command).
  const upToCaret = v.slice(0, caret);
  if (/\s/.test(upToCaret)) { hideConsoleAutocomplete(); return; }
  const prefix = upToCaret.toLowerCase();
  if (!prefix) { hideConsoleAutocomplete(); return; }
  const i = allInstances.find(x => x.instance_id === instanceModalState.id);
  const moduleID = i && i.module_id;
  const all = getConsoleCommandFlat(moduleID);
  const hits = all.filter(c => c.cmd.toLowerCase().startsWith(prefix)).slice(0, 8);
  if (!hits.length) { hideConsoleAutocomplete(); return; }
  consoleAcCurrent = hits;
  consoleAcIndex = 0;
  renderConsoleAutocomplete();
}

function onConsoleInputKey(e) {
  const drop = $("#im-c-autocomplete");
  const open = drop && drop.classList.contains("on");
  if (open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
    e.preventDefault();
    consoleAcIndex += (e.key === "ArrowDown" ? 1 : -1);
    if (consoleAcIndex < 0) consoleAcIndex = consoleAcCurrent.length - 1;
    if (consoleAcIndex >= consoleAcCurrent.length) consoleAcIndex = 0;
    renderConsoleAutocomplete();
    return;
  }
  if (open && (e.key === "Tab" || (e.key === "Enter" && consoleAcIndex >= 0))) {
    if (e.key === "Tab" || consoleAcCurrent[consoleAcIndex]) {
      e.preventDefault();
      applyConsoleAutocomplete(consoleAcCurrent[consoleAcIndex]);
      return;
    }
  }
  if (open && e.key === "Escape") { hideConsoleAutocomplete(); return; }
  if (e.key === "Enter" && !open) { sendInstanceRCON(); return; }
}

function renderConsoleAutocomplete() {
  const drop = $("#im-c-autocomplete"); if (!drop) return;
  drop.innerHTML = consoleAcCurrent.map((c, i) => `
    <div class="cch-item ${i === consoleAcIndex ? 'sel' : ''}" data-cch-idx="${i}">
      <div><span class="cmd">${esc(c.cmd)}</span>${c.args ? `<span class="args">${esc(c.args)}</span>` : ''}</div>
      <div class="desc">${esc(c.desc || '')}</div>
    </div>
  `).join('');
  drop.classList.add("on");
  $$("#im-c-autocomplete [data-cch-idx]").forEach(el =>
    el.addEventListener("click", () => applyConsoleAutocomplete(consoleAcCurrent[Number(el.dataset.cchIdx)])));
}

function hideConsoleAutocomplete() {
  const drop = $("#im-c-autocomplete"); if (drop) drop.classList.remove("on");
  consoleAcIndex = -1; consoleAcCurrent = [];
}

function applyConsoleAutocomplete(c) {
  if (!c) return;
  const input = $("#im-c-cmd");
  // Replace the current first-token with the command, leave a trailing
  // space if it takes args so the user just starts typing.
  const current = input.value;
  const firstSpace = current.indexOf(" ");
  const rest = firstSpace >= 0 ? current.slice(firstSpace) : "";
  input.value = c.cmd + (c.args ? " " : "") + rest;
  hideConsoleAutocomplete();
  input.focus();
  // Position caret after the command (before any existing args).
  const pos = c.cmd.length + (c.args ? 1 : 0);
  input.setSelectionRange(pos, pos);
}

// ---- Commands drawer (📖 button) ----

function toggleConsoleHelpDrawer() {
  const drawer = $("#im-c-helpdrawer"); if (!drawer) return;
  if (drawer.classList.contains("on")) {
    drawer.classList.remove("on");
    drawer.innerHTML = "";
    return;
  }
  const i = allInstances.find(x => x.instance_id === instanceModalState.id);
  const moduleID = i && i.module_id;
  const reg = consoleCommandRegistry[moduleID];
  if (!reg) {
    drawer.innerHTML = `<div class="cch-empty">
      <div style="font-size:22px;margin-bottom:6px">📖</div>
      No command reference registered for <code>${esc(moduleID || "?")}</code> yet.
      <div class="dim" style="font-size:11.5px;margin-top:6px">The Console still works — this panel will populate when we add a registry for this module.</div>
    </div>`;
    drawer.classList.add("on");
    return;
  }
  renderConsoleHelpDrawer(reg);
  drawer.classList.add("on");
}

function renderConsoleHelpDrawer(reg) {
  const drawer = $("#im-c-helpdrawer");
  drawer.innerHTML = `
    <div class="cch-head">
      <input id="im-c-helpfilter" placeholder="Filter commands…" autocomplete="off" spellcheck="false">
      <span class="dim" style="font-size:11px">${esc(reg.protocol || '')}</span>
      <button class="small" id="im-c-helpclose" title="Close">✕</button>
    </div>
    ${reg.tagline ? `<div class="dim" style="padding:6px 14px;font-size:11.5px;line-height:1.5">${esc(reg.tagline)}</div>` : ''}
    <div id="im-c-helpbody"></div>
  `;
  paintConsoleHelpBody(reg, "");
  $("#im-c-helpclose").addEventListener("click", toggleConsoleHelpDrawer);
  $("#im-c-helpfilter").addEventListener("input", (e) => paintConsoleHelpBody(reg, e.target.value.toLowerCase().trim()));
  setTimeout(() => { const f = $("#im-c-helpfilter"); if (f) f.focus(); }, 60);
}

function paintConsoleHelpBody(reg, filter) {
  const body = $("#im-c-helpbody"); if (!body) return;
  const parts = [];
  for (const cat of reg.categories || []) {
    const matched = (cat.commands || []).filter(c =>
      !filter ||
      c.cmd.toLowerCase().includes(filter) ||
      (c.desc || '').toLowerCase().includes(filter) ||
      (c.args || '').toLowerCase().includes(filter)
    );
    if (!matched.length) continue;
    parts.push(`<div class="cch-cat">
      <h5>${esc(cat.icon || '')} ${esc(cat.title)}</h5>
      ${matched.map(c => `
        <div class="cch-row" data-cch-cmd="${esc(c.cmd)}" data-cch-args="${esc(c.args || '')}" title="Click to insert">
          <div style="min-width:160px;flex-shrink:0">
            <div><span class="mono">${esc(c.cmd)}</span></div>
            ${c.args ? `<div class="args">${esc(c.args)}</div>` : ''}
          </div>
          <div style="flex:1">
            <div class="desc">${esc(c.desc || '')}</div>
            ${c.ex ? `<code class="ex">${esc(c.ex)}</code>` : ''}
          </div>
        </div>`).join('')}
    </div>`);
  }
  body.innerHTML = parts.length
    ? parts.join('')
    : `<div class="cch-empty">No commands match "${esc(filter)}"</div>`;
  $$("#im-c-helpbody [data-cch-cmd]").forEach(row =>
    row.addEventListener("click", () => {
      applyConsoleAutocomplete({ cmd: row.dataset.cchCmd, args: row.dataset.cchArgs || "" });
      // Close drawer after selection so the user sees their input.
      const drawer = $("#im-c-helpdrawer"); if (drawer) drawer.classList.remove("on");
    }));
}
async function sendInstanceRCON() {
  const id = instanceModalState.id; const cmd = $("#im-c-cmd").value.trim();
  if (!id || !cmd) return;
  appendConsoleLine({ kind: "cmd", text: cmd });
  $("#im-c-send").disabled = true;
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ command: cmd }) });
    const data = await r.json();
    if (!r.ok) appendConsoleLine({ kind: "err", text: `${data.message || r.status}` });
    else if (data.error) { appendConsoleLine({ kind: "err", text: data.error }); if (data.output) appendConsoleLine({ kind: "reply", text: data.output }); }
    else appendConsoleLine({ kind: "reply", text: data.output || "(ok — no output)" });
    $("#im-c-cmd").value = "";
  } catch (err) { appendConsoleLine({ kind: "err", text: err.message }); }
  finally { $("#im-c-send").disabled = false; $("#im-c-cmd").focus(); }
}

// ---------------- FILES tab ----------------
//
// Path model:
//   - Modules with a single root (most of them): use relative paths against
//     the default root. Same as before.
//   - Modules with multiple roots (7DTD: saves + game): send CONTAINER-
//     ABSOLUTE paths so the agent knows which root to apply. The user
//     picks a root from the picker; `instanceModalState.rootPath` is that
//     root's absolute path; `instanceModalState.path` is the subpath
//     inside it.
function currentRootPath() {
  const i = allInstances.find(x => x.instance_id === instanceModalState.id);
  const roots = i ? rootsFor(i.module_id) : null;
  if (!roots) return "";                // single-root mode — relative paths
  return instanceModalState.rootPath || roots[0].path;
}
function buildFilePath(sub) {
  const root = currentRootPath();
  if (!root) return sub;                // single-root: relative
  return root + (sub ? "/" + sub : "");
}
function renderInstanceRootPicker() {
  const wrap = $("#im-f-roots");
  const i = allInstances.find(x => x.instance_id === instanceModalState.id);
  const roots = i ? rootsFor(i.module_id) : null;
  if (!roots) { wrap.style.display = "none"; return; }
  wrap.style.display = "";
  if (!instanceModalState.rootPath) instanceModalState.rootPath = roots[0].path;
  wrap.innerHTML = `<div class="fs-roots">
    <span class="label">View:</span>
    ${roots.map(r => `<button class="fs-root-btn${r.path === instanceModalState.rootPath ? ' active' : ''}" data-fs-root="${esc(r.path)}">
      <span class="name">${esc(r.name)}</span>
      ${r.hint ? `<span class="hint">${esc(r.hint)}</span>` : ""}
    </button>`).join("")}
  </div>`;
}
function renderInstanceCrumbs() {
  const el = $("#im-f-crumbs"); const id = instanceModalState.id;
  const parts = instanceModalState.path ? instanceModalState.path.split("/").filter(Boolean) : [];
  let path = "";
  const rootName = (() => {
    const i = allInstances.find(x => x.instance_id === id);
    const roots = i ? rootsFor(i.module_id) : null;
    if (!roots) return id;
    const r = roots.find(x => x.path === instanceModalState.rootPath);
    return r ? r.name : id;
  })();
  const crumbs = [`<a data-fspath="">${esc(rootName)}</a>`];
  for (const p of parts) { path = path ? path + "/" + p : p; crumbs.push(`<a data-fspath="${esc(path)}">${esc(p)}</a>`); }
  el.innerHTML = crumbs.join(`<span class="sep">/</span>`);
}
// --- File browser state (selection / current-listing cache / xhr handle).
// _fsEntries caches the last successful listing so the right-click menu can
// look up "is this a directory?" without re-fetching. _fsSelection tracks
// which subpaths are checked. _fsLastClicked enables shift-click-range.
let _fsEntries = [];
let _fsSelection = new Set();
let _fsLastClicked = null;
let _fsActiveUploadXHR = null;
let _fsUploadCancelled = false;

const _ARCHIVE_RE = /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tbz|tar\.xz|txz|7z|rar)$/i;
function _isArchiveName(name) { return _ARCHIVE_RE.test(name); }

async function loadInstanceFiles() {
  const id = instanceModalState.id; if (!id) return;
  // Show the "Install base mods" toolbar button only for 7DTD instances —
  // it's the only module whose mods come from the panel's basemods sidecar
  // (Allocs/PrismaCore). Hidden by default in the HTML.
  try {
    const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null;
    const btn = document.getElementById("im-f-basemods");
    if (btn) btn.style.display = (inst && inst.module_id === "7dtd") ? "" : "none";
  } catch {}
  renderInstanceRootPicker();
  renderInstanceCrumbs();
  const wrap = $("#im-f-wrap");
  wrap.innerHTML = loadingState("Loading…");
  // A folder change invalidates the current selection — paths belong to
  // the previous directory. Keep selections tied to a single dir for
  // sanity (matches Windows Explorer).
  _fsSelection.clear();
  _fsLastClicked = null;
  _fsRefreshSelBar();
  try {
    const q = new URLSearchParams({ path: buildFilePath(instanceModalState.path) });
    const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`);
    _fsEntries = entries || [];
    if (!_fsEntries.length) { wrap.innerHTML = `<div class="empty">(empty folder)</div>`; return; }
    _fsEntries.sort((a, b) => (a.is_dir !== b.is_dir ? (a.is_dir ? -1 : 1) : a.name.localeCompare(b.name)));
    const root = currentRootPath();
    const subFor = e => root && e.path.startsWith(root + "/") ? e.path.slice(root.length + 1)
                       : root && e.path === root ? ""
                       : e.path;
    wrap.innerHTML = `<table>
      <thead><tr>
        <th class="fs-chk"><input type="checkbox" id="im-f-chk-all" title="Select all"></th>
        <th>name</th><th>size</th><th>modified</th><th></th>
      </tr></thead>
      <tbody>${_fsEntries.map(e => {
        const icon = e.is_dir ? "📁" : iconForFile(e.name);
        const sub = subFor(e);
        const isArc = !e.is_dir && _isArchiveName(e.name);
        return `<tr data-entry="${esc(sub)}" data-dir="${e.is_dir ? 1 : 0}" data-name="${esc(e.name)}" data-arc="${isArc ? 1 : 0}">
          <td class="fs-chk"><input type="checkbox" class="fs-row-chk" data-sub="${esc(sub)}"></td>
          <td class="fs-name-cell">${icon} ${esc(e.name)}</td>
          <td class="dim">${e.is_dir ? "—" : esc(fmtBytes(e.size))}</td>
          <td class="dim">${esc(fmtTime(e.mod_time))}</td>
          <td class="fs-actions-cell">
            ${e.is_dir
              ? ``
              : `<button class="small" data-fsdownload="${esc(sub)}" data-name="${esc(e.name)}" title="Download">↓</button>`}
            <button class="small danger" data-fsdel="${esc(sub)}"${e.is_dir ? ' data-fsdir="1"' : ''} data-name="${esc(e.name)}" title="Delete">×</button>
          </td>
        </tr>`;
      }).join("")}</tbody>
    </table>`;
    // Re-apply selection visuals if any survived (intentionally cleared on
    // folder change above, but a re-render after action should re-mark).
    _fsApplySelectionVisuals();
  } catch (err) { wrap.innerHTML = errorState(`Error: ${err.message}`); }
}
$("#im-f-refresh").addEventListener("click", loadInstanceFiles);
// "Install base mods" — POSTs to /api/instances/{id}/install-base-mods.
// Confirms first; warns if the instance is running (the agent's sidecar
// will fail because the running game holds the Mods DLLs open). Async on
// the agent side — this returns 202 + a correlation id, the operator
// follows progress via the standard "updating" pulse on the card and the
// agent journal. Only enabled for 7DTD per the show/hide gate above.
$("#im-f-basemods").addEventListener("click", async () => {
  const id = instanceModalState.id; if (!id) return;
  const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null;
  if (inst && (inst.status === "running" || inst.status === "starting")) {
    if (!confirm(`${id} is currently ${inst.status}.\n\nInstalling base mods while the server is running will fail — the game holds Mods DLLs open. Stop the server first and retry. Proceed anyway?`)) return;
  } else {
    if (!confirm(`Re-install base mods on ${id}?\n\nWill download:\n  • Allocs Server Fixes (illy.bz)\n  • PrismaCore latest release (GitHub)\n\nExisting copies of those mods get backed up to ".bak-<UTC>" siblings — re-runs are reversible. TFP_* and xMarkers are shipped by SteamCMD and not touched. Custom mods (RefugeBot, AGF-VP, etc.) are not touched. ~30-60s.`)) return;
  }
  const btn = $("#im-f-basemods"); const orig = btn.textContent;
  btn.disabled = true; btn.textContent = "⏳ Installing…";
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/install-base-mods`, { method: "POST" });
    const d = await r.json().catch(() => ({}));
    if (!r.ok) { toast(d.message || `install-base-mods failed (${r.status})`, "err"); return; }
    toast("Base-mods install kicked — agent will finish in ~30-60s; refresh Files to see new dirs.", "ok");
  } catch (e) {
    toast("Error: " + (e.message || e), "err");
  } finally {
    btn.disabled = false; btn.textContent = orig;
  }
});

// ---- Selection helpers ----
function _fsRowSubs() {
  return Array.from(document.querySelectorAll("#im-f-wrap tr[data-entry]")).map(tr => tr.dataset.entry);
}
function _fsApplySelectionVisuals() {
  document.querySelectorAll("#im-f-wrap tr[data-entry]").forEach(tr => {
    const on = _fsSelection.has(tr.dataset.entry);
    tr.classList.toggle("selected", on);
    const cb = tr.querySelector(".fs-row-chk");
    if (cb) cb.checked = on;
  });
  const all = $("#im-f-chk-all");
  if (all) {
    const subs = _fsRowSubs();
    all.checked = subs.length > 0 && subs.every(s => _fsSelection.has(s));
    all.indeterminate = !all.checked && subs.some(s => _fsSelection.has(s));
  }
  _fsRefreshSelBar();
}
function _fsRefreshSelBar() {
  const bar = $("#im-f-selbar");
  const lbl = $("#im-f-selcount");
  if (!bar) return;
  const n = _fsSelection.size;
  if (n === 0) { bar.style.display = "none"; return; }
  bar.style.display = "inline-flex";
  lbl.textContent = n === 1 ? "1 selected" : `${n} selected`;
}
function _fsToggleSelection(sub, mode /* "single" | "toggle" | "range" */) {
  const subs = _fsRowSubs();
  if (mode === "range" && _fsLastClicked) {
    const a = subs.indexOf(_fsLastClicked);
    const b = subs.indexOf(sub);
    if (a >= 0 && b >= 0) {
      const [lo, hi] = a < b ? [a, b] : [b, a];
      for (let i = lo; i <= hi; i++) _fsSelection.add(subs[i]);
      _fsApplySelectionVisuals();
      return;
    }
  }
  if (mode === "single") {
    _fsSelection.clear();
    _fsSelection.add(sub);
  } else {
    if (_fsSelection.has(sub)) _fsSelection.delete(sub); else _fsSelection.add(sub);
  }
  _fsLastClicked = sub;
  _fsApplySelectionVisuals();
}

// ---- Upload (XHR for real progress) ----
$("#im-f-upload").addEventListener("click", () => $("#im-f-upload-input").click());
$("#im-f-upload-input").addEventListener("change", e => {
  const files = Array.from(e.target.files || []);
  e.target.value = "";
  if (files.length) _fsUploadFiles(files);
});

function _fsShowProgress(title, meta, pct) {
  const w = $("#im-f-prog");
  if (!w) return;
  w.style.display = "block";
  $("#im-f-prog-title").textContent = title;
  $("#im-f-prog-meta").textContent = meta;
  $("#im-f-prog-fill").style.width = (pct == null ? 0 : Math.max(0, Math.min(100, pct))) + "%";
}
function _fsHideProgress() {
  const w = $("#im-f-prog"); if (w) w.style.display = "none";
}

// Chunk size — must stay below any edge-proxy body cap on the panel's
// public hostname. Cloudflare free tier silently buffers/stalls bodies
// over ~100 MB, and even smaller-than-cap uploads can hang behind their
// proxy. 8 MiB chunks are well clear of every common limit and give
// good throughput across home WAN links.
const _FS_CHUNK_SIZE = 8 * 1024 * 1024;

function _fsNewUploadId() {
  // Browser UUIDs aren't always available (older Safari, some
  // privacy-locked Firefox) — fall back to crypto.getRandomValues.
  if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
  const b = new Uint8Array(16); crypto.getRandomValues(b);
  return Array.from(b, x => x.toString(16).padStart(2, "0")).join("");
}

async function _fsUploadFiles(files) {
  const id = instanceModalState.id; if (!id) return;
  const currentDir = instanceModalState.path || "";
  _fsUploadCancelled = false;
  let totalBytes = 0;
  for (const f of files) totalBytes += f.size;
  let bytesDone = 0;
  let i = 0;
  for (const f of files) {
    if (_fsUploadCancelled) break;
    i++;
    const sub = currentDir ? `${currentDir}/${f.name}` : f.name;
    const fpath = buildFilePath(sub);
    const uploadId = _fsNewUploadId();
    try {
      await _fsUploadOneChunked(id, f, fpath, uploadId, i, files.length, bytesDone, totalBytes);
      bytesDone += f.size;
      toast(`Uploaded ${f.name}`, "ok");
    } catch (err) {
      toast(`Upload failed (${f.name}): ${err.message}`, "err", 8000);
      break;
    }
  }
  _fsHideProgress();
  _fsActiveUploadXHR = null;
  loadInstanceFiles();
}

async function _fsUploadOneChunked(instanceId, file, fpath, uploadId, fileIdx, fileCount, batchBytesDone, batchTotalBytes) {
  const total = file.size;
  let offset = 0;
  // Empty file is legal — send a single zero-byte final chunk so the
  // agent still creates the destination.
  do {
    if (_fsUploadCancelled) throw new Error("cancelled");
    const end = Math.min(offset + _FS_CHUNK_SIZE, total);
    const chunk = file.slice(offset, end);
    const isFinal = end >= total;
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      _fsActiveUploadXHR = xhr;
      xhr.open("POST", `/api/instances/${encodeURIComponent(instanceId)}/files/upload-chunk?path=${encodeURIComponent(fpath)}`);
      xhr.setRequestHeader("Content-Type", "application/octet-stream");
      xhr.setRequestHeader("X-Upload-Id", uploadId);
      xhr.setRequestHeader("X-Chunk-Offset", String(offset));
      xhr.setRequestHeader("X-Total-Size", String(total));
      xhr.setRequestHeader("X-Is-Final", isFinal ? "1" : "0");
      xhr.upload.onprogress = ev => {
        if (!ev.lengthComputable) return;
        const sent = offset + ev.loaded;
        const overall = batchBytesDone + sent;
        const pct = (overall / Math.max(1, batchTotalBytes)) * 100;
        _fsShowProgress(
          `Uploading ${file.name} (${fileIdx}/${fileCount})`,
          `${fmtBytes(sent)} / ${fmtBytes(total)} · ${fmtBytes(overall)} of ${fmtBytes(batchTotalBytes)} total`,
          pct,
        );
      };
      xhr.onload  = () => {
        _fsActiveUploadXHR = null;
        if (xhr.status >= 200 && xhr.status < 300) resolve();
        else reject(new Error(`HTTP ${xhr.status}: ${(xhr.responseText || "").slice(0, 400) || xhr.statusText}`));
      };
      xhr.onerror = () => { _fsActiveUploadXHR = null; reject(new Error("network error")); };
      xhr.onabort = () => { _fsActiveUploadXHR = null; reject(new Error("cancelled")); };
      xhr.send(chunk);
    });
    offset = end;
  } while (offset < total);
}
$("#im-f-prog-cancel").addEventListener("click", () => {
  _fsUploadCancelled = true;
  if (_fsActiveUploadXHR) try { _fsActiveUploadXHR.abort(); } catch {}
});

// ---- New file / mkdir ----
$("#im-f-new").addEventListener("click", async () => {
  const id = instanceModalState.id; if (!id) return;
  const name = prompt("New file name (relative to current folder):", "notes.txt"); if (!name) return;
  const subpath = instanceModalState.path ? instanceModalState.path + "/" + name : name;
  const path = buildFilePath(subpath);
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ path, content: "" }) });
    toast(`Created ${name}`, "ok"); loadInstanceFiles(); openFileInEditor(subpath, name);
  } catch (err) { toast("Create failed: " + err.message, "err"); }
});
$("#im-f-mkdir").addEventListener("click", async () => {
  const id = instanceModalState.id; if (!id) return;
  const name = prompt("New folder name:", "new-folder"); if (!name) return;
  // We don't have a dedicated mkdir RPC, but FsWrite of a placeholder
  // file inside the new path implicitly creates the parents. Then we
  // remove the placeholder so the folder is empty.
  const placeholderSub = (instanceModalState.path ? instanceModalState.path + "/" : "") + name + "/.panel-keep";
  const placeholderPath = buildFilePath(placeholderSub);
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ path: placeholderPath, content: "" }),
    });
    toast(`Created folder ${name}`, "ok");
    loadInstanceFiles();
  } catch (err) { toast("Mkdir failed: " + err.message, "err"); }
});

// ---- Selection toolbar ----
$("#im-f-sel-clear").addEventListener("click", () => { _fsSelection.clear(); _fsLastClicked = null; _fsApplySelectionVisuals(); });
$("#im-f-sel-del").addEventListener("click", () => _fsBulkDelete(Array.from(_fsSelection)));
$("#im-f-sel-zip").addEventListener("click", () => _fsCompressPrompt(Array.from(_fsSelection)));
$("#im-f-sel-move").addEventListener("click", () => _fsMovePrompt(Array.from(_fsSelection)));

async function _fsBulkDelete(subs) {
  const id = instanceModalState.id; if (!id || !subs.length) return;
  const lookup = new Map(_fsEntries.map(e => {
    const root = currentRootPath();
    const s = root && e.path.startsWith(root + "/") ? e.path.slice(root.length + 1) : e.path;
    return [s, e];
  }));
  if (!confirm(`Delete ${subs.length} item${subs.length === 1 ? "" : "s"}? This cannot be undone.`)) return;
  let ok = 0, fail = 0;
  for (const sub of subs) {
    const e = lookup.get(sub);
    const isDir = e ? e.is_dir : false;
    const url = `/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(buildFilePath(sub))}${isDir ? "&recursive=true" : ""}`;
    try { await fetchJSON(url, { method: "DELETE" }); ok++; }
    catch (err) { fail++; toast(`Delete ${sub} failed: ${err.message}`, "err"); }
  }
  if (ok) toast(`Deleted ${ok} item${ok === 1 ? "" : "s"}`, fail ? "warn" : "ok");
  loadInstanceFiles();
}

async function _fsCompressPrompt(subs) {
  const id = instanceModalState.id; if (!id || !subs.length) return;
  const defaultName = subs.length === 1
    ? subs[0].split("/").pop().replace(/\.[^./]+$/, "") + ".zip"
    : "archive.zip";
  const out = prompt(`Compress ${subs.length} item${subs.length === 1 ? "" : "s"} into a .zip — destination filename (in current folder, or relative path):`, defaultName);
  if (!out) return;
  const dest = buildFilePath(instanceModalState.path ? instanceModalState.path + "/" + out : out);
  const sources = subs.map(s => buildFilePath(s));
  toast(`Compressing ${subs.length} item${subs.length === 1 ? "" : "s"}…`);
  _fsShowProgress(`Compressing into ${out}`, "agent is zipping…", null);
  try {
    const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/compress`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ sources, dest_zip_path: dest, overwrite: true }),
    });
    toast(`Zipped ${res.entries_written} entr${res.entries_written === 1 ? "y" : "ies"} into ${out}`, "ok");
    _fsSelection.clear();
    loadInstanceFiles();
  } catch (err) { toast("Compress failed: " + err.message, "err", 8000); }
  _fsHideProgress();
}

async function _fsExtractHere(sub, name) {
  await _fsExtractTo(sub, name, "");
}
async function _fsExtractToPrompt(sub, name) {
  const defaultDir = name.replace(/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tbz)$/i, "");
  const dest = prompt(`Extract "${name}" — destination folder (relative to current, blank = current):`, defaultDir);
  if (dest == null) return;
  await _fsExtractTo(sub, name, dest.trim());
}
async function _fsExtractTo(sub, name, destRel) {
  const id = instanceModalState.id; if (!id) return;
  const archivePath = buildFilePath(sub);
  const destDir = destRel ? buildFilePath(instanceModalState.path ? instanceModalState.path + "/" + destRel : destRel) : "";
  toast(`Extracting ${name}…`);
  _fsShowProgress(`Extracting ${name}`, "agent is unpacking…", null);
  try {
    const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/extract`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ archive_path: archivePath, dest_dir: destDir, overwrite: true }),
    });
    toast(`Extracted ${res.entries_written} entr${res.entries_written === 1 ? "y" : "ies"} (${fmtBytes(res.bytes_written)})`, "ok");
    loadInstanceFiles();
  } catch (err) { toast("Extract failed: " + err.message, "err", 8000); }
  _fsHideProgress();
}

async function _fsRenamePrompt(sub, name) {
  const id = instanceModalState.id; if (!id) return;
  const next = prompt(`Rename "${name}" to:`, name);
  if (!next || next === name) return;
  const dir = sub.includes("/") ? sub.slice(0, sub.lastIndexOf("/")) : "";
  const toSub = dir ? dir + "/" + next : next;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/rename`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ from_path: buildFilePath(sub), to_path: buildFilePath(toSub) }),
    });
    toast(`Renamed to ${next}`, "ok");
    loadInstanceFiles();
  } catch (err) { toast("Rename failed: " + err.message, "err"); }
}

// _fsMovePrompt — prompt for a destination folder (relative to the
// current root, or `..` to climb out) and call the rename RPC for each
// selected entry. The agent's FsRename does an actual `mv` and mkdir's
// the parent, so moving to a not-yet-existing folder works.
//
// Common case the user hit: a zip extracts to `nested/ModName/`, and
// they want to flatten it by moving everything in `nested/ModName/` up
// to the current dir. Solution: navigate into `nested/ModName/`, select
// all, Move → `..`.
async function _fsMovePrompt(subs) {
  const id = instanceModalState.id; if (!id || !subs.length) return;
  const cur = instanceModalState.path || "";
  const hint = cur ? `(current folder: ${cur} — use ".." to move up one level)` : `(at root — destination is relative to root)`;
  const destRel = prompt(`Move ${subs.length} item${subs.length === 1 ? "" : "s"} to which folder? ${hint}`, "..");
  if (destRel == null) return;
  const trimmed = destRel.trim();
  // Resolve destination relative to the current folder. "" / "." = current.
  // Leading "/" means relative-to-root.
  let destBase;
  if (trimmed === "" || trimmed === ".") destBase = cur;
  else if (trimmed.startsWith("/")) destBase = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
  else destBase = (cur ? cur + "/" : "") + trimmed.replace(/\/+$/, "");
  // Normalize "a/b/../c" → "a/c", and handle leading "../".
  const parts = [];
  for (const seg of destBase.split("/")) {
    if (seg === "" || seg === ".") continue;
    if (seg === "..") { parts.pop(); continue; }
    parts.push(seg);
  }
  destBase = parts.join("/");

  toast(`Moving ${subs.length} item${subs.length === 1 ? "" : "s"}…`);
  let ok = 0, fail = 0;
  for (const sub of subs) {
    const name = sub.split("/").pop();
    const toSub = destBase ? destBase + "/" + name : name;
    if (toSub === sub) { fail++; continue; } // would be a no-op
    try {
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/rename`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ from_path: buildFilePath(sub), to_path: buildFilePath(toSub) }),
      });
      ok++;
    } catch (err) { fail++; toast(`Move ${name} failed: ${err.message}`, "err"); }
  }
  if (ok) toast(`Moved ${ok} item${ok === 1 ? "" : "s"}${fail ? ` (${fail} failed)` : ""}`, fail ? "warn" : "ok");
  _fsSelection.clear();
  loadInstanceFiles();
}

// ---- Root picker / breadcrumb clicks ----
$("#im-f-roots").addEventListener("click", e => {
  const btn = e.target.closest("[data-fs-root]"); if (!btn) return;
  instanceModalState.rootPath = btn.dataset.fsRoot;
  instanceModalState.path = "";
  loadInstanceFiles();
});
$("#im-f-crumbs").addEventListener("click", e => {
  const link = e.target.closest("a[data-fspath]"); if (link) { e.preventDefault(); instanceModalState.path = link.dataset.fspath; loadInstanceFiles(); }
});

// ---- Row interactions ----
$("#im-f-wrap").addEventListener("click", async e => {
  const id = instanceModalState.id; if (!id) return;
  // Header "select all" toggle.
  if (e.target.id === "im-f-chk-all") {
    const all = e.target;
    const subs = _fsRowSubs();
    if (all.checked) subs.forEach(s => _fsSelection.add(s));
    else _fsSelection.clear();
    _fsApplySelectionVisuals();
    return;
  }
  // Per-row checkbox.
  const cb = e.target.closest(".fs-row-chk");
  if (cb) {
    e.stopPropagation();
    _fsToggleSelection(cb.dataset.sub, e.shiftKey ? "range" : "toggle");
    return;
  }
  // Action buttons (download / delete).
  const del = e.target.dataset.fsdel;
  if (del) {
    const isDir = e.target.dataset.fsdir === "1";
    const name = e.target.dataset.name || del;
    const msg = isDir
      ? `Delete folder "${name}" and EVERYTHING inside it?\n\nThis is a recursive rm -rf and cannot be undone.`
      : `Delete "${name}"?`;
    if (!confirm(msg)) return;
    const url = `/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(buildFilePath(del))}${isDir ? "&recursive=true" : ""}`;
    try { await fetchJSON(url, { method: "DELETE" }); toast(isDir ? `Deleted folder "${name}"` : "Deleted", "ok"); loadInstanceFiles(); }
    catch (err) { toast("Delete failed: " + err.message, "err"); }
    return;
  }
  const dl = e.target.dataset.fsdownload;
  if (dl) {
    const q = new URLSearchParams({ path: buildFilePath(dl) });
    const a = document.createElement("a");
    a.href = `/api/instances/${encodeURIComponent(id)}/files/download?${q}`;
    a.download = e.target.dataset.name || dl.split("/").pop();
    document.body.appendChild(a); a.click(); a.remove();
    return;
  }
  // Row body click — navigate (dir) or open editor (file). Ctrl/Shift +
  // click toggles selection without navigating, like Explorer.
  const row = e.target.closest("tr[data-entry]");
  if (row) {
    if (e.ctrlKey || e.metaKey) { _fsToggleSelection(row.dataset.entry, "toggle"); return; }
    if (e.shiftKey) { _fsToggleSelection(row.dataset.entry, "range"); return; }
    const sub = row.dataset.entry, isDir = row.dataset.dir === "1";
    if (isDir) { instanceModalState.path = sub; loadInstanceFiles(); }
    else { openFileInEditor(sub, row.dataset.name); }
  }
});

// ---- Right-click context menu ----
$("#im-f-wrap").addEventListener("contextmenu", e => {
  const row = e.target.closest("tr[data-entry]");
  if (!row) return;
  e.preventDefault();
  const sub = row.dataset.entry, name = row.dataset.name;
  const isDir = row.dataset.dir === "1";
  const isArchive = row.dataset.arc === "1";
  // Selecting with right-click feels natural — if the row isn't already
  // in the selection, replace it. Multiple-selected items keep their set.
  if (!_fsSelection.has(sub)) {
    _fsSelection.clear();
    _fsSelection.add(sub);
    _fsLastClicked = sub;
    _fsApplySelectionVisuals();
  }
  _fsOpenCtxMenu(e.clientX, e.clientY, { sub, name, isDir, isArchive });
});
function _fsOpenCtxMenu(x, y, ctx) {
  const menu = $("#im-f-ctx"); if (!menu) return;
  const multi = _fsSelection.size > 1;
  const items = [];
  if (multi) {
    items.push({ k: "move-many", icon: "➡", label: `Move ${_fsSelection.size} items to…` });
    items.push({ k: "zip-many",  icon: "📦", label: `Compress ${_fsSelection.size} items to .zip…` });
    items.push({ sep: true });
    items.push({ k: "del-many",  icon: "🗑️", label: `Delete ${_fsSelection.size} items`, danger: true });
  } else {
    items.push({ k: "rename",   icon: "✎",  label: "Rename…" });
    items.push({ k: "move",     icon: "➡",  label: "Move to…" });
    items.push({ sep: true });
    if (!ctx.isDir) {
      items.push({ k: "open",     icon: "📝", label: "Open in editor" });
      items.push({ k: "download", icon: "↓",  label: "Download" });
    }
    if (ctx.isArchive) {
      items.push({ sep: true });
      items.push({ k: "extract-here", icon: "📂", label: "Extract here" });
      items.push({ k: "extract-to",   icon: "📂", label: "Extract to…" });
    }
    items.push({ sep: true });
    items.push({ k: "zip",      icon: "📦", label: "Compress to .zip…" });
    items.push({ k: "del",      icon: "🗑️", label: "Delete", danger: true });
  }
  menu.innerHTML = items.map((it, i, arr) => {
    if (it.sep) {
      if (i === 0 || i === arr.length - 1) return "";
      if (arr[i - 1]?.sep) return "";
      return `<div class="fs-ctx-sep"></div>`;
    }
    return `<div class="fs-ctx-item${it.danger ? " danger" : ""}" data-act="${esc(it.k)}"><span class="fs-ctx-icn">${it.icon}</span>${esc(it.label)}</div>`;
  }).join("");
  menu.style.display = "block";
  // Position, then nudge if it would overflow the viewport edges.
  menu.style.left = x + "px"; menu.style.top  = y + "px";
  const rect = menu.getBoundingClientRect();
  if (rect.right  > window.innerWidth)  menu.style.left = Math.max(0, window.innerWidth  - rect.width  - 8) + "px";
  if (rect.bottom > window.innerHeight) menu.style.top  = Math.max(0, window.innerHeight - rect.height - 8) + "px";
  menu.dataset.ctx = JSON.stringify(ctx);
}
function _fsCloseCtxMenu() { const m = $("#im-f-ctx"); if (m) m.style.display = "none"; }
document.addEventListener("click", e => { if (!e.target.closest("#im-f-ctx")) _fsCloseCtxMenu(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") _fsCloseCtxMenu(); });
$("#im-f-ctx").addEventListener("click", async e => {
  const it = e.target.closest(".fs-ctx-item"); if (!it) return;
  const act = it.dataset.act;
  const menu = $("#im-f-ctx");
  const ctx = JSON.parse(menu.dataset.ctx || "{}");
  _fsCloseCtxMenu();
  switch (act) {
    case "open":          openFileInEditor(ctx.sub, ctx.name); break;
    case "download":      {
      const q = new URLSearchParams({ path: buildFilePath(ctx.sub) });
      const a = document.createElement("a");
      a.href = `/api/instances/${encodeURIComponent(instanceModalState.id)}/files/download?${q}`;
      a.download = ctx.name;
      document.body.appendChild(a); a.click(); a.remove();
      break;
    }
    case "extract-here":  _fsExtractHere(ctx.sub, ctx.name); break;
    case "extract-to":    _fsExtractToPrompt(ctx.sub, ctx.name); break;
    case "zip":           _fsCompressPrompt([ctx.sub]); break;
    case "zip-many":      _fsCompressPrompt(Array.from(_fsSelection)); break;
    case "rename":        _fsRenamePrompt(ctx.sub, ctx.name); break;
    case "move":          _fsMovePrompt([ctx.sub]); break;
    case "move-many":     _fsMovePrompt(Array.from(_fsSelection)); break;
    case "del":           _fsBulkDelete([ctx.sub]); break;
    case "del-many":      _fsBulkDelete(Array.from(_fsSelection)); break;
  }
});

// ---- Drag & drop upload ----
(() => {
  const pane  = $("#im-f-pane");
  const droppy = $("#im-f-droppy");
  if (!pane || !droppy) return;
  let dragDepth = 0;
  // Only react to drags that carry actual files — internal drags (text
  // selections, dragged buttons) shouldn't trip the overlay.
  const hasFiles = ev => Array.from(ev.dataTransfer?.types || []).includes("Files");
  pane.addEventListener("dragenter", ev => {
    if (!hasFiles(ev)) return;
    ev.preventDefault();
    dragDepth++;
    droppy.style.display = "flex";
  });
  pane.addEventListener("dragover", ev => {
    if (!hasFiles(ev)) return;
    ev.preventDefault();
    ev.dataTransfer.dropEffect = "copy";
  });
  pane.addEventListener("dragleave", ev => {
    if (!hasFiles(ev)) return;
    dragDepth = Math.max(0, dragDepth - 1);
    if (dragDepth === 0) droppy.style.display = "none";
  });
  pane.addEventListener("drop", ev => {
    if (!hasFiles(ev)) return;
    ev.preventDefault();
    dragDepth = 0;
    droppy.style.display = "none";
    const files = Array.from(ev.dataTransfer.files || []);
    if (files.length) _fsUploadFiles(files);
  });
})();

// ---------------- BACKUPS tab ----------------
// =============== BACKUPS TAB ===============
// Card-based list with explicit Restore/Delete buttons, animated progress
// during backup (SSE-fed activity line + indeterminate bar), and an
// "Auto: every X" dropdown that creates a backup-action schedule under
// the hood (no separate UI required — it just appears in the Schedules
// tab as well).
const _bkAutoCronMap = {
  "off": null,
  "6h":  "0 0 */6 * * *",
  "12h": "0 0 */12 * * *",
  "24h": "0 0 4 * * *",   // 4am daily — least disruptive
  "7d":  "0 0 4 * * 0",   // Sunday 4am
};
const _bkCronToOption = {
  "0 0 */6 * * *": "6h",
  "0 0 */12 * * *": "12h",
  "0 0 4 * * *": "24h",
  "0 0 4 * * 0": "7d",
};

// Auto-save cron map (ARK SaveWorld over RCON). Sub-hour cadences for
// world snapshotting on busy servers. Cron is 6-field (sec min hr dom mon dow).
const _bkAutoSaveCronMap = {
  "off":  null,
  "15m":  "0 */15 * * * *",
  "30m":  "0 */30 * * * *",
  "60m":  "0 0 * * * *",
};
const _bkAutoSaveCronToOption = {
  "0 */15 * * * *": "15m",
  "0 */30 * * * *": "30m",
  "0 0 * * * *":    "60m",
};

let _bkAutoSchedule = null;     // cached schedule row (auto-backup)
let _bkAutoSaveSchedule = null; // cached schedule row (ARK SaveWorld)
let _bkProgressTimer = null;    // elapsed-time updater
let _bkProgressUnsub = null;    // unsubscribe fn for backup log lines (shared SSE hub)
let _bkProgressStart = 0;

function fmtAgo(ts) {
  if (!ts) return "";
  const t = Date.parse(ts);
  if (isNaN(t)) return "";
  const diff = Date.now() - t;
  const sec = Math.max(0, Math.floor(diff / 1000));
  if (sec < 60) return `${sec}s ago`;
  const min = Math.floor(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `${hr}h ago`;
  const d = Math.floor(hr / 24);
  return `${d}d ago`;
}

// ---- Region Health (7DTD .7rg corruption detect + heal) ----
async function loadRegionHealth() {
  const id = instanceModalState.id; const wrap = $("#im-rh-wrap"); if (!id || !wrap) return;
  wrap.innerHTML = loadingState("Scanning regions…");
  let data;
  try { data = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health`); }
  catch (err) { wrap.innerHTML = `<div class="empty"><span class="dim">⚠ Couldn't scan: ${esc(err.message||err)}</span></div>`; return; }
  const modeSel = $("#im-rh-mode"); if (modeSel && data.mode) modeSel.value = data.mode;
  const regions = data.regions || [];
  if (!regions.length) { wrap.innerHTML = `<div class="empty"><div class="big">${EMPTY_ICONS.health}</div>All regions healthy<div class="dim" style="font-size:12px;margin-top:4px">No corruption detected in the active world.</div></div>`; return; }
  wrap.innerHTML = regions.map(rg => `
    <div class="bk-card" style="display:flex;align-items:center;gap:12px;justify-content:space-between;padding:12px 14px;margin-bottom:8px;border-radius:var(--radius);background:rgba(255,255,255,0.03)">
      <div>
        <div style="font-weight:600;font-family:var(--mono,monospace)">${esc(rg.name)} ${rg.file_corrupt ? '<span style="color:#ff6b6b">●</span>' : '<span style="color:#ffd54f">●</span>'}</div>
        <div class="dim" style="font-size:12px">${esc(rg.issue||"")}</div>
      </div>
      <button class="small primary" data-rhheal="${esc(rg.name)}" title="Restore this region from the newest clean backup">🩹 Heal</button>
    </div>`).join("");
}
document.addEventListener("click", async (e) => {
  const el = e.target && e.target.closest ? e.target.closest("[data-rhheal]") : null; if (!el) return;
  const id = instanceModalState.id; if (!id) return;
  const region = el.dataset.rhheal;
  if (!confirm(`Heal region ${region}? This STOPS the server, swaps the region from the newest clean backup, then restarts it.`)) return;
  el.disabled = true; const orig = el.textContent; el.textContent = "Healing…";
  try {
    const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health/${encodeURIComponent(region)}/heal`, {method:"POST"});
    toast(`Healed ${res.region} from ${res.source_backup}`, "ok"); loadRegionHealth();
  } catch (err) { toast("Heal failed: " + (err.message||err), "err"); el.disabled = false; el.textContent = orig; }
});
document.addEventListener("change", async (e) => {
  if (!e.target || e.target.id !== "im-rh-mode") return;
  const id = instanceModalState.id; if (!id) return;
  try { await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health/mode`, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({mode: e.target.value})}); toast(e.target.value === "auto" ? "Auto-heal enabled" : "Alert-only mode", "ok"); }
  catch (err) { toast("Couldn't set mode: " + (err.message||err), "err"); }
});
document.addEventListener("click", (e) => { if (e.target && e.target.id === "im-rh-refresh") loadRegionHealth(); });

async function loadInstanceBackups() {
  const id = instanceModalState.id; const wrap = $("#im-b-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Loading backups…");
  // Fetch backups + the instance's schedules in parallel — schedules
  // tell us if an auto-backup is configured so we can populate the
  // dropdown.
  let backups = [], schedules = [];
  try {
    const [b, s] = await Promise.all([
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`).catch(() => ({backups: []})),
      fetchJSON(`/api/schedules?instance_id=${encodeURIComponent(id)}`).catch(() => ({schedules: []})),
    ]);
    backups = b.backups || [];
    schedules = s.schedules || [];
  } catch (err) {
    wrap.innerHTML = `<div class="empty"><div class="big">⚠</div>Couldn't load backups<span class="hint">${esc(err.message)}</span></div>`;
    return;
  }

  // Find the auto-backup schedule (action.type === "backup") and the
  // auto-save schedule (action.type === "rcon" + command === "SaveWorld",
  // tagged in description with the "Auto-save" prefix so we don't pick up
  // hand-rolled SaveWorld schedules the operator made themselves).
  _bkAutoSchedule = null;
  _bkAutoSaveSchedule = null;
  for (const s of schedules) {
    let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = {}; }
    if (!act || s.trigger_kind !== "cron") continue;
    if (!_bkAutoSchedule && act.type === "backup") { _bkAutoSchedule = s; continue; }
    if (!_bkAutoSaveSchedule
        && act.type === "rcon"
        && (act.command || "").toLowerCase() === "saveworld") {
      const desc = (s.description || "").toLowerCase();
      // Accept either the current label ("auto saveworld …") or the
      // older "auto-save …" prefix from the first cut, so dropdowns from
      // a previous deploy still get re-detected correctly.
      if (desc.startsWith("auto saveworld") || desc.startsWith("auto-save")) {
        _bkAutoSaveSchedule = s;
        continue;
      }
    }
  }
  const sel = $("#im-b-auto");
  if (sel) {
    const opt = _bkAutoSchedule ? (_bkCronToOption[_bkAutoSchedule.cron_spec] || "off") : "off";
    sel.value = opt;
    sel.parentElement.classList.toggle("active", opt !== "off");
  }
  // ARK-only auto-save dropdown — visibility + selection state.
  const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null;
  const isArkSA = inst && inst.module_id === "ark-sa";
  const saveWrap = $("#im-b-autosave-wrap");
  if (saveWrap) saveWrap.style.display = isArkSA ? "" : "none";
  const saveSel = $("#im-b-autosave");
  if (saveSel) {
    const sopt = _bkAutoSaveSchedule ? (_bkAutoSaveCronToOption[_bkAutoSaveSchedule.cron_spec] || "off") : "off";
    saveSel.value = sopt;
    saveSel.parentElement.classList.toggle("active", sopt !== "off");
  }

  // Card list
  if (!backups.length) {
    wrap.innerHTML = `
      ${_bkSmartNoteHTML(id)}
      <div class="empty"><div class="big">${EMPTY_ICONS.disk}</div>No backups yet<span class="hint">Click <b>Back up now</b> above to create your first snapshot.</span></div>
    `;
    return;
  }
  // Sort newest first.
  backups.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
  const cardsHTML = backups.map(b => {
    const isAuto = (b.description || "").toLowerCase().startsWith("auto-backup")
                || (b.description || "").toLowerCase() === "auto-backup";
    return `<div class="bk-card">
      <div class="bk-card-info">
        <div class="bk-card-when">
          ${esc(fmtTime(b.created_at))}
          <span class="ago">${esc(fmtAgo(b.created_at))}</span>
        </div>
        <div class="bk-card-meta">
          <span class="size">${esc(fmtBytes(b.size_bytes))}</span>
          ${isAuto ? `<span class="auto-tag">Auto</span>` : ""}
          ${b.description && !isAuto ? `<span class="desc">${esc(b.description)}</span>` : ""}
          <span class="id" title="Backup ID">${esc(b.id)}</span>
        </div>
      </div>
      <div class="bk-card-actions">
        <button class="bk-browse" data-bkbrowse="${esc(b.id)}" data-bkmeta='${esc(JSON.stringify({ created_at: b.created_at, size: b.size_bytes, desc: b.description||"" }))}' title="Browse the contents of this backup without restoring">📂 Browse</button>
        <button class="bk-restore" data-bkrestore="${esc(b.id)}" data-bkmeta='${esc(JSON.stringify({ created_at: b.created_at, size: b.size_bytes, desc: b.description||"" }))}' title="Restore this backup">↻ Restore</button>
        <button class="bk-del" data-bkdel="${esc(b.id)}" title="Remove this backup from the list">🗑</button>
      </div>
    </div>`;
  }).join("");
  wrap.innerHTML = `${_bkSmartNoteHTML(id)}<div class="bk-card-list">${cardsHTML}</div>`;
}

function _bkSmartNoteHTML(id) {
  const inst = allInstances.find(x => x.instance_id === id);
  const mod = inst && (allModules.find(m => m.id === inst.module_id) || {});
  const moduleName = mod ? mod.name || inst.module_id : "this server";
  return `<div class="bk-smart-note">
    <span class="ic">🧠</span>
    <div>
      <strong>Intelligent backups.</strong> Only saves, configs, and world data are archived for ${esc(moduleName)} — the bulky game install is excluded because it rebuilds from SteamCMD on next start. Tarballs typically stay in the MB to low-GB range; restore overwrites the same paths in place.
    </div>
  </div>`;
}

$("#im-b-refresh").addEventListener("click", loadInstanceBackups);

// Backup-now flow with animated progress.
$("#im-b-create").addEventListener("click", async (e) => {
  const id = instanceModalState.id; if (!id) return;
  // Optional description — small inline prompt; users who skip it get an
  // empty desc which renders as "manual" in the card list.
  const desc = prompt("Backup description (optional, e.g. 'before mod update'):", "") || "";
  _bkStartProgress(id, desc);
  e.target.disabled = true;
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ description: desc }),
    });
    _bkStopProgress();
    toast(`Backup complete — ${fmtBytes(r.size_bytes)}`, "ok");
    loadInstanceBackups();
  } catch (err) {
    _bkStopProgress();
    toast(`Backup failed: ${err.message}`, "err");
  } finally {
    e.target.disabled = false;
  }
});

function _bkStartProgress(instanceID, desc) {
  const wrap = $("#im-b-progress");
  const titleEl = $("#im-b-progress-title");
  const elapsed = $("#im-b-progress-elapsed");
  const line = $("#im-b-progress-line");
  if (!wrap) return;
  wrap.style.display = "";
  titleEl.textContent = `Backing up ${instanceID}${desc ? ` — "${desc}"` : ""}…`;
  line.textContent = "starting sidecar…";
  _bkProgressStart = Date.now();
  if (_bkProgressTimer) clearInterval(_bkProgressTimer);
  _bkProgressTimer = setInterval(() => {
    const sec = Math.floor((Date.now() - _bkProgressStart) / 1000);
    elapsed.textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
  }, 1000);
  // Subscribe to SSE; show the latest log line whose stream is "backup".
  try { if (_bkProgressUnsub) _bkProgressUnsub(); } catch {}
  _bkProgressUnsub = sseSubscribeInstance(instanceID, (e) => {
    try {
      if (e.log && e.log.stream === "backup" && e.log.line) {
        const t = String(e.log.line).trim();
        if (t) line.textContent = t.length > 90 ? "…" + t.slice(-90) : t;
      }
    } catch {}
  });
}
function _bkStopProgress() {
  const wrap = $("#im-b-progress");
  if (wrap) wrap.style.display = "none";
  if (_bkProgressTimer) { clearInterval(_bkProgressTimer); _bkProgressTimer = null; }
  if (_bkProgressUnsub) { try { _bkProgressUnsub(); } catch {} _bkProgressUnsub = null; }
}

// Restore + delete handlers — friendlier confirm dialogs.
$("#im-b-wrap").addEventListener("click", async (e) => {
  const id = instanceModalState.id; if (!id) return;
  const bEl = e.target.closest("[data-bkbrowse]");
  if (bEl) {
    let meta = {}; try { meta = JSON.parse(bEl.dataset.bkmeta || "{}"); } catch {}
    openBackupBrowser(id, bEl.dataset.bkbrowse, meta);
    return;
  }
  const rEl = e.target.closest("[data-bkrestore]");
  const dEl = e.target.closest("[data-bkdel]");
  if (rEl) {
    let meta = {};
    try { meta = JSON.parse(rEl.dataset.bkmeta || "{}"); } catch {}
    const inst = allInstances.find(x => x.instance_id === id);
    const isRunning = inst && inst.status === "running";
    const lines = [
      `Restore "${id}" from this backup?`,
      "",
      `📅 Captured: ${fmtTime(meta.created_at)} (${fmtAgo(meta.created_at)})`,
      `💾 Size: ${fmtBytes(meta.size || 0)}`,
      meta.desc ? `📝 Description: ${meta.desc}` : null,
      "",
      "⚠ This will OVERWRITE the current saves / configs / world data.",
      isRunning ? "⚠ The server is running — STOP it first to avoid corrupting state." : null,
    ].filter(Boolean);
    if (!confirm(lines.join("\n"))) return;
    rEl.disabled = true; toast(`Restoring "${id}"… (may take a minute)`);
    try {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(rEl.dataset.bkrestore)}/restore`, { method: "POST" });
      toast(`Restored ${fmtBytes(r.bytes_restored)} — start the server to use it`, "ok");
      loadInstanceBackups();
    } catch (err) {
      toast("Restore failed: " + err.message, "err");
    } finally { rEl.disabled = false; }
  } else if (dEl) {
    if (!confirm("Remove this backup from the panel's list?\n\nNote: the tarball file on the agent's disk is not deleted automatically — only the DB row is removed.")) return;
    try {
      await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(dEl.dataset.bkdel)}`, { method: "DELETE" });
      toast("Removed", "ok");
      loadInstanceBackups();
    } catch (err) {
      toast("Remove failed: " + err.message, "err");
    }
  }
});

// Auto-backup dropdown — creates / updates / deletes a "backup" action
// schedule for this instance. Only one auto-backup schedule per instance.
$("#im-b-auto").addEventListener("change", async (e) => {
  const id = instanceModalState.id; if (!id) return;
  const opt = e.target.value;
  const cron = _bkAutoCronMap[opt];
  // Always delete any existing auto-backup schedule first.
  if (_bkAutoSchedule) {
    try { await fetchJSON(`/api/schedules/${encodeURIComponent(_bkAutoSchedule.id)}`, { method: "DELETE" }); } catch {}
    _bkAutoSchedule = null;
  }
  if (!cron) {
    toast("Auto-backup disabled", "ok");
    e.target.parentElement.classList.remove("active");
    return;
  }
  const labels = { "6h":"every 6 hours", "12h":"every 12 hours", "24h":"daily at 4am", "7d":"weekly (Sunday 4am)" };
  try {
    await fetchJSON("/api/schedules", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({
        instance_id: id,
        description: `Auto-backup ${labels[opt] || opt}`,
        trigger_kind: "cron",
        cron_spec: cron,
        action: { type: "backup", description: "Auto-backup" },
        enabled: true,
      }),
    });
    toast(`Auto-backup scheduled ${labels[opt] || opt}`, "ok");
    e.target.parentElement.classList.add("active");
    loadInstanceBackups();
  } catch (err) {
    toast("Schedule failed: " + err.message, "err");
    e.target.value = "off";
    e.target.parentElement.classList.remove("active");
  }
});

// Auto-save dropdown — fires `SaveWorld` over RCON on a recurring cron.
// Mirrors the auto-backup dropdown's pattern: only one auto-save schedule
// per instance, identified by its description prefix + rcon SaveWorld
// command. ARK-only (the dropdown wrapper is hidden for non-ARK modules).
$("#im-b-autosave").addEventListener("change", async (e) => {
  const id = instanceModalState.id; if (!id) return;
  const opt = e.target.value;
  const cron = _bkAutoSaveCronMap[opt];
  if (_bkAutoSaveSchedule) {
    try { await fetchJSON(`/api/schedules/${encodeURIComponent(_bkAutoSaveSchedule.id)}`, { method: "DELETE" }); } catch {}
    _bkAutoSaveSchedule = null;
  }
  if (!cron) {
    toast("Auto SaveWorld disabled", "ok");
    e.target.parentElement.classList.remove("active");
    return;
  }
  const labels = { "15m":"every 15 min", "30m":"every 30 min", "60m":"hourly" };
  try {
    await fetchJSON("/api/schedules", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({
        instance_id: id,
        description: `Auto SaveWorld ${labels[opt] || opt}`,
        trigger_kind: "cron",
        cron_spec: cron,
        action: { type: "rcon", command: "SaveWorld" },
        enabled: true,
      }),
    });
    toast(`SaveWorld will fire ${labels[opt] || opt}`, "ok");
    e.target.parentElement.classList.add("active");
    loadInstanceBackups();
  } catch (err) {
    toast("Schedule failed: " + err.message, "err");
    e.target.value = "off";
    e.target.parentElement.classList.remove("active");
  }
});

// =============== BACKUP BROWSER ===============
// Drill into a tarball without restoring it: tree on the left, content
// pane on the right. Tree comes from /entries (one shot — agent reads
// the tar header and ships back every path). Each file click hits
// /file?path=... which streams the entry's bytes; text files render
// inline, binaries get a Download button.
const _bkbState = {
  id: null,
  bkpId: null,
  meta: null,
  entries: [],          // raw flat list from API
  tree: null,           // built tree: { name, path, isDir, size, children: [] }
  expanded: new Set(),  // dir paths the user has opened
  selected: null,       // currently-viewed file path
  filter: "",
};

function openBackupBrowser(instanceID, bkpID, meta) {
  _bkbState.id = instanceID;
  _bkbState.bkpId = bkpID;
  _bkbState.meta = meta || {};
  _bkbState.entries = [];
  _bkbState.tree = null;
  _bkbState.expanded = new Set();
  _bkbState.selected = null;
  _bkbState.filter = "";
  $("#bkb-title").textContent = `Backup contents`;
  const m = _bkbState.meta;
  $("#bkb-meta").textContent = [
    m.created_at ? fmtTime(m.created_at) : "",
    m.size ? fmtBytes(m.size) : "",
    m.desc ? `"${m.desc}"` : "",
  ].filter(Boolean).join(" · ");
  $("#bkb-tree-rows").innerHTML = `<div class="bkb-loading">Loading entries…</div>`;
  $("#bkb-content").innerHTML = `<div class="bkb-empty"><span class="icon">📂</span><div>Click a file in the tree to view its contents.</div></div>`;
  $("#bkb-filter").value = "";
  showModal("#bk-browser-modal");
  fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/backups/${encodeURIComponent(bkpID)}/entries`)
    .then(res => {
      _bkbState.entries = res.entries || [];
      _bkbState.tree = _bkbBuildTree(_bkbState.entries);
      // Auto-expand the first level so the operator sees the structure
      // immediately — most game backups have a single top-level folder
      // (Saves/, world/, mods/) anyway.
      if (_bkbState.tree.children.length === 1 && _bkbState.tree.children[0].isDir) {
        _bkbState.expanded.add(_bkbState.tree.children[0].path);
      }
      _bkbRenderTree();
    })
    .catch(err => {
      $("#bkb-tree-rows").innerHTML = `<div class="bkb-empty"><span class="icon">⚠</span><div>Couldn't load entries</div><span class="dim">${esc(err.message)}</span></div>`;
    });
}

function _bkbBuildTree(entries) {
  const root = { name: "", path: "", isDir: true, size: 0, children: [] };
  // Index dirs by path so we can attach children even when the
  // directory header itself isn't an explicit entry (some tarballs
  // omit them).
  const dirs = new Map();
  dirs.set("", root);
  function ensureDir(path) {
    if (dirs.has(path)) return dirs.get(path);
    const slash = path.lastIndexOf("/");
    const parent = ensureDir(slash >= 0 ? path.slice(0, slash) : "");
    const node = { name: slash >= 0 ? path.slice(slash + 1) : path, path, isDir: true, size: 0, children: [] };
    parent.children.push(node);
    dirs.set(path, node);
    return node;
  }
  for (const e of entries) {
    const p = (e.path || "").replace(/\/+$/, "");
    if (!p) continue;
    if (e.is_dir) { ensureDir(p); continue; }
    const slash = p.lastIndexOf("/");
    const parent = ensureDir(slash >= 0 ? p.slice(0, slash) : "");
    parent.children.push({ name: slash >= 0 ? p.slice(slash + 1) : p, path: p, isDir: false, size: e.size || 0 });
  }
  // Sort: dirs first, then files, alphabetical within each group.
  function sortRec(node) {
    node.children.sort((a, b) => {
      if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
      return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
    });
    for (const c of node.children) if (c.isDir) sortRec(c);
  }
  sortRec(root);
  return root;
}

function _bkbRenderTree() {
  const rows = [];
  const filter = (_bkbState.filter || "").toLowerCase();
  function walk(node, depth) {
    for (const c of node.children) {
      // When filtering, hide subtrees with no matching descendant —
      // but keep ancestors of matches visible. Pre-compute per node.
      if (filter && !_bkbNodeMatches(c, filter)) continue;
      const isOpen = !c.isDir ? false : (filter ? true : _bkbState.expanded.has(c.path));
      const isSel = c.path === _bkbState.selected;
      const sizeLabel = c.isDir ? "" : fmtBytes(c.size);
      const icon = c.isDir ? "📁" : _bkbFileIcon(c.name);
      rows.push(`<div class="bkb-row${c.isDir ? " dir" : ""}${isSel ? " selected" : ""}" data-bkpath="${esc(c.path)}" data-bkdir="${c.isDir ? "1" : "0"}" style="padding-left:${6 + depth*14}px">
        <span class="bkb-twist${isOpen ? " open" : ""}">${c.isDir ? "▸" : ""}</span>
        <span class="bkb-icon">${icon}</span>
        <span class="bkb-name" title="${esc(c.path)}">${esc(c.name)}</span>
        <span class="bkb-size">${sizeLabel}</span>
      </div>`);
      if (c.isDir && isOpen) walk(c, depth + 1);
    }
  }
  walk(_bkbState.tree || { children: [] }, 0);
  if (!rows.length) {
    $("#bkb-tree-rows").innerHTML = `<div class="bkb-empty"><div>${filter ? "No matches" : "Empty backup"}</div></div>`;
    return;
  }
  $("#bkb-tree-rows").innerHTML = rows.join("");
}

function _bkbNodeMatches(node, filter) {
  if (node.path.toLowerCase().includes(filter)) return true;
  if (node.isDir) {
    for (const c of node.children) if (_bkbNodeMatches(c, filter)) return true;
  }
  return false;
}

function _bkbFileIcon(name) {
  const ext = (name.split(".").pop() || "").toLowerCase();
  if (["json","ini","cfg","conf","yml","yaml","toml","xml","txt","md","log"].includes(ext)) return "📄";
  if (["png","jpg","jpeg","gif","webp","bmp","ico"].includes(ext)) return "🖼";
  if (["zip","gz","tar","7z","rar","bz2","xz"].includes(ext)) return "📦";
  if (["dll","exe","so","bin"].includes(ext)) return "⚙️";
  if (["lua","js","ts","py","cs","go","sh","bat"].includes(ext)) return "📜";
  return "📄";
}

$("#bkb-tree-rows").addEventListener("click", (e) => {
  const row = e.target.closest("[data-bkpath]");
  if (!row) return;
  const path = row.dataset.bkpath;
  const isDir = row.dataset.bkdir === "1";
  if (isDir) {
    if (_bkbState.expanded.has(path)) _bkbState.expanded.delete(path);
    else _bkbState.expanded.add(path);
    _bkbRenderTree();
  } else {
    _bkbState.selected = path;
    _bkbRenderTree();
    _bkbLoadFile(path);
  }
});

$("#bkb-filter").addEventListener("input", (e) => {
  _bkbState.filter = e.target.value.trim();
  _bkbRenderTree();
});

async function _bkbLoadFile(path) {
  const wrap = $("#bkb-content");
  wrap.innerHTML = `<div class="bkb-loading">Loading ${esc(path)}…</div>`;
  let res;
  try {
    res = await fetchJSON(`/api/instances/${encodeURIComponent(_bkbState.id)}/backups/${encodeURIComponent(_bkbState.bkpId)}/file?path=${encodeURIComponent(path)}`);
  } catch (err) {
    wrap.innerHTML = `<div class="bkb-empty"><span class="icon">⚠</span><div>Couldn't load file</div><span class="dim">${esc(err.message)}</span></div>`;
    return;
  }
  // Decode the base64 payload. For text we go via TextDecoder so utf-8
  // characters (mod descriptions in many languages) round-trip; for
  // binaries we keep the raw bytes for the download path.
  const raw = atob(res.content || "");
  const u8 = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) u8[i] = raw.charCodeAt(i);
  const sizeLabel = `${fmtBytes(res.size || u8.length)}${res.truncated ? " (truncated)" : ""}`;
  const fileName = path.split("/").pop() || "file";
  const head = `<div class="bkb-content-head">
    <span class="path" title="${esc(path)}">${esc(path)}</span>
    <span class="bytes">${sizeLabel}</span>
    <button id="bkb-download" type="button">⤓ Download</button>
  </div>`;
  let body;
  if (res.is_binary) {
    body = `<div class="bkb-binary"><span class="icon">📦</span><div>${esc(fileName)} is a binary file.</div><div class="dim">Click <b>Download</b> above to save it locally.</div></div>`;
  } else {
    let text;
    try { text = new TextDecoder("utf-8", { fatal: false }).decode(u8); }
    catch { text = raw; }
    const banner = res.truncated
      ? `<div class="bkb-banner">Only the first ${fmtBytes(u8.length)} of ${fmtBytes(res.size)} are shown. Download to view the full file.</div>`
      : "";
    body = `${banner}<div class="bkb-viewer text">${esc(text)}</div>`;
  }
  wrap.innerHTML = head + body;
  $("#bkb-download").addEventListener("click", () => {
    // Build a Blob URL from the bytes we already have. For truncated
    // files, this download is also truncated — surface that in the
    // filename so the operator knows.
    const dlName = res.truncated ? fileName + ".truncated" : fileName;
    const blob = new Blob([u8], { type: res.is_binary ? "application/octet-stream" : "text/plain" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = dlName;
    document.body.appendChild(a); a.click();
    setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
  });
}

$("#bkb-close").addEventListener("click", () => hideModal("#bk-browser-modal"));

// ---------------- SCHEDULES tab ----------------
async function loadInstanceSchedules() {
  const id = instanceModalState.id; const wrap = $("#im-s-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Loading…");
  try {
    const { schedules } = await fetchJSON(`/api/schedules?instance_id=${encodeURIComponent(id)}`);
    if (!schedules || !schedules.length) { wrap.innerHTML = `<div class="empty"><div class="big">${EMPTY_ICONS.clock}</div>No schedules yet<span class="hint">Click "Add schedule" above (auto-backup, idle shutdown, restarts)</span></div>`; return; }
    wrap.innerHTML = `<table>
      <thead><tr><th>When</th><th>Action</th><th>Last fired</th><th>On</th><th></th></tr></thead>
      <tbody>${schedules.map(s => {
        let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = s.action; }
        const when = s.trigger_kind === "event"
          ? humanizeEvent(s.event_spec) + (s.event_sustained_seconds ? ` for ${Math.round(s.event_sustained_seconds/60)}m` : "")
          : humanizeCron(s.cron_spec);
        const fired = s.last_fired_at ? fmtTime(s.last_fired_at) : "—";
        const statusDot = s.last_status === "ok"   ? '<span class="dot ok"></span>'
                        : s.last_status === "error" ? '<span class="dot err"></span>'
                        : '<span class="dot"></span>';
        return `<tr>
          <td>${esc(when)}${s.description ? `<br><span class="dim">${esc(s.description)}</span>` : ""}</td>
          <td>${esc(humanizeAction(act))}</td>
          <td>${statusDot}<span class="dim">${esc(fired)}</span></td>
          <td><label><input type="checkbox" data-toggle="${esc(s.id)}" ${s.enabled ? "checked":""}></label></td>
          <td style="text-align:right"><button class="small" data-schedit="${esc(s.id)}" title="Edit cron / action">✏️ Edit</button> <button class="small danger" data-schdel="${esc(s.id)}">Remove</button></td>
        </tr>`;
      }).join("")}</tbody>
    </table>`;
  } catch (err) { wrap.innerHTML = errorState(`Error: ${err.message}`); }
}
$("#im-s-add").addEventListener("click", () => {
  // Pre-fill instance in the shared schedule modal.
  $("#s-instance").value = instanceModalState.id;
  showModal("#sched-modal");
});

// ---------------- UPDATES tab ----------------
async function loadInstanceUpdates() {
  const id = instanceModalState.id; const wrap = $("#im-u-wrap"); if (!id) return;
  const i = allInstances.find(x => x.instance_id === id);
  const mod = (allModules.find(m => m.id === (i && i.module_id)) || {});
  const providers = mod.update_providers || [];
  const is7dtd = (i && i.module_id) === "7dtd";

  // 7DTD version lock surface. Frozen branches (v2.6, v2.5, …) pin a server to
  // a fixed build so a Steam update can't drift it onto the moving public
  // branch (which flips to 3.0 on 2026-06-29 and would corrupt a 2.6 world).
  // The pinned branch is config_values.provider_id. MOVING ids auto-advance.
  const VLABEL = pid => ({v2_6:"2.6 Stable",v2_5:"2.5 Stable",v2_4:"2.4 Stable",v2_3:"2.3 Stable",v2_0:"2.0 Stable",v1_4:"1.4 Stable",latest_experimental:"3.0 Experimental (Dead Hot Summer)",current:"Public / latest stable",stable:"Public / latest stable"}[String(pid||"").toLowerCase()] || pid || "—");
  const MOVING = ["current","stable","latest_experimental"];
  const pinned = (i && i.config_values && i.config_values.provider_id) || "";
  const pinnedLower = pinned.toLowerCase();
  const lockedNow = is7dtd && pinned && !MOVING.includes(pinnedLower);

  let lockBadge = "";
  if (is7dtd) {
    if (lockedNow) {
      lockBadge = `<div class="panel" style="padding:14px 16px;border-left:3px solid var(--ok,var(--ok))">
        <div style="font-weight:600">🔒 Locked to ${esc(VLABEL(pinned))}</div>
        <div class="dim" style="font-size:12px;margin-top:4px">Pinned to a frozen Steam branch — a Steam update can't move it to a new game version (e.g. the public branch flipping to 3.0 on June 29). Your world is safe. Switching versions below needs a typed confirmation.</div>
      </div>`;
    } else if (pinned) {
      lockBadge = `<div class="panel" style="padding:14px 16px;border-left:3px solid var(--warn,#f59e0b)">
        <div style="font-weight:600">⚠ ${esc(VLABEL(pinned))} — unlocked (auto-updates)</div>
        <div class="dim" style="font-size:12px;margin-top:4px">This server tracks a MOVING branch and will advance when that branch does. Switch to a frozen version (e.g. 2.6) below to lock it.</div>
      </div>`;
    } else {
      lockBadge = `<div class="panel" style="padding:14px 16px;border-left:3px solid var(--warn,#f59e0b)">
        <div style="font-weight:600">⚠ Not explicitly pinned</div>
        <div class="dim" style="font-size:12px;margin-top:4px">No game version is recorded for this server. An update currently defaults to <strong>2.6 (frozen)</strong>, but lock it explicitly to be safe.</div>
        <button class="primary" id="im-u-lock26" style="margin-top:10px">🔒 Lock to 2.6</button>
      </div>`;
    }
  }

  const runLabel = (is7dtd && pinned) ? `⬆ Re-validate ${esc(VLABEL(pinned))}` : "⬆ Run update now";
  const runHint = is7dtd
    ? "Re-downloads/validates the files for THIS server's pinned version. Safe — it never changes the game version. Stop the server first if needed."
    : "Pulls the latest files from the configured provider. Stop the server first if the provider requires it.";

  const rows = providers.map(p => {
    const isCur = is7dtd && (p.id.toLowerCase() === pinnedLower || (!pinned && p.id.toLowerCase() === "v2_6"));
    const action = !is7dtd ? "" : (isCur
      ? `<span class="dim">current</span>`
      : `<button class="im-u-switch" data-pid="${esc(p.id)}" style="font-size:12px;padding:4px 10px">Switch to this</button>`);
    return `<tr>
      <td><strong>${esc(p.id)}</strong>${is7dtd ? `<div class="dim" style="font-size:11px">${esc(VLABEL(p.id))}</div>` : ""}</td>
      <td class="mono">${esc(p.kind)}</td>
      <td class="dim">${esc([p.app_id && "app_id="+p.app_id, p.beta && "branch="+p.beta, p.repo && "repo="+p.repo, p.url && "url="+p.url].filter(Boolean).join(" · ") || "—")}</td>
      ${is7dtd ? `<td>${action}</td>` : ""}
    </tr>`;
  }).join("");

  wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:14px">
    ${lockBadge}
    <div class="panel" style="padding:16px">
      <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
        <div>
          <div style="font-weight:600">Update this server</div>
          <div class="dim" style="font-size:12px;margin-top:4px">${runHint}</div>
        </div>
        <button class="primary" id="im-u-run">${runLabel}</button>
      </div>
    </div>
    <div class="panel" style="padding:16px">
      <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
        <div>
          <div style="font-weight:600">Update available?</div>
          <div class="dim" style="font-size:12px;margin-top:4px">Compares the installed Steam build (from this server's appmanifest) against the latest build for its branch. Check only — never installs or restarts anything.</div>
        </div>
        <button id="im-u-check">🔍 Check for updates</button>
      </div>
      <div id="im-u-check-result" style="margin-top:10px"></div>
    </div>
    ${providers.length ? `<div class="panel">
      <header style="padding:12px 18px;border-bottom:1px solid var(--border);background:var(--surface-2);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim)">${is7dtd ? "Game versions" : "Available sources"}</header>
      <table><thead><tr><th>ID</th><th>Type</th><th>Details</th>${is7dtd ? "<th></th>" : ""}</tr></thead>
        <tbody>${rows}</tbody></table>
    </div>` : `<div class="empty">This module has no configured update providers.</div>`}
  </div>`;

  // WI-14: update-available check (manual, check-only).
  const renderCheckResult = (d) => {
    const box = $("#im-u-check-result"); if (!box) return;
    if (!d || d.checked === false) { box.innerHTML = `<span class="dim" style="font-size:12px">Never checked.</span>`; return; }
    const when = d.checked_at ? fmtTime(d.checked_at) : "—";
    let pill;
    if (d.error) pill = `<span class="uc-pill uc-err" title="${esc(d.error)}">⚠ check failed</span>`;
    else if (d.update_available) pill = `<span class="uc-pill uc-stale">⬆ Update available</span>`;
    else pill = `<span class="uc-pill uc-ok">✓ Up to date</span>`;
    box.innerHTML = `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12px">
      ${pill}
      <span class="mono dim">branch=${esc(d.branch || "—")}</span>
      <span class="mono dim">installed=${esc(d.installed_buildid || "?")}</span>
      <span class="mono dim">latest=${esc(d.latest_buildid || "?")}</span>
      <span class="dim">checked ${esc(when)}${d.cached ? " (cached)" : ""}</span>
    </div>${d.error ? `<div class="dim" style="font-size:11px;margin-top:4px;color:var(--warn,#f59e0b)">${esc(d.error)}</div>` : ""}`;
  };
  fetchJSON(`/api/instances/${encodeURIComponent(id)}/update-check`).then(renderCheckResult).catch(() => {});
  const checkBtn = $("#im-u-check");
  if (checkBtn) checkBtn.addEventListener("click", async () => {
    checkBtn.disabled = true;
    const box = $("#im-u-check-result");
    if (box) box.innerHTML = `<span class="dim" style="font-size:12px">Checking… (may take up to a minute on a cold cache)</span>`;
    try {
      const d = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/update-check`, { method: "POST" });
      renderCheckResult(d);
      await refreshUpdateCheckMap();
      _renderGrid(); // repaint card badges with the fresh result
    } catch (err) {
      if (box) box.innerHTML = `<span style="font-size:12px;color:var(--danger,#ef4444)">Check failed: ${esc(err.message)}</span>`;
    } finally { checkBtn.disabled = false; }
  });

  const btn = $("#im-u-run");
  if (btn) btn.addEventListener("click", async () => {
    btn.disabled = true;
    try {
      const r = await triggerUpdateWithSteamGate(id);
      if (r && r.accepted) toast(`Update started (${r.provider_id} · ${r.provider_kind})`, "ok");
    } catch (err) { toast(`Update failed: ${err.message}`, "err"); }
    finally { btn.disabled = false; }
  });
  // 7DTD: explicit "lock to 2.6" for servers with no recorded version.
  const lockBtn = $("#im-u-lock26");
  if (lockBtn) lockBtn.addEventListener("click", async () => {
    lockBtn.disabled = true;
    try {
      const r = await triggerUpdateWithSteamGate(id, "v2_6");
      if (r && r.accepted) {
        if (i && i.config_values) i.config_values.provider_id = "v2_6";
        toast("Locked to 2.6 — re-validating files", "ok");
        loadInstanceUpdates();
      }
    } catch (err) { toast(`Lock failed: ${err.message}`, "err"); }
    finally { lockBtn.disabled = false; }
  });
  // 7DTD: deliberate version switch (gated server-side + typed-slug confirm).
  $$(".im-u-switch").forEach(b => b.addEventListener("click", async () => {
    const pid = b.getAttribute("data-pid");
    b.disabled = true;
    try {
      const r = await triggerUpdateWithSteamGate(id, pid);
      if (r && r.accepted) {
        if (i && i.config_values) i.config_values.provider_id = r.provider_id || pid;
        toast(`Switching to ${r.provider_id || pid} — downloading`, "ok");
        loadInstanceUpdates();
      }
    } catch (err) { toast(`Switch failed: ${err.message}`, "err"); }
    finally { b.disabled = false; }
  }));
}

// ---------------- ARK MODS tab ----------------
//
// CurseForge-backed mod manager. Mirrors the DayZ mod tab (same .dzm-*
// CSS classes for design cohesion), but under the hood the flow is much
// simpler: ASA's dedicated server has its own built-in CurseForge
// downloader, so we never run steamcmd. Installing just mutates the
// instance's MOD_IDS (or PASSIVE_MODS) env var and recreates the
// container — ASA downloads the mod files on next start.
//
// Search + detail data come from api.curse.tools (free CurseForge proxy,
// no key). We only proxy it — the acekorneya image fetches the actual
// mod binaries directly from CurseForge CDN at server start.

const arkModsState = {
  mods: [],            // [{id, title, logo_url, passive, ...}] from server
  searchResults: [],   // last CurseForge search hits
  searchPreview: null, // single-mod preview when user pastes ID/URL
  searchTerm: "",
  searchDebounce: null,
  searchController: null,
  installing: false,
  // Pending changes — none of these touch the backend until Apply. Each
  // recreate is expensive (stops the server, deletes container, recreates,
  // restarts), so we batch every UX action into one POST /ark/mods/batch.
  pending: {
    adds: new Map(),     // modID(string) → {modID, passive, title, logo_url, author}
    removes: new Set(),  // modID(string) — staged for removal
    activeOrder: null,   // [modID,...] override or null = use server order
    passiveOrder: null,  // same
  },
};

function _arkModsResetPending() {
  arkModsState.pending = {
    adds: new Map(), removes: new Set(),
    activeOrder: null, passiveOrder: null,
  };
}

function _arkModsPendingCount() {
  const p = arkModsState.pending;
  let n = p.adds.size + p.removes.size;
  if (p.activeOrder || p.passiveOrder) n += 1; // ordering counts as one logical change
  return n;
}

// Project current installed list + pending changes into the lists
// the user sees on screen. Adds get a `_pending: "add"` marker;
// removes get `_pending: "remove"` (kept in the list with strikethrough
// so the operator can still see what they're about to lose).
function _arkModsEffectiveLists() {
  const p = arkModsState.pending;
  const installed = arkModsState.mods.map(m => ({ ...m, _pending: p.removes.has(String(m.id)) ? "remove" : null }));
  const fromInstalled = (passive) => installed.filter(m => !!m.passive === passive);
  const addsList = (passive) => Array.from(p.adds.values())
    .filter(a => !!a.passive === passive)
    .map(a => ({ id: a.modID, passive: a.passive, title: a.title || `Mod ${a.modID}`, logo_url: a.logo_url || "", author: a.author || "", _pending: "add" }));
  let active  = fromInstalled(false).concat(addsList(false));
  let passive = fromInstalled(true).concat(addsList(true));
  if (p.activeOrder)  active  = _arkModsApplyOrder(active,  p.activeOrder);
  if (p.passiveOrder) passive = _arkModsApplyOrder(passive, p.passiveOrder);
  return { active, passive };
}

function _arkModsApplyOrder(list, ids) {
  const idx = new Map(list.map(m => [String(m.id), m]));
  const seen = new Set();
  const out = [];
  for (const id of ids) {
    const sid = String(id);
    if (idx.has(sid) && !seen.has(sid)) { out.push(idx.get(sid)); seen.add(sid); }
  }
  for (const m of list) if (!seen.has(String(m.id))) out.push(m);
  return out;
}

async function loadArkModsTab() {
  const id = instanceModalState.id; const wrap = $("#im-arkm-wrap"); if (!id) return;
  // If we switched to a different instance, drop any pending staged
  // changes — they belong to the previous server and shouldn't
  // accidentally Apply against this one.
  if (arkModsState._pendingForID && arkModsState._pendingForID !== id) {
    _arkModsResetPending();
  }
  arkModsState._pendingForID = id;
  wrap.innerHTML = loadingState("Loading installed mods…");
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods`);
    arkModsState.mods = r.mods || [];
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`);
    return;
  }
  renderArkModsTab();
}

function renderArkModsTab() {
  const wrap = $("#im-arkm-wrap");
  const eff  = _arkModsEffectiveLists();
  const active  = eff.active;
  const passive = eff.passive;
  const inst = allInstances.find(x => x.instance_id === instanceModalState.id);
  const isRunning = inst && inst.status === "running";

  const installedRow = (m, kind, idx, total) => {
    const pendState = m._pending; // "add" | "remove" | null
    const pendBadge = pendState === "add"    ? `<span class="arkm-pending-badge add">+ to install</span>`
                    : pendState === "remove" ? `<span class="arkm-pending-badge remove">– will remove</span>`
                    : "";
    const cardCls = pendState ? `dzm-mod-row ${kind} arkm-pending arkm-pending-${pendState}` : `dzm-mod-row ${kind}`;
    // Action button: cancel-add OR cancel-remove OR stage-remove.
    const actionBtn = pendState === "add"
      ? `<button class="small" data-arkm-cancel-add="${esc(m.id)}" title="Cancel staged add">↩</button>`
      : pendState === "remove"
        ? `<button class="small" data-arkm-cancel-remove="${esc(m.id)}" title="Cancel staged removal">↩</button>`
        : `<button class="small danger" data-arkm-stage-remove="${esc(m.id)}" title="Stage for removal (applied on Apply)">✕</button>`;
    return `<div class="${cardCls}">
      <div style="display:flex;flex-direction:column;gap:2px;font-size:10px;color:var(--text-dim);font-weight:700;width:18px;text-align:center">
        <span title="Load order position">${idx + 1}</span>
        <span class="dim" style="font-size:9px">/${total}</span>
      </div>
      <div style="width:40px;height:40px;flex-shrink:0;border-radius:var(--radius);background:#111;overflow:hidden;display:flex;align-items:center;justify-content:center">
        ${m.logo_url ? `<img src="${esc(m.logo_url)}" style="width:100%;height:100%;object-fit:cover">` : '<span style="font-size:18px">🧩</span>'}
      </div>
      <div class="dzm-mod-row-main">
        <div class="dzm-mod-row-title">${esc(m.title || 'Mod ' + m.id)} ${pendBadge}</div>
        <div class="dzm-mod-row-sub">
          <button class="small" data-arkm-info="${esc(m.id)}" style="font-size:10.5px;padding:1px 7px" title="Mod info">${esc(m.id)}</button>
          ${m.author ? `<span class="dim" style="font-size:11px">by ${esc(m.author)}</span>` : ''}
          <span class="dzm-mod-row-kind ${kind}">${kind === 'client' ? '-mods=' : '-passivemods='}</span>
        </div>
      </div>
      <div style="display:flex;gap:2px">
        <button class="small" data-arkm-move="up"   data-arkm-id="${esc(m.id)}" data-arkm-kind="${kind}" title="Move up"   ${idx === 0 ? 'disabled' : ''} style="padding:3px 7px">▲</button>
        <button class="small" data-arkm-move="down" data-arkm-id="${esc(m.id)}" data-arkm-kind="${kind}" title="Move down" ${idx === total - 1 ? 'disabled' : ''} style="padding:3px 7px">▼</button>
      </div>
      ${actionBtn}
    </div>`;
  };

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">
    <div class="panel" style="padding:14px">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px">
        <div style="flex:1;min-width:240px">
          <div style="font-weight:600;font-size:14px">🧩 CurseForge mods</div>
          <div class="dim" style="font-size:12px;margin-top:2px">Search by name, or paste a CurseForge URL / project ID. The server downloads mods itself on start — just queue them here and restart.</div>
        </div>
      </div>
      <div class="dzm-search-wrap">
        <span class="dzm-search-icon">🔎</span>
        <input id="im-arkm-search-input" class="dzm-search-input" placeholder="Search — try Super Cryopod, Awesome SpyGlass, Dino Depot…"
               value="${esc(arkModsState.searchTerm)}" spellcheck="false" autocomplete="off" />
        <div class="dzm-search-spin" id="im-arkm-search-spin" aria-hidden="true"></div>
        <button class="dzm-search-clear" id="im-arkm-search-clear" title="Clear" style="display:${arkModsState.searchTerm ? 'block':'none'}">✕</button>
      </div>
      <div class="dzm-search-opts">
        <label><input type="checkbox" id="im-arkm-install-passive"> Install as <b style="color:var(--accent-2)">server-side (passive)</b> — balance/admin tools that don't need client downloads</label>
      </div>
      <div id="im-arkm-search-results" style="margin-top:12px"></div>
    </div>

    <div class="panel" style="padding:14px">
      <div class="dzm-section">
        <h4>🎮 Active mods <span class="dim">(${active.length})</span></h4>
        <span class="dim" style="font-size:11px">clients must subscribe · loaded with <span class="mono">-mods=</span></span>
      </div>
      ${active.length
        ? `<div class="dzm-list">${active.map((m, i) => installedRow(m, 'client', i, active.length)).join('')}</div>`
        : `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.server}</div>No active mods yet. Search above to install one.</div>`}

      <div class="dzm-section">
        <h4>⚙️ Passive / server-side mods <span class="dim">(${passive.length})</span></h4>
        <span class="dim" style="font-size:11px">server-only · loaded with <span class="mono">-passivemods=</span></span>
      </div>
      ${passive.length
        ? `<div class="dzm-list">${passive.map((m, i) => installedRow(m, 'server', i, passive.length)).join('')}</div>`
        : `<div class="dzm-empty" style="padding:14px"><span class="dim" style="font-size:12px">Passive mods are balance or admin tools that shouldn't force clients to download anything — e.g. server-side cryofridge balance tweaks.</span></div>`}
    </div>

    <div class="panel" style="padding:10px 14px;background:rgba(95,178,255,.06)">
      <div style="font-size:12px;color:var(--text-dim);line-height:1.55">
        💡 <b>How ASA mod downloads work:</b> When you install a mod here, the panel adds its ID to the server's launch args (<span class="mono">-mods=id1,id2,id3</span>). The next time the server starts, ASA's built-in CurseForge downloader pulls the mod files. First-boot downloads take a few minutes — watch the Console tab.
        <br><br>
        🔢 <b>Load order matters.</b> Mods load top-to-bottom in the order shown — whatever's at position 1 wins conflicts. Use ▲ / ▼ to reorder.
        <br><br>
        🛠 <b>Stage, then Apply.</b> Installs / removals / reorders only happen when you click <b>Apply</b> at the bottom — that runs ONE recreate instead of one per mod. Stop the server first if you want to add many mods without disrupting players.
      </div>
    </div>

    <!-- Sticky-feeling staging footer — only shown when there are pending changes. -->
    <div class="arkm-pending-footer" id="arkm-pending-footer" style="display:${_arkModsPendingCount() > 0 ? 'flex' : 'none'}">
      <span class="arkm-pending-pip"></span>
      <span class="arkm-pending-status" id="arkm-pending-status"></span>
      <span style="flex:1"></span>
      <button class="small" id="arkm-pending-reset">Reset</button>
      <button class="small primary" id="arkm-pending-apply">${isRunning ? 'Apply &amp; restart server' : 'Apply changes'}</button>
    </div>
  </div>`;

  const input = $("#im-arkm-search-input");
  input.addEventListener("input", onArkModSearchInput);
  input.addEventListener("keydown", e => {
    if (e.key === "Enter") {
      const v = input.value.trim();
      const m = v.match(/(\d{4,12})/);
      if (m) { stageArkModAdd(m[1]); input.value = ""; renderArkSearchResults(); }
    } else if (e.key === "Escape") {
      input.value = ""; onArkModSearchInput({ target: input });
    }
  });
  $("#im-arkm-search-clear").addEventListener("click", () => {
    input.value = ""; input.focus(); onArkModSearchInput({ target: input });
  });
  // Stage buttons (replace previous immediate-POST handlers).
  $$("#im-arkm-wrap [data-arkm-stage-remove]").forEach(b => b.addEventListener("click", () => stageArkModRemove(b.dataset.arkmStageRemove)));
  $$("#im-arkm-wrap [data-arkm-cancel-remove]").forEach(b => b.addEventListener("click", () => unstageArkModRemove(b.dataset.arkmCancelRemove)));
  $$("#im-arkm-wrap [data-arkm-cancel-add]").forEach(b => b.addEventListener("click", () => unstageArkModAdd(b.dataset.arkmCancelAdd)));
  $$("#im-arkm-wrap [data-arkm-info]").forEach(b => b.addEventListener("click", () => openArkModInfoModal(b.dataset.arkmInfo)));
  $$("#im-arkm-wrap [data-arkm-move]").forEach(b => b.addEventListener("click", () => stageArkModMove(b.dataset.arkmId, b.dataset.arkmKind, b.dataset.arkmMove)));

  // Footer wiring.
  _arkModsRefreshFooter();
  $("#arkm-pending-reset")?.addEventListener("click", () => {
    _arkModsResetPending();
    renderArkModsTab();
  });
  $("#arkm-pending-apply")?.addEventListener("click", applyArkModsPending);

  renderArkSearchResults();
  if (!arkModsState.searchTerm) setTimeout(() => input && input.focus(), 50);
}

function _arkModsRefreshFooter() {
  const foot = $("#arkm-pending-footer");
  if (!foot) return;
  const p = arkModsState.pending;
  const adds = p.adds.size;
  const removes = p.removes.size;
  const orders = (p.activeOrder ? 1 : 0) + (p.passiveOrder ? 1 : 0);
  const total = adds + removes + orders;
  const status = $("#arkm-pending-status");
  if (total === 0) {
    foot.style.display = "none";
    return;
  }
  foot.style.display = "flex";
  const bits = [];
  if (adds)    bits.push(`${adds} to install`);
  if (removes) bits.push(`${removes} to remove`);
  if (orders)  bits.push(`load order changed`);
  status.textContent = `${bits.join(" · ")}`;
}

// Stage a mod for installation (does NOT POST). Looks up metadata for
// the title/logo so the staged row in the active list reads cleanly.
async function stageArkModAdd(modID) {
  const id = instanceModalState.id; if (!id) return;
  const mid = String(modID);
  // Skip if already installed or already staged.
  if (arkModsState.mods.some(m => String(m.id) === mid)) {
    // If currently staged for removal, treat the click as "cancel removal".
    if (arkModsState.pending.removes.has(mid)) {
      arkModsState.pending.removes.delete(mid);
      renderArkModsTab();
      return;
    }
    toast(`${mid} is already installed`, "ok"); return;
  }
  if (arkModsState.pending.adds.has(mid)) { toast(`${mid} is already staged`, "ok"); return; }
  const passive = !!($("#im-arkm-install-passive") && $("#im-arkm-install-passive").checked);
  // Best-effort metadata fetch for the row title.
  let title = `Mod ${mid}`, logo_url = "", author = "";
  try {
    const d = await fetch(`/api/ark/mods/detail?id=${encodeURIComponent(mid)}`).then(r => r.ok ? r.json() : null);
    if (d) { title = d.name || title; logo_url = d.logo_url || ""; author = d.author || ""; }
  } catch {}
  arkModsState.pending.adds.set(mid, { modID: mid, passive, title, logo_url, author });
  renderArkModsTab();
}

function unstageArkModAdd(modID) {
  arkModsState.pending.adds.delete(String(modID));
  renderArkModsTab();
}

function stageArkModRemove(modID) {
  arkModsState.pending.removes.add(String(modID));
  renderArkModsTab();
}

function unstageArkModRemove(modID) {
  arkModsState.pending.removes.delete(String(modID));
  renderArkModsTab();
}

// Stage a one-position swap. Updates pending.activeOrder or
// pending.passiveOrder (the full list as currently displayed, after
// applying the swap).
function stageArkModMove(modID, kind, direction) {
  const eff = _arkModsEffectiveLists();
  const list = kind === "server" ? eff.passive : eff.active;
  const idx = list.findIndex(m => String(m.id) === String(modID));
  if (idx < 0) return;
  const target = direction === "up" ? idx - 1 : idx + 1;
  if (target < 0 || target >= list.length) return;
  const newList = list.slice();
  [newList[idx], newList[target]] = [newList[target], newList[idx]];
  const ids = newList.map(m => String(m.id));
  if (kind === "server") arkModsState.pending.passiveOrder = ids;
  else                   arkModsState.pending.activeOrder  = ids;
  renderArkModsTab();
}

async function applyArkModsPending() {
  const id = instanceModalState.id; if (!id) return;
  const p = arkModsState.pending;
  if (_arkModsPendingCount() === 0) { toast("No pending changes", "ok"); return; }
  const inst = allInstances.find(x => x.instance_id === id);
  const isRunning = inst && inst.status === "running";
  if (isRunning) {
    if (!confirm(`Apply ${_arkModsPendingCount()} mod change(s)?\n\nThe server will stop, recreate with the new launch args, and restart. Connected players drop briefly. First-boot mod downloads can take several minutes.`)) return;
  }
  const body = {
    adds: Array.from(p.adds.values()).map(a => ({ mod_id: a.modID, passive: !!a.passive })),
    removes: Array.from(p.removes),
  };
  if (p.activeOrder)  body.active_order  = p.activeOrder;
  if (p.passiveOrder) body.passive_order = p.passiveOrder;
  const btn = $("#arkm-pending-apply"); const reset = $("#arkm-pending-reset");
  if (btn) { btn.disabled = true; btn.textContent = "Applying…"; }
  if (reset) reset.disabled = true;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/batch`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify(body),
    });
    toast(isRunning ? "Mods applied — server restarting" : "Mods applied — start the server to download", "ok");
    _arkModsResetPending();
    await loadArkModsTab();
    pollInstances();
  } catch (err) {
    toast(`Apply failed: ${err.message}`, "err");
    if (btn) { btn.disabled = false; }
    if (reset) reset.disabled = false;
    _arkModsRefreshFooter();
  }
}

function onArkModSearchInput(e) {
  const v = (e.target.value || "").trim();
  arkModsState.searchTerm = v;
  const clear = $("#im-arkm-search-clear");
  if (clear) clear.style.display = v ? "block" : "none";
  clearTimeout(arkModsState.searchDebounce);
  if (arkModsState.searchController) { try { arkModsState.searchController.abort(); } catch {} }
  if (!v) {
    arkModsState.searchResults = [];
    arkModsState.searchPreview = null;
    toggleArkSearchSpin(false);
    renderArkSearchResults();
    return;
  }
  toggleArkSearchSpin(true);
  arkModsState.searchDebounce = setTimeout(() => runArkLiveSearch(v), 300);
}

function toggleArkSearchSpin(on) {
  const s = $("#im-arkm-search-spin");
  if (s) s.classList.toggle("on", !!on);
}

async function runArkLiveSearch(q) {
  const ctrl = new AbortController();
  arkModsState.searchController = ctrl;
  try {
    const idMatch = q.match(/^\s*(?:https?:\/\/\S+?)?(?:[?&/])?(\d{4,12})\s*$/);
    if (idMatch) {
      const mid = idMatch[1];
      const d = await fetch(`/api/ark/mods/detail?id=${encodeURIComponent(mid)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      arkModsState.searchPreview = d;
      arkModsState.searchResults = [];
    } else {
      const d = await fetch(`/api/ark/mods/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      arkModsState.searchResults = d.items || [];
      arkModsState.searchPreview = null;
    }
  } catch (err) {
    if (err.name === "AbortError") return;
    console.warn("ark live search failed", err);
  } finally {
    if (arkModsState.searchController === ctrl) {
      arkModsState.searchController = null;
      toggleArkSearchSpin(false);
    }
  }
  renderArkSearchResults();
}

function renderArkSearchResults() {
  const host = $("#im-arkm-search-results"); if (!host) return;
  const q = arkModsState.searchTerm || "";
  if (!q) { host.innerHTML = ""; return; }
  if (arkModsState.searchPreview) {
    host.innerHTML = renderArkSearchPreview(arkModsState.searchPreview);
    const btn = $("#im-arkm-preview-install");
    if (btn) btn.addEventListener("click", () => {
      const d = arkModsState.searchPreview;
      stageArkModAdd(d.id);
    });
    const info = $("#im-arkm-preview-info");
    if (info) info.addEventListener("click", () => openArkModInfoModal(arkModsState.searchPreview.id));
    return;
  }
  const items = arkModsState.searchResults || [];
  if (!items.length) {
    host.innerHTML = `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.search}</div>No results for "${esc(q)}". Try a shorter term, or paste a CurseForge URL.</div>`;
    return;
  }
  const installed = new Set(arkModsState.mods.map(m => String(m.id)));
  host.innerHTML = `<div class="dzm-grid">${items.slice(0, 30).map(it => arkSearchCard(it, installed.has(String(it.id)))).join("")}</div>`;
  $$("#im-arkm-search-results [data-arkm-card]").forEach(c =>
    c.addEventListener("click", e => {
      if (e.target.closest("[data-install-search]")) return;
      openArkModInfoModal(c.dataset.arkmCard);
    }));
  $$("#im-arkm-search-results [data-install-search]").forEach(b =>
    b.addEventListener("click", e => {
      e.stopPropagation();
      stageArkModAdd(b.dataset.installSearch);
    }));
}

function arkSearchCard(it, isInstalled) {
  const fmtDownloads = (n) => {
    n = Number(n) || 0;
    if (n < 1000) return String(n);
    if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k";
    return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M";
  };
  return `<div class="dzm-card" data-arkm-card="${esc(it.id)}" title="Click for details">
    ${it.logo_url ? `<img class="dzm-card-img" src="${esc(it.logo_url)}" alt="" style="object-fit:contain;padding:10px;background:#0a0e1a">` : ''}
    <div class="dzm-card-body">
      <div class="dzm-card-title">${esc(it.name || '(untitled)')}</div>
      <div class="dzm-card-meta">
        ${it.author ? `<span>${esc(it.author)}</span>` : ''}
        ${it.download_count ? `<span class="dim">· ⬇ ${fmtDownloads(it.download_count)}</span>` : ''}
      </div>
      <div class="dzm-card-foot">
        ${isInstalled
          ? `<span class="dzm-card-badge-installed">✓ Installed</span>`
          : `<button class="small primary" data-install-search="${esc(it.id)}">＋ Stage</button>`}
      </div>
    </div>
  </div>`;
}

function renderArkSearchPreview(d) {
  const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; };
  const installed = arkModsState.mods.some(m => String(m.id) === String(d.id));
  return `<div class="dzm-preview">
    ${d.logo_url ? `<img class="dzm-preview-img" src="${esc(d.logo_url)}" style="object-fit:contain;padding:10px;background:#0a0e1a" alt="">` : `<div class="dzm-preview-img" style="display:flex;align-items:center;justify-content:center;color:var(--muted)">no preview</div>`}
    <div class="dzm-preview-body">
      <div class="dzm-preview-title">${esc(d.name || 'Mod #' + d.id)}</div>
      <div class="dzm-preview-meta">
        <span>🆔 <span class="mono">${esc(d.id)}</span></span>
        ${d.author ? `<span>👤 ${esc(d.author)}</span>` : ''}
        ${d.latest_size ? `<span>📦 ${fmtSize(d.latest_size)}</span>` : ''}
        ${d.download_count ? `<span>⬇ ${Number(d.download_count).toLocaleString()}</span>` : ''}
      </div>
      <div class="dim" style="font-size:12px;margin-top:2px">${esc(d.summary || 'Paste a URL or ID — we\'ll fetch CurseForge metadata and stage the install.')}</div>
    </div>
    <div class="dzm-preview-actions">
      ${installed
        ? `<span class="dzm-card-badge-installed" style="text-align:center;padding:8px 14px">✓ Installed</span>`
        : `<button class="primary" id="im-arkm-preview-install">＋ Stage install</button>`}
      <button class="small" id="im-arkm-preview-info">ℹ Full details</button>
    </div>
  </div>`;
}

async function runArkModInstall(modID) {
  if (arkModsState.installing) return;
  const id = instanceModalState.id; if (!id) return;
  const passive = !!($("#im-arkm-install-passive") && $("#im-arkm-install-passive").checked);
  const body = JSON.stringify({ mod_id: String(modID), passive });
  arkModsState.installing = true;
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body });
    if (r.duplicate) toast(`Already installed`, "ok");
    else toast(`Installed ${r.title || modID}${passive ? " (passive)" : ""} — start the server to download`, "ok");
    await loadArkModsTab();
  } catch (err) {
    toast(`Install failed: ${err.message}`, "err");
  } finally {
    arkModsState.installing = false;
  }
}

// runArkModMove shifts a mod one position up or down within its list
// (active or passive). Swaps with the neighbour, then calls the reorder
// endpoint with full replacement lists — that does the
// stop→delete-preserve→create-with-new-env→start dance.
async function runArkModMove(modID, kind, direction) {
  const id = instanceModalState.id; if (!id) return;
  const passive = kind === "server";
  const list = (arkModsState.mods || []).filter(m => !!m.passive === passive).map(m => String(m.id));
  const otherList = (arkModsState.mods || []).filter(m => !!m.passive !== passive).map(m => String(m.id));
  const idx = list.indexOf(String(modID));
  if (idx < 0) return;
  const target = direction === "up" ? idx - 1 : idx + 1;
  if (target < 0 || target >= list.length) return;
  // Swap.
  [list[idx], list[target]] = [list[target], list[idx]];
  const body = JSON.stringify(passive
    ? { active: otherList, passive: list }
    : { active: list,      passive: otherList });
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/order`,
      { method: "PUT", headers: {"Content-Type":"application/json"}, body });
    await loadArkModsTab();
  } catch (err) {
    toast(`Reorder failed: ${err.message}`, "err");
  }
}

async function runArkModUninstall(modID) {
  const id = instanceModalState.id; if (!id) return;
  const m = arkModsState.mods.find(x => String(x.id) === String(modID));
  if (!confirm(`Uninstall ${m ? m.title || modID : modID}? The server will stop + recreate without this mod in the launch args.`)) return;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/${encodeURIComponent(modID)}`,
      { method: "DELETE" });
    toast(`Removed ${m ? m.title : modID}`, "ok");
    await loadArkModsTab();
  } catch (err) {
    toast(`Uninstall failed: ${err.message}`, "err");
  }
}

// ---- mod info modal (ARK / CurseForge) ----
// Reuses the #mod-info-modal DOM that DayZ uses. Data source differs:
// we hit /api/ark/mods/detail which returns CurseForge-normalized JSON
// with a full HTML description.

async function openArkModInfoModal(modID) {
  const body = $("#mi-body");
  $("#mi-title").textContent = "Mod info";
  body.innerHTML = loadingState("Loading CurseForge details…");
  showModal("#mod-info-modal");
  let d;
  try {
    d = await fetchJSON(`/api/ark/mods/detail?id=${encodeURIComponent(modID)}`);
  } catch (err) {
    body.innerHTML = errorState(`Couldn't load details: ${err.message}`);
    return;
  }
  $("#mi-title").textContent = d.name || "Mod info";
  const installed = arkModsState.mods.some(m => String(m.id) === String(d.id));
  const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; };
  const fmtDate = (s) => { if (!s) return "—"; const t = new Date(s); return isNaN(t) ? "—" : t.toLocaleDateString(); };
  const fmtNum = (n) => (Number(n)||0).toLocaleString();
  // Sanitise the CurseForge description HTML. We allow links, b, i, u,
  // br, p, h1-6, ul, ol, li, img, pre, code — strip everything else,
  // strip inline event handlers / scripts.
  const sanitiseHTML = (html) => {
    if (!html) return "<i>No description.</i>";
    const allowedTags = /^(a|b|strong|i|em|u|br|p|h[1-6]|ul|ol|li|img|pre|code|blockquote|hr|div|span|table|thead|tbody|tr|th|td)$/i;
    const template = document.createElement("template");
    template.innerHTML = html;
    const clean = (node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        if (!allowedTags.test(node.tagName)) {
          const parent = node.parentNode;
          while (node.firstChild) parent.insertBefore(node.firstChild, node);
          parent.removeChild(node);
          return;
        }
        for (const a of [...node.attributes]) {
          if (/^on/i.test(a.name)) node.removeAttribute(a.name);
          else if ((a.name === "href" || a.name === "src") && /^javascript:/i.test(a.value)) node.removeAttribute(a.name);
          else if (!/^(href|src|alt|title|class|colspan|rowspan|target|rel)$/i.test(a.name)) node.removeAttribute(a.name);
        }
        if (node.tagName === "A") { node.setAttribute("target","_blank"); node.setAttribute("rel","noopener"); }
      }
      for (const child of [...node.childNodes]) clean(child);
    };
    clean(template.content);
    return template.innerHTML;
  };

  body.innerHTML = `
    <div style="display:grid;grid-template-columns:240px 1fr;gap:16px;align-items:start">
      <div>
        ${d.logo_url ? `<img src="${esc(d.logo_url)}" alt="" style="width:100%;border-radius:var(--radius);background:#0a0e1a;display:block;object-fit:contain;padding:10px">` : ''}
        <div style="margin-top:10px;display:flex;flex-direction:column;gap:6px;font-size:12px">
          <div style="display:flex;justify-content:space-between"><span class="dim">Downloads</span><span>${fmtNum(d.download_count)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Latest file</span><span class="mono">${fmtSize(d.latest_size)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Updated</span><span>${fmtDate(d.updated)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Released</span><span>${fmtDate(d.released)}</span></div>
          ${d.rank ? `<div style="display:flex;justify-content:space-between"><span class="dim">Popularity</span><span>#${d.rank}</span></div>` : ''}
          ${d.rating ? `<div style="display:flex;justify-content:space-between"><span class="dim">Rating</span><span>${Number(d.rating).toFixed(1)}% (${fmtNum(d.ratings_total)})</span></div>` : ''}
        </div>
        <div style="margin-top:10px;font-size:12px">
          ${d.website ? `<a href="${esc(d.website)}" target="_blank" rel="noopener">↗ Open on CurseForge</a>` : ''}
        </div>
      </div>
      <div style="min-width:0">
        ${(d.categories || []).length ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:6px">
          ${d.categories.map(tagChip).join("")}
        </div>` : ''}
        <div style="font-size:12.5px;line-height:1.55;max-height:340px;overflow:auto;padding-right:6px;white-space:normal">
          ${d.summary ? `<p style="margin:0 0 10px 0"><b>${esc(d.summary)}</b></p>` : ''}
          ${sanitiseHTML(d.description_html || "")}
        </div>
      </div>
    </div>
    <div style="margin-top:16px;display:flex;gap:10px;justify-content:flex-end;align-items:center">
      <label style="font-size:12px;display:flex;align-items:center;gap:6px">
        <input type="checkbox" id="mi-arkm-passive"> passive (server-side)
      </label>
      <button data-close>Close</button>
      ${installed
        ? installedBadge()
        : `<button class="primary" id="mi-arkm-install">＋ Stage install</button>`}
    </div>`;
  const btn = $("#mi-arkm-install");
  if (btn) btn.addEventListener("click", async () => {
    btn.disabled = true; btn.textContent = "⏳ Staging…";
    try {
      const passive = !!($("#mi-arkm-passive") && $("#mi-arkm-passive").checked);
      if ($("#im-arkm-install-passive")) $("#im-arkm-install-passive").checked = passive;
      await stageArkModAdd(d.id);
      hideModal("#mod-info-modal");
      toast(`Staged ${d.name || d.id} — review and Apply on the Mods tab`, "ok");
    } catch (err) {
      toast(`Stage failed: ${err.message}`, "err");
    } finally {
      btn.disabled = false; btn.textContent = "＋ Stage install";
    }
  });
}

// ---------------- ARK CLUSTER tab ----------------
//
// One-click cluster setup for ARK: Survival Ascended. Two modes:
//   - NOT clustered → "🔗 Setup New Cluster" button (generates id + makes
//     the first member) + "➕ Join existing" dropdown when other clusters
//     already exist on the node.
//   - Clustered → shows the cluster's name, siblings list, and a "Leave"
//     button. Explains that leaving recreates the container without the
//     shared mount.
//
// All actions are synchronous from the UI's perspective: the backend does
// stop → delete-preserve → recreate → start internally. The UI disables
// buttons + shows a spinner during that (~20-60s) cycle.

const arkClusterState = {
  currentCluster: null,   // {id, name, siblings} or null
  allClusters: [],        // all clusters on the node (for Join dropdown)
  loading: false,
};

async function loadArkClusterTab() {
  const id = instanceModalState.id; const wrap = $("#im-ark-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Loading cluster info…");
  try {
    const [mine, all] = await Promise.all([
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`),
      fetchJSON(`/api/ark/clusters`),
    ]);
    arkClusterState.currentCluster = mine.cluster || null;
    arkClusterState.allClusters = all.clusters || [];
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load cluster state: ${err.message}`);
    return;
  }
  renderArkClusterTab();
}

function renderArkClusterTab() {
  const wrap = $("#im-ark-wrap"); if (!wrap) return;
  const id = instanceModalState.id;
  const cluster = arkClusterState.currentCluster;
  const others = arkClusterState.allClusters.filter(c => !cluster || c.id !== cluster.id);

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">

    <!-- Explainer -->
    <div class="panel" style="padding:14px">
      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
        <span style="font-size:20px">🔗</span>
        <div>
          <div style="font-weight:600;font-size:14px">ARK cluster manager</div>
          <div class="dim" style="font-size:12px">Share character + dino transfers across multiple ARK maps on this node.</div>
        </div>
      </div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-top:6px">
        Setting up a cluster creates a shared Docker volume mounted into this server at
        <span class="mono">Saved/clusters</span>. Any other ARK SA server on this node can then join the same cluster with one click — they'll all share character uploads, downloads, and dino transfers. The server auto-restarts to apply the mount change.
      </div>
    </div>

    ${cluster
      ? renderArkClusterJoined(cluster)
      : renderArkClusterStandalone(others)}

  </div>`;

  // Wire handlers depending on current state.
  if (cluster) {
    const leaveBtn = $("#im-ark-leave");
    if (leaveBtn) leaveBtn.addEventListener("click", () => arkClusterLeave());
    const shareBtn = $("#im-ark-share");
    if (shareBtn) shareBtn.addEventListener("click", () => arkClusterShareModal());
  } else {
    const setupBtn = $("#im-ark-setup");
    if (setupBtn) setupBtn.addEventListener("click", () => arkClusterSetup());
    const joinBtn = $("#im-ark-join");
    if (joinBtn) joinBtn.addEventListener("click", () => {
      const sel = $("#im-ark-join-picker");
      if (sel && sel.value) arkClusterJoin(sel.value);
    });
  }
}

function renderArkClusterJoined(c) {
  const siblings = c.siblings || [];
  // Drift banner — set by the controller when the registry says this
  // instance is in a cluster but the persisted CONFIG_VALUES.CLUSTER_ID
  // doesn't match (= the recreate dance was rolled back or never
  // actually applied). Operator must Leave + re-Join to repair the
  // mount; otherwise transfers won't sync to siblings.
  const driftBanner = c.mount_drift ? `
    <div style="border:1px solid rgba(248,113,113,.45);background:rgba(248,113,113,.07);border-radius:var(--radius);padding:11px 13px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start">
      <span style="font-size:18px;line-height:1">⚠</span>
      <div>
        <div style="font-weight:600;color:var(--err);margin-bottom:3px">Cluster mount didn't take</div>
        <div class="dim" style="font-size:12px;line-height:1.5">
          The cluster registry shows this server as a member, but its container is still using the per-instance volume — transfers won't sync to siblings.
          Click <b>Leave cluster</b>, then re-join to recreate the container with the correct shared mount.
        </div>
      </div>
    </div>` : "";
  return `
    <div class="panel" style="padding:16px;border-left:3px solid ${c.mount_drift ? 'rgba(248,113,113,.6)' : 'var(--ok)'}">
      ${driftBanner}
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:10px;flex-wrap:wrap">
        <div style="min-width:0">
          <div style="font-weight:700;font-size:15px">${esc(c.name || 'Cluster')}</div>
          <div class="dim mono" style="font-size:11.5px">${esc(c.id)}</div>
        </div>
        <span style="font-size:11px;padding:4px 10px;border-radius:var(--radius-pill);background:${c.mount_drift ? 'rgba(248,113,113,.15)' : 'rgba(74,222,128,.15)'};color:${c.mount_drift ? 'var(--err)' : 'var(--ok)'};font-weight:600;border:1px solid ${c.mount_drift ? 'rgba(248,113,113,.3)' : 'rgba(74,222,128,.3)'}">${c.mount_drift ? '⚠ Member (mount broken)' : '✓ Member of this cluster'}</span>
      </div>
      <div style="font-size:13px;margin:10px 0 6px">
        <b>${siblings.length === 0 ? 'No siblings yet' : siblings.length + ' other server' + (siblings.length === 1 ? '' : 's') + ' in this cluster'}</b>
        ${siblings.length ? `<div class="mono" style="font-size:11.5px;color:var(--text-dim);margin-top:4px">${siblings.map(esc).join(" · ")}</div>` : ''}
      </div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-top:6px">
        Transfers, dinos, and characters uploaded on this server appear on all siblings. Any new ARK SA server you create on this node can click <b>Join Cluster</b> and pick <span class="mono">${esc(c.name || c.id)}</span> from the dropdown.
      </div>
      <div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
        ${siblings.length ? `<button class="small primary" id="im-ark-share">📤 Copy settings to siblings</button>` : ""}
        <button class="small danger" id="im-ark-leave">⏏ Leave cluster</button>
      </div>
    </div>`;
}

function renderArkClusterStandalone(others) {
  return `
    <div class="panel" style="padding:16px">
      <div style="font-weight:600;font-size:14px;margin-bottom:10px">🆕 Set up a new cluster</div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-bottom:12px">
        Creates a fresh shared volume for transfers and makes this server its first member. Other ARK servers on this node will then see a "Join Cluster" option.
      </div>
      <div style="display:flex;gap:8px;justify-content:flex-end">
        <button class="primary" id="im-ark-setup">🔗 Setup Cluster</button>
      </div>
    </div>

    ${others.length ? `
    <div class="panel" style="padding:16px">
      <div style="font-weight:600;font-size:14px;margin-bottom:10px">➕ Join an existing cluster</div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-bottom:12px">
        There ${others.length === 1 ? 'is 1 cluster' : 'are ' + others.length + ' clusters'} already set up on this node. Pick one to join — this server will be recreated with the shared cluster volume.
      </div>
      <div class="row" style="gap:8px;align-items:flex-end">
        <select id="im-ark-join-picker" style="flex:1">
          ${others.map(c => `<option value="${esc(c.id)}">${esc(c.name || c.id)} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? '' : 's'}</option>`).join('')}
        </select>
        <button class="primary" id="im-ark-join">➕ Join</button>
      </div>
    </div>` : `
    <div class="dzm-empty"><div class="big">🔗</div>No other clusters on this node yet. Set one up to start sharing transfers across ARK maps.</div>`}`;
}

async function arkClusterSetup() {
  const id = instanceModalState.id; if (!id || arkClusterState.loading) return;
  if (!confirm("Setup a new ARK cluster for this server? The server will stop, recreate with a shared cluster volume, and restart if it was running. World data is preserved.")) return;
  arkClusterState.loading = true;
  setArkClusterBusy(true, "Creating cluster + recreating server…");
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({}) });
    toast(`Cluster ${r.cluster_id} set up`, "ok");
    await loadArkClusterTab();
  } catch (err) {
    toast(`Cluster setup failed: ${err.message}`, "err");
  } finally {
    arkClusterState.loading = false;
  }
}

async function arkClusterJoin(clusterID) {
  const id = instanceModalState.id; if (!id || arkClusterState.loading) return;
  if (!confirm(`Join cluster ${clusterID}? The server will stop, recreate with the shared cluster volume, and restart if it was running.`)) return;
  arkClusterState.loading = true;
  setArkClusterBusy(true, "Joining cluster + recreating server…");
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster/join`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ cluster_id: clusterID }) });
    toast(`Joined cluster ${clusterID}`, "ok");
    await loadArkClusterTab();
  } catch (err) {
    toast(`Join failed: ${err.message}`, "err");
  } finally {
    arkClusterState.loading = false;
  }
}

async function arkClusterLeave() {
  const id = instanceModalState.id; if (!id || arkClusterState.loading) return;
  if (!confirm("Leave the cluster? The server will recreate with its own per-instance cluster volume. Shared transfers will no longer reach siblings.")) return;
  arkClusterState.loading = true;
  setArkClusterBusy(true, "Leaving cluster + recreating server…");
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`,
      { method: "DELETE" });
    toast(`Left cluster`, "ok");
    await loadArkClusterTab();
  } catch (err) {
    toast(`Leave failed: ${err.message}`, "err");
  } finally {
    arkClusterState.loading = false;
  }
}

// ====================================================================
// "Copy settings to siblings" — broadcast this server's INI multipliers
// + selected env vars to every other ARK server in the same cluster.
//
// Source of truth for what's safe to copy is per-group, derived from the
// existing config schema. Defaults reflect cluster-shared norms: rates,
// modes, mods all match across cluster; identity (passwords, MOTD) and
// network (RCON port, slot count) stay per-server. Map and session name
// are NEVER copied — they're definitionally per-instance.
//
// Apply path:
//   - INI changes write via /files (no recreate; effective on next
//     server restart, just like a normal Apply).
//   - Env changes write via /env-config (forces a recreate dance per
//     sibling, ~30s each).
// ====================================================================
// Groups that cluster-share never offers — Identity (passwords / MOTD)
// and Slots & Network (RCON port / reserved slots) are always per-
// server, so showing them as checkboxes invites a foot-gun.
const ARK_SHARE_INI_HIDE_GROUPS = new Set([
  "Identity",
  "Slots & Network",
]);
const ARK_SHARE_ENV_GROUPS = [
  { id: "mods",      label: "Mod IDs / Passive mods",
    hint: "Cluster-wide mod parity is required — survivors transferring between maps lose mod-gated items if a target server isn't running the same mods.",
    props: ["MOD_IDS", "PASSIVE_MODS"], defaultOn: true },
  { id: "battleeye", label: "BattlEye anti-cheat",
    hint: "Cluster-wide for consistent player experience.",
    props: ["BATTLEEYE"], defaultOn: true },
  { id: "auth",      label: "Admin + server passwords",
    hint: "Optional. Off by default — operators sometimes use per-map passwords.",
    props: ["SERVER_ADMIN_PASSWORD", "SERVER_PASSWORD"], defaultOn: false },
  { id: "capacity",  label: "Max players cap",
    hint: "Off by default — many operators tune this per map for performance.",
    props: ["MAX_PLAYERS"], defaultOn: false },
];

async function arkClusterShareModal() {
  const id = instanceModalState.id; if (!id) return;
  const cluster = arkClusterState.currentCluster;
  if (!cluster) return;
  const siblings = cluster.siblings || [];
  if (!siblings.length) { toast("No sibling servers to copy to.", "err"); return; }

  const schema = configSchemas["ark-sa"];
  if (!schema) { toast("ARK schema not loaded.", "err"); return; }

  const overlay = document.createElement("div");
  overlay.className = "ark-share-overlay";
  overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:1100;padding:20px";
  overlay.innerHTML = `
    <div style="background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius);max-width:780px;width:100%;max-height:90vh;display:flex;flex-direction:column;overflow:hidden">
      <div style="padding:14px 18px;border-bottom:1px solid var(--border-soft);display:flex;align-items:center;gap:10px">
        <div style="font-weight:700;font-size:16px;flex:1">📤 Copy settings from <span class="mono">${esc(id)}</span> to siblings</div>
        <button class="small" data-close>✕</button>
      </div>
      <div style="padding:16px 18px;overflow:auto;flex:1">
        <div style="margin-bottom:14px">
          <div class="dim" style="font-size:12px;line-height:1.5">
            INI changes <b>don't recreate</b> the target containers — they take effect on each sibling's next restart.
            Env changes (mods, BattlEye, passwords) <b>do recreate</b> their containers (~30s each).
            Tick "Restart siblings sequentially" below to roll restarts on INI-only targets after the copy.
          </div>
        </div>

        <div style="margin-bottom:18px">
          <div style="font-weight:600;font-size:13px;margin-bottom:6px">Targets (${siblings.length})</div>
          <div style="display:flex;flex-wrap:wrap;gap:6px" id="ark-share-targets">
            ${siblings.map(s => `
              <label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border-soft);border-radius:var(--radius);cursor:pointer;font-size:12.5px">
                <input type="checkbox" class="ark-share-target" data-sib="${esc(s)}" checked>
                <span class="mono">${esc(s)}</span>
              </label>
            `).join("")}
          </div>
        </div>

        <div style="margin-bottom:18px">
          <div style="font-weight:600;font-size:13px;margin-bottom:6px">INI settings (effective on next restart of each sibling)</div>
          <div style="display:flex;flex-direction:column;gap:6px" id="ark-share-ini">
            ${(schema.groups || []).map(g => {
              if (ARK_SHARE_INI_HIDE_GROUPS.has(g.title)) return "";
              const fieldCount = (g.fields || []).length;
              if (!fieldCount) return "";
              return `
                <label style="display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--border-soft);border-radius:var(--radius);cursor:pointer">
                  <input type="checkbox" class="ark-share-ini-grp" data-grp="${esc(g.title)}" checked style="margin-top:2px">
                  <div style="flex:1">
                    <div style="font-weight:500;font-size:13px">${esc(g.icon || "")} ${esc(g.title)} <span class="dim" style="font-weight:400;font-size:11.5px">(${fieldCount} field${fieldCount === 1 ? "" : "s"})</span></div>
                    <div class="dim" style="font-size:11px;line-height:1.4;margin-top:2px">${esc((g.description || "").replace(/<[^>]+>/g, ""))}</div>
                  </div>
                </label>`;
            }).join("")}
          </div>
        </div>

        <div>
          <div style="font-weight:600;font-size:13px;margin-bottom:6px">Launch / env settings (recreates each sibling)</div>
          <div style="display:flex;flex-direction:column;gap:6px" id="ark-share-env">
            ${ARK_SHARE_ENV_GROUPS.map(g => `
              <label style="display:flex;align-items:flex-start;gap:8px;padding:8px 10px;border:1px solid var(--border-soft);border-radius:var(--radius);cursor:pointer">
                <input type="checkbox" class="ark-share-env-grp" data-grp="${esc(g.id)}" ${g.defaultOn ? "checked" : ""} style="margin-top:2px">
                <div style="flex:1">
                  <div style="font-weight:500;font-size:13px">${esc(g.label)} <span class="dim" style="font-weight:400;font-size:11.5px">(${g.props.join(", ")})</span></div>
                  <div class="dim" style="font-size:11px;line-height:1.4;margin-top:2px">${esc(g.hint)}</div>
                </div>
              </label>
            `).join("")}
          </div>
        </div>
      </div>
      <div style="padding:12px 18px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:12px;flex-wrap:wrap">
        <label style="display:inline-flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer;flex:1;min-width:0">
          <input type="checkbox" id="ark-share-restart">
          <span>Restart siblings sequentially after applying <span class="dim" style="font-size:11px">(only siblings that didn't already recreate from env changes)</span></span>
        </label>
        <button class="small" data-close>Cancel</button>
        <button class="small primary" id="ark-share-apply">📤 Copy &amp; apply</button>
      </div>
    </div>`;
  document.body.appendChild(overlay);
  const close = () => overlay.remove();
  overlay.addEventListener("click", e => {
    if (e.target === overlay || (e.target.dataset && e.target.dataset.close !== undefined)) close();
  });
  overlay.querySelector("#ark-share-apply").addEventListener("click", async () => {
    const targets = Array.from(overlay.querySelectorAll(".ark-share-target:checked")).map(el => el.dataset.sib);
    const iniGroups = new Set(Array.from(overlay.querySelectorAll(".ark-share-ini-grp:checked")).map(el => el.dataset.grp));
    const envGroups = new Set(Array.from(overlay.querySelectorAll(".ark-share-env-grp:checked")).map(el => el.dataset.grp));
    const restartAfter = !!overlay.querySelector("#ark-share-restart").checked;
    if (!targets.length) { toast("Pick at least one target sibling.", "err"); return; }
    if (!iniGroups.size && !envGroups.size) { toast("Pick at least one settings group.", "err"); return; }
    const btn = overlay.querySelector("#ark-share-apply");
    btn.disabled = true;
    try {
      await arkClusterShareApply({ sourceID: id, targets, iniGroups, envGroups, restartAfter, statusEl: btn });
      close();
    } catch (err) {
      toast("Cluster share failed: " + err.message, "err", 8000);
      btn.disabled = false;
      btn.textContent = "📤 Copy & apply";
    }
  });
}

// Builds the {prop -> value} maps to push to siblings, fetches the
// source instance's INI, then writes per-sibling. Sequential to avoid
// dogpiling the agent + cluster recreate flow.
async function arkClusterShareApply({ sourceID, targets, iniGroups, envGroups, restartAfter, statusEl }) {
  const schema = configSchemas["ark-sa"];

  // Build the INI prop set from the chosen groups.
  const iniProps = [];
  for (const g of (schema.groups || [])) {
    if (!iniGroups.has(g.title)) continue;
    for (const f of (g.fields || [])) iniProps.push(f.prop);
  }

  // Build the env prop set.
  const envProps = [];
  for (const g of ARK_SHARE_ENV_GROUPS) {
    if (!envGroups.has(g.id)) continue;
    for (const p of g.props) envProps.push(p);
  }

  // Pull source values. INI: read the file, parse, pluck the selected
  // keys. Env: read directly from the cached instance row.
  const iniUpdates = {};
  if (iniProps.length) {
    if (statusEl) statusEl.textContent = "Reading source config…";
    let srcContent = null;
    for (const p of schema.filePaths) {
      try {
        const q = new URLSearchParams({ path: p });
        const r = await fetchJSON(`/api/instances/${encodeURIComponent(sourceID)}/files/read?${q}`);
        srcContent = r.content; break;
      } catch {}
    }
    if (srcContent == null) throw new Error("couldn't read source GameUserSettings.ini");
    const parsed = parseINI(srcContent);
    for (const p of iniProps) {
      if (parsed.values[p] !== undefined) iniUpdates[p] = parsed.values[p];
    }
  }

  const envUpdates = {};
  if (envProps.length) {
    const srcInst = allInstances.find(x => x.instance_id === sourceID);
    const cv = (srcInst && srcInst.config_values) || {};
    for (const p of envProps) {
      // Skip empty strings — server-side env-config treats "" as a
      // delete signal, so propagating "" would clobber the target's
      // existing value. That's how three ARK instances lost their
      // MAP_NAME/SESSION_NAME/MOD_IDS via cluster-share.
      if (cv[p] !== undefined && cv[p] !== "") envUpdates[p] = cv[p];
    }
  }

  if (!Object.keys(iniUpdates).length && !Object.keys(envUpdates).length) {
    throw new Error("source has no values set for the chosen groups");
  }

  // Apply per sibling. We do them sequentially so a recreate dance
  // doesn't trigger 5 agents at once on big clusters. Errors collect;
  // we keep going and show a summary toast at the end.
  const results = [];
  let i = 0;
  const envApplied = Object.keys(envUpdates).length > 0;
  for (const tgt of targets) {
    i++;
    if (statusEl) statusEl.textContent = `Apply (${i}/${targets.length}) ${tgt}…`;
    let appliedINI = false, appliedEnv = false;
    try {
      // INI write — read sibling's existing file, splice in our updates,
      // write back. This preserves their unrelated settings.
      if (Object.keys(iniUpdates).length) {
        let tgtContent = null, tgtPath = null;
        for (const p of schema.filePaths) {
          try {
            const q = new URLSearchParams({ path: p });
            const r = await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/files/read?${q}`);
            tgtContent = r.content; tgtPath = p; break;
          } catch {}
        }
        if (tgtContent == null) throw new Error("can't read GameUserSettings.ini");
        const parsed = parseINI(tgtContent);
        const newContent = serializeINI(parsed, iniUpdates);
        await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/files`, {
          method: "POST", headers: {"Content-Type":"application/json"},
          body: JSON.stringify({ path: tgtPath, content: newContent }),
        });
        appliedINI = true;
      }
      // Env write — single POST that does the recreate dance agent-side.
      if (envApplied) {
        await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/env-config`, {
          method: "POST", headers: {"Content-Type":"application/json"},
          body: JSON.stringify({ updates: envUpdates }),
        });
        appliedEnv = true;
      }
      results.push({ tgt, ok: true, ini: appliedINI, env: appliedEnv });
      toast(`✓ ${tgt}`, "ok", 3500);
    } catch (err) {
      results.push({ tgt, ok: false, err: err.message });
      toast(`✗ ${tgt}: ${err.message}`, "err", 6000);
    }
  }

  const ok = results.filter(r => r.ok).length;
  const fail = results.length - ok;
  toast(`Cluster share: ${ok}/${results.length} ok${fail ? `, ${fail} failed` : ""}`, fail ? "warn" : "ok", 6000);

  // Refresh the live instance list so the cluster tab + main grid pick
  // up new env values (if env was applied).
  pollInstances();

  // Optional sequential restart pass. Skips siblings whose env was
  // applied — env-config already runs a recreate dance (stop → delete-
  // preserve → create → start), so re-restarting them now would race
  // that in-flight cycle. Only INI-only siblings need a manual bounce
  // to reload GameUserSettings.ini.
  if (restartAfter) {
    const toRestart = results.filter(r => r.ok && r.ini && !r.env).map(r => r.tgt);
    if (!toRestart.length) {
      toast(envApplied ? "Restart skipped — env recreate already cycled the targets." : "Nothing to restart.", "ok", 4500);
      return;
    }
    let j = 0;
    for (const tgt of toRestart) {
      j++;
      if (statusEl) statusEl.textContent = `Restart (${j}/${toRestart.length}) ${tgt}…`;
      try {
        const inst = allInstances.find(x => x.instance_id === tgt);
        const wasRunning = inst && inst.status === "running";
        if (wasRunning) {
          await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/stop?grace=30`, { method: "POST" });
        }
        // Always issue start — picks up newly-stopped instances cleanly,
        // and a no-op start on an already-running instance returns 200.
        await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/start`, { method: "POST" });
        toast(`↻ restarted ${tgt}`, "ok", 3500);
      } catch (err) {
        toast(`✗ restart ${tgt}: ${err.message}`, "err", 6000);
      }
    }
    toast(`Restart pass: ${toRestart.length} sibling${toRestart.length === 1 ? "" : "s"} cycled`, "ok", 5000);
    pollInstances();
  }
}

function setArkClusterBusy(busy, msg) {
  const wrap = $("#im-ark-wrap"); if (!wrap) return;
  if (busy) {
    wrap.innerHTML = `<div class="empty" style="padding:40px 16px">
      <div style="width:28px;height:28px;margin:0 auto 14px;border:3px solid rgba(255,107,26,.2);border-top-color:var(--accent);border-radius:50%;animation:im-dzm-spin .7s linear infinite"></div>
      <div style="font-size:13px">${esc(msg || 'working…')}</div>
      <div class="dim" style="font-size:11.5px;margin-top:6px">This takes ~30-60 s (stop → delete → recreate → start)</div>
    </div>`;
  }
}

// ---------------- 7DTD CLUSTER tab ----------------
//
// Structural clone of the ARK cluster manager. Same JSON shapes, same
// recreate-dance semantics, but 7DTD shares PLAYER PROGRESSION instead of
// character/dino transfers: the world's Player/ save folder is symlinked
// into the cluster's shared /cluster/Player, so members share
// level/skills/inventory while world + bases stay per-server. No
// "copy settings to siblings" feature here — setup / join / leave only.
const sdtdClusterState = {
  currentCluster: null,   // {id, name, siblings} or null
  allClusters: [],        // all clusters on the node (for Join dropdown)
  loading: false,
};

async function loadSdtdClusterTab() {
  const id = instanceModalState.id; const wrap = $("#im-sdtd-cluster-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Loading cluster info…");
  try {
    const [mine, all] = await Promise.all([
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`),
      fetchJSON(`/api/7dtd/clusters`),
    ]);
    sdtdClusterState.currentCluster = mine.cluster || null;
    sdtdClusterState.allClusters = all.clusters || [];
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load cluster state: ${err.message}`);
    return;
  }
  renderSdtdClusterTab();
}

function renderSdtdClusterTab() {
  const wrap = $("#im-sdtd-cluster-wrap"); if (!wrap) return;
  const id = instanceModalState.id;
  const cluster = sdtdClusterState.currentCluster;
  const others = sdtdClusterState.allClusters.filter(c => !cluster || c.id !== cluster.id);

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">

    <!-- Explainer -->
    <div class="panel" style="padding:14px">
      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
        <span style="font-size:20px">🔗</span>
        <div>
          <div style="font-weight:600;font-size:14px">7DTD cluster manager</div>
          <div class="dim" style="font-size:12px">Share player progression across multiple 7 Days to Die worlds on this node.</div>
        </div>
      </div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-top:6px">
        Setting up a cluster creates a shared Docker volume and symlinks this world's
        <span class="mono">Player/</span> save folder into the cluster's shared <span class="mono">/cluster/Player</span>. Any other 7DTD server on this node can then join the same cluster with one click — members share level, skills, and inventory while each world and its bases stay per-server. The server auto-restarts to apply the mount change.
      </div>
    </div>

    ${cluster
      ? renderSdtdClusterJoined(cluster)
      : renderSdtdClusterStandalone(others)}

    ${renderMedicCard()}

  </div>`;

  // Wire handlers depending on current state.
  if (cluster) {
    const leaveBtn = $("#im-sdtd-leave");
    if (leaveBtn) leaveBtn.addEventListener("click", () => sdtdClusterLeave());
    // Shared-player-world picker: populate from the server's actual worlds and
    // pre-select the current pin (CLUSTER_PLAYER_SAVE in this instance's config).
    const _inst = allInstances.find(x => x.instance_id === instanceModalState.id);
    const _curWorld = (_inst && _inst.config_values && _inst.config_values.CLUSTER_PLAYER_SAVE) || "";
    populateSdtdPlayerWorlds(instanceModalState.id, _curWorld);
    const applyWorldBtn = $("#im-sdtd-apply-world");
    if (applyWorldBtn) applyWorldBtn.addEventListener("click", () => sdtdClusterSetPlayerWorld());
    const syncModsBtn = $("#im-sdtd-sync-mods");
    if (syncModsBtn) syncModsBtn.addEventListener("click", () => sdtdClusterSyncMods());
  } else {
    const setupBtn = $("#im-sdtd-setup");
    if (setupBtn) setupBtn.addEventListener("click", () => sdtdClusterSetup());
    const joinBtn = $("#im-sdtd-join");
    if (joinBtn) joinBtn.addEventListener("click", () => {
      const sel = $("#im-sdtd-join-picker");
      const seed = $("#im-sdtd-seed-mods");
      if (sel && sel.value) sdtdClusterJoin(sel.value, seed ? seed.checked : false);
    });
  }

  // Region Medic settings — shown for every 7DTD server (cluster or not).
  populateMedicConfig(id);
  const medicSaveBtn = $("#im-medic-save");
  if (medicSaveBtn) medicSaveBtn.addEventListener("click", () => saveMedicConfig());
}

// Region Medic & heal-alert config card. Settings persist to region-medic.json in
// the saves volume via the /files API (FsWrite) — NO container recreate; they
// apply on the server's next reboot. Read back + the Discord channel picker are
// populated by populateMedicConfig().
function renderMedicCard() {
  return `
    <div class="panel" style="padding:16px">
      <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
        <span style="font-size:20px">🩺</span>
        <div>
          <div style="font-weight:600;font-size:14px">Region Medic &amp; heal alerts</div>
          <div class="dim" style="font-size:12px;line-height:1.5">On every reboot — while the server is down — validates the world's region files, heals any corrupt ones from a rolling snapshot (then backups), and keeps fast folder snapshots. Saving here does <b>not</b> restart the server.</div>
        </div>
      </div>
      <label style="display:flex;align-items:center;gap:8px;font-size:12.5px;margin:12px 0;cursor:pointer">
        <input type="checkbox" id="im-medic-enabled" checked style="flex:0 0 auto">
        <span>Enable Region Medic on this server</span>
      </label>
      <div style="margin:12px 0">
        <div style="font-weight:600;font-size:12.5px;margin-bottom:4px">Rolling region snapshots to keep</div>
        <input type="number" id="im-medic-keep" min="1" max="20" value="2" style="width:90px">
        <span class="dim" style="font-size:11.5px;margin-left:6px">full folder copies — instant restore, no unzip</span>
      </div>
      <div style="margin:12px 0">
        <div style="font-weight:600;font-size:12.5px;margin-bottom:4px">🗺 World to monitor</div>
        <div class="dim" style="font-size:12px;line-height:1.5;margin-bottom:6px">Which save's regions get validated + healed. <b>Auto-detect</b> uses the active world (GameWorld/GameName) — pick explicitly if this server keeps multiple saves so the medic never touches the wrong one.</div>
        <select id="im-medic-world" style="width:100%"><option value="">Loading worlds…</option></select>
      </div>
      <div style="margin:12px 0">
        <div style="font-weight:600;font-size:12.5px;margin-bottom:4px">📢 Discord heal-alert channel</div>
        <div class="dim" style="font-size:12px;line-height:1.5;margin-bottom:6px">When a reboot heals corrupt regions, RefugeBot posts here — the alert names this server and which snapshot/backup each region was pulled from. Leave on "No alerts" to stay silent.</div>
        <select id="im-medic-channel" style="width:100%"><option value="">Loading channels…</option></select>
      </div>
      <div style="margin-top:14px;display:flex;justify-content:flex-end">
        <button class="small primary" id="im-medic-save">Save medic settings</button>
      </div>
    </div>`;
}

// populateMedicConfig loads the current region-medic.json from the saves volume
// and fills the form, then populates the Discord channel picker from the bot's
// channels (pre-selecting the saved one). All best-effort — an absent config or
// unreachable Discord just yields defaults.
async function populateMedicConfig(id) {
  let cfg = { enabled: true, keep: 2, discord_channel: "" };
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/read?path=${encodeURIComponent("/game-saves/region-medic.json")}`);
    if (r && r.content) { try { cfg = Object.assign(cfg, JSON.parse(r.content)); } catch {} }
  } catch {}
  const en = $("#im-medic-enabled"); if (en) en.checked = cfg.enabled !== false;
  const kp = $("#im-medic-keep"); if (kp) kp.value = cfg.keep || 2;
  // World picker — enumerate the server's saves (World/GameName holding a main.ttw).
  const wsel = $("#im-medic-world");
  if (wsel) {
    try {
      const ROOT = "/game-saves/.local/share/7DaysToDie/Saves";
      const listDir = async (p) => { try { const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}`); return r.entries || []; } catch { return []; } };
      const worldDirs = (await listDir(ROOT)).filter(e => e.is_dir).map(e => e.name);
      const saves = [];
      await Promise.all(worldDirs.map(async (world) => {
        const games = (await listDir(`${ROOT}/${world}`)).filter(e => e.is_dir).map(e => e.name);
        await Promise.all(games.map(async (game) => {
          const files = await listDir(`${ROOT}/${world}/${game}`);
          if (files.some(e => e.name === "main.ttw")) saves.push(`${world}/${game}`);
        }));
      }));
      saves.sort();
      const cur = cfg.world || "";
      let html = `<option value="">— Auto-detect (active world) —</option>`;
      html += saves.map(s => `<option value="${esc(s)}"${s === cur ? " selected" : ""}>${esc(s)}</option>`).join("");
      if (cur && !saves.includes(cur)) html += `<option value="${esc(cur)}" selected>${esc(cur)} (pinned — not on disk)</option>`;
      wsel.innerHTML = html;
    } catch { wsel.innerHTML = `<option value="">— Auto-detect (active world) —</option>`; }
  }
  const sel = $("#im-medic-channel"); if (!sel) return;
  try {
    const r = await fetchJSON("/api/discord/channels");
    const chans = (r && r.channels) || [];
    let html = `<option value="">— No alerts —</option>` +
      chans.map(c => `<option value="${esc(c.id)}"${c.id === cfg.discord_channel ? " selected" : ""}>${esc(c.label)}</option>`).join("");
    if (cfg.discord_channel && !chans.some(c => c.id === cfg.discord_channel)) {
      html += `<option value="${esc(cfg.discord_channel)}" selected>channel ${esc(cfg.discord_channel)} (not in bot's guilds)</option>`;
    }
    sel.innerHTML = html;
  } catch (e) {
    sel.innerHTML = cfg.discord_channel
      ? `<option value="${esc(cfg.discord_channel)}" selected>channel ${esc(cfg.discord_channel)}</option><option value="">— No alerts —</option>`
      : `<option value="">(Discord bot not reachable — alerts off)</option>`;
  }
}

// saveMedicConfig writes region-medic.json into the saves volume via FsWrite.
// No recreate — the medic reads it on the next reboot.
async function saveMedicConfig() {
  const id = instanceModalState.id; if (!id) return;
  const enabled = $("#im-medic-enabled") ? $("#im-medic-enabled").checked : true;
  const keep = Math.max(1, parseInt(($("#im-medic-keep") || {}).value || "2", 10) || 2);
  const channel = ($("#im-medic-channel") || {}).value || "";
  const world = ($("#im-medic-world") || {}).value || "";
  const cfg = { enabled, keep, world, discord_channel: channel };
  const btn = $("#im-medic-save"); if (btn) { btn.disabled = true; btn.textContent = "Saving…"; }
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: "/game-saves/region-medic.json", content: JSON.stringify(cfg, null, 2) })
    });
    toast("Region Medic settings saved — applies on next reboot, no restart needed", "ok");
  } catch (err) {
    toast(`Save failed: ${err.message}`, "err");
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = "Save medic settings"; }
  }
}

function renderSdtdClusterJoined(c) {
  const siblings = c.siblings || [];
  // Drift banner — set by the controller when the registry says this
  // instance is in a cluster but the persisted CONFIG_VALUES.CLUSTER_ID
  // doesn't match (= the recreate dance was rolled back or never
  // actually applied). Operator must Leave + re-Join to repair the
  // mount; otherwise progression won't sync to siblings.
  const driftBanner = c.mount_drift ? `
    <div style="border:1px solid rgba(248,113,113,.45);background:rgba(248,113,113,.07);border-radius:var(--radius);padding:11px 13px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start">
      <span style="font-size:18px;line-height:1">⚠</span>
      <div>
        <div style="font-weight:600;color:var(--err);margin-bottom:3px">Cluster mount didn't take</div>
        <div class="dim" style="font-size:12px;line-height:1.5">
          The cluster registry shows this server as a member, but its container is still using the per-instance volume — progression won't sync to siblings.
          Click <b>Leave cluster</b>, then re-join to recreate the container with the correct shared mount.
        </div>
      </div>
    </div>` : "";
  return `
    <div class="panel" style="padding:16px;border-left:3px solid ${c.mount_drift ? 'rgba(248,113,113,.6)' : 'var(--ok)'}">
      ${driftBanner}
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:10px;flex-wrap:wrap">
        <div style="min-width:0">
          <div style="font-weight:700;font-size:15px">${esc(c.name || 'Cluster')}</div>
          <div class="dim mono" style="font-size:11.5px">${esc(c.id)}</div>
        </div>
        <span style="font-size:11px;padding:4px 10px;border-radius:var(--radius-pill);background:${c.mount_drift ? 'rgba(248,113,113,.15)' : 'rgba(74,222,128,.15)'};color:${c.mount_drift ? 'var(--err)' : 'var(--ok)'};font-weight:600;border:1px solid ${c.mount_drift ? 'rgba(248,113,113,.3)' : 'rgba(74,222,128,.3)'}">${c.mount_drift ? '⚠ Member (mount broken)' : '✓ Member of this cluster'}</span>
      </div>
      <div style="font-size:13px;margin:10px 0 6px">
        <b>${siblings.length === 0 ? 'No siblings yet' : siblings.length + ' other server' + (siblings.length === 1 ? '' : 's') + ' in this cluster'}</b>
        ${siblings.length ? `<div class="mono" style="font-size:11.5px;color:var(--text-dim);margin-top:4px">${siblings.map(esc).join(" · ")}</div>` : ''}
      </div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-top:6px">
        Level, skills, and inventory earned on this server carry across to all siblings — only the world and its bases stay per-server. Any new 7DTD server you create on this node can click <b>Join Cluster</b> and pick <span class="mono">${esc(c.name || c.id)}</span> from the dropdown.
      </div>
      <div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
        <div style="font-weight:600;font-size:13px;margin-bottom:4px">🗺 Shared player world</div>
        <div class="dim" style="font-size:12px;line-height:1.5;margin-bottom:8px">
          Which of this server's worlds shares its <span class="mono">Player</span> folder into the cluster. Pick the active map — <b>required for RWG servers</b> (7DTD names the save folder after the generated seed, not "RWG"). Changing this recreates the server (world data preserved).
        </div>
        <div class="row" style="gap:8px;align-items:flex-end">
          <select id="im-sdtd-player-world" style="flex:1"><option value="">Loading worlds…</option></select>
          <button class="small primary" id="im-sdtd-apply-world">Apply</button>
        </div>
      </div>
      ${c.is_master ? `
      <div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
        <div style="font-weight:600;font-size:13px;margin-bottom:4px">📦 Sync mods to members <span style="font-size:10px;padding:2px 7px;border-radius:var(--radius-pill);background:rgba(96,165,250,.18);color:#93c5fd;font-weight:600;margin-left:4px">this server is the master</span></div>
        <div class="dim" style="font-size:12px;line-height:1.5;margin-bottom:8px">
          Push <b>this server's</b> current <span class="mono">Mods/</span> set to ${siblings.length === 0 ? 'the other members' : siblings.length + ' other member' + (siblings.length === 1 ? '' : 's')}. Upload or update mods here first, then sync. Each member's per-server data (RefugeBot teleport homes) is preserved, and <b>no member is restarted</b> — they load the new mods on their next reboot.
        </div>
        <div id="im-sdtd-sync-result" style="display:none;margin-bottom:8px"></div>
        <div style="display:flex;justify-content:flex-end">
          <button class="small primary" id="im-sdtd-sync-mods"${siblings.length === 0 ? ' disabled' : ''}>⇪ Sync mods to ${siblings.length} member${siblings.length === 1 ? '' : 's'}</button>
        </div>
      </div>` : ''}
      <div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
        <button class="small danger" id="im-sdtd-leave">⏏ Leave cluster</button>
      </div>
    </div>`;
}

function renderSdtdClusterStandalone(others) {
  return `
    <div class="panel" style="padding:16px">
      <div style="font-weight:600;font-size:14px;margin-bottom:10px">🆕 Set up a new cluster</div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-bottom:12px">
        Creates a fresh shared volume for player progression and makes this server its first member. Other 7DTD servers on this node will then see a "Join Cluster" option.
      </div>
      <div style="display:flex;gap:8px;justify-content:flex-end">
        <button class="primary" id="im-sdtd-setup">🔗 Setup Cluster</button>
      </div>
    </div>

    ${others.length ? `
    <div class="panel" style="padding:16px">
      <div style="font-weight:600;font-size:14px;margin-bottom:10px">➕ Join an existing cluster</div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-bottom:12px">
        There ${others.length === 1 ? 'is 1 cluster' : 'are ' + others.length + ' clusters'} already set up on this node. Pick one to join — this server will be recreated with the shared cluster volume.
      </div>
      <label style="display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.5;margin-bottom:11px;cursor:pointer">
        <input type="checkbox" id="im-sdtd-seed-mods" checked style="margin-top:2px;flex:0 0 auto">
        <span>Seed this server's <b>Mods</b> from the cluster master — wipes its current mods and clones the master's exact set so it plays identically. Per-server state (your teleport homes) is preserved.</span>
      </label>
      <div class="row" style="gap:8px;align-items:flex-end">
        <select id="im-sdtd-join-picker" style="flex:1">
          ${others.map(c => `<option value="${esc(c.id)}">${esc(c.name || c.id)} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? '' : 's'}${c.master ? ' · master: ' + esc(c.master) : ''}</option>`).join('')}
        </select>
        <button class="primary" id="im-sdtd-join">➕ Join</button>
      </div>
    </div>` : `
    <div class="dzm-empty"><div class="big">🔗</div>No other clusters on this node yet. Set one up to start sharing player progression across 7DTD worlds.</div>`}`;
}

async function sdtdClusterSetup() {
  const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return;
  if (!confirm("Setup a new 7DTD cluster for this server? The server will stop, recreate with a shared cluster volume, and restart if it was running. World data is preserved.")) return;
  sdtdClusterState.loading = true;
  setSdtdClusterBusy(true, "Creating cluster + recreating server…");
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({}) });
    toast(`Cluster ${r.cluster_id} set up`, "ok");
    await loadSdtdClusterTab();
  } catch (err) {
    toast(`Cluster setup failed: ${err.message}`, "err");
  } finally {
    sdtdClusterState.loading = false;
  }
}

async function sdtdClusterJoin(clusterID, seedMods) {
  const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return;
  const seedNote = seedMods ? " Its mods will be REPLACED with the cluster master's set (your teleport homes are kept)." : "";
  if (!confirm(`Join cluster ${clusterID}? The server will stop, recreate with the shared cluster volume, and restart if it was running.${seedNote}`)) return;
  sdtdClusterState.loading = true;
  setSdtdClusterBusy(true, seedMods ? "Joining cluster + cloning master mods + recreating…" : "Joining cluster + recreating server…");
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster/join`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ cluster_id: clusterID, seed_mods: !!seedMods }) });
    toast(`Joined cluster ${clusterID}${seedMods ? " + cloned master mods" : ""}`, "ok");
    await loadSdtdClusterTab();
  } catch (err) {
    toast(`Join failed: ${err.message}`, "err");
  } finally {
    sdtdClusterState.loading = false;
  }
}

async function sdtdClusterLeave() {
  const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return;
  if (!confirm("Leave the cluster? The server will recreate with its own per-instance cluster volume. Shared progression will no longer reach siblings.")) return;
  sdtdClusterState.loading = true;
  setSdtdClusterBusy(true, "Leaving cluster + recreating server…");
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`,
      { method: "DELETE" });
    toast(`Left cluster`, "ok");
    await loadSdtdClusterTab();
  } catch (err) {
    toast(`Leave failed: ${err.message}`, "err");
  } finally {
    sdtdClusterState.loading = false;
  }
}

// sdtdClusterSyncMods (master only): pushes this server's current Mods/ set to
// every other cluster member, live. Per-server homes.json is preserved and no
// member is restarted — they load the new mods on their next reboot. Renders a
// per-member result list (✓ N mods / ✕ error / ⊘ skipped cross-host).
async function sdtdClusterSyncMods() {
  const id = instanceModalState.id;
  const c = sdtdClusterState.currentCluster;
  if (!id || !c || sdtdClusterState.loading) return;
  const n = (c.siblings || []).length;
  if (n === 0) { toast("No other members to sync", "warn"); return; }
  if (!confirm(`Push this server's current mod set to ${n} other cluster member${n === 1 ? '' : 's'}?\n\nPer-server data (teleport homes) is preserved. Members are NOT restarted — they load the new mods on their next reboot.`)) return;
  const btn = $("#im-sdtd-sync-mods");
  const out = $("#im-sdtd-sync-result");
  const orig = btn ? btn.textContent : "";
  sdtdClusterState.loading = true;
  if (btn) { btn.disabled = true; btn.textContent = "Syncing…"; }
  if (out) { out.style.display = "block"; out.innerHTML = `<div class="dim" style="font-size:12px">⏳ Copying mods to ${n} member${n === 1 ? '' : 's'}… this can take a minute.</div>`; }
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster/sync-mods`, { method: "POST" });
    const rows = (r.results || []).map(res => {
      if (res.skipped) return `<div style="font-size:12px;color:var(--warn)">⊘ ${esc(res.instance)} — skipped (${esc(res.error || '')})</div>`;
      if (res.ok) return `<div style="font-size:12px;color:var(--ok)">✓ ${esc(res.instance)} — ${res.mods} mods copied</div>`;
      return `<div style="font-size:12px;color:var(--err)">✕ ${esc(res.instance)} — ${esc(res.error || 'failed')}</div>`;
    }).join("");
    if (out) out.innerHTML = `
      <div style="border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:rgba(255,255,255,.02)">
        <div style="font-weight:600;font-size:12px;margin-bottom:6px">Synced ${r.synced}/${r.total} member${r.total === 1 ? '' : 's'} <span class="dim" style="font-weight:400">· new mods load on each member's next restart</span></div>
        ${rows || '<div class="dim" style="font-size:12px">No other members to sync.</div>'}
      </div>`;
    toast(`Mods synced to ${r.synced}/${r.total} member${r.total === 1 ? '' : 's'}`, r.synced === r.total ? "ok" : "warn");
  } catch (err) {
    if (out) out.innerHTML = `<div style="font-size:12px;color:var(--err)">Sync failed: ${esc(err.message)}</div>`;
    toast("Mod sync failed: " + err.message, "err");
  } finally {
    sdtdClusterState.loading = false;
    if (btn) { btn.disabled = false; btn.textContent = orig; }
  }
}

// Populate the "Shared player world" dropdown by enumerating this server's
// actual worlds via the existing Files API: Saves/<World>/<GameName>/, keeping
// only folders that hold real save data (a main.ttw file). The value written
// is "<World>/<GameName>" — exactly what the entrypoint's CLUSTER_PLAYER_SAVE
// expects. Pre-selects the current pin. Runs against the running container (or
// the agent's fs helper sidecar if stopped).
async function populateSdtdPlayerWorlds(id, currentValue) {
  const sel = document.getElementById("im-sdtd-player-world");
  if (!sel) return;
  const ROOT = "/game-saves/.local/share/7DaysToDie/Saves";
  const listDir = async (p) => {
    try {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}`);
      return r.entries || [];
    } catch { return []; }
  };
  try {
    const worldDirs = (await listDir(ROOT)).filter(e => e.is_dir).map(e => e.name);
    const saves = [];
    await Promise.all(worldDirs.map(async (world) => {
      const games = (await listDir(`${ROOT}/${world}`)).filter(e => e.is_dir).map(e => e.name);
      await Promise.all(games.map(async (game) => {
        const files = await listDir(`${ROOT}/${world}/${game}`);
        if (files.some(e => e.name === "main.ttw")) saves.push(`${world}/${game}`);
      }));
    }));
    saves.sort();
    const cur = currentValue || "";
    let html = `<option value="">— Auto (GameWorld/GameName from config) —</option>`;
    html += saves.map(s => `<option value="${esc(s)}"${s === cur ? " selected" : ""}>${esc(s)}</option>`).join("");
    if (cur && !saves.includes(cur)) {
      html += `<option value="${esc(cur)}" selected>${esc(cur)} (pinned — not found on disk)</option>`;
    }
    if (!saves.length && !cur) html += `<option value="" disabled>(no generated worlds yet — boot the server first)</option>`;
    sel.innerHTML = html;
  } catch (e) {
    sel.innerHTML = `<option value="">(couldn't list worlds — is the server up?)</option>`;
  }
}

async function sdtdClusterSetPlayerWorld() {
  const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return;
  const sel = document.getElementById("im-sdtd-player-world"); if (!sel) return;
  const val = sel.value;
  const msg = val
    ? `Share players from "${val}" for this server? It recreates (stop → recreate → start) so the cluster Player symlink re-points to that world. World data is preserved.`
    : `Reset to auto (use GameWorld/GameName from config)? The server will recreate.`;
  if (!confirm(msg)) return;
  sdtdClusterState.loading = true;
  setSdtdClusterBusy(true, "Setting shared player world + recreating…");
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/env-config`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ updates: { CLUSTER_PLAYER_SAVE: val } }) });
    toast(val ? `Shared player world set to ${val}` : "Shared player world reset to auto", "ok");
    await loadSdtdClusterTab();
  } catch (err) {
    toast(`Failed: ${err.message}`, "err");
  } finally {
    sdtdClusterState.loading = false;
  }
}

function setSdtdClusterBusy(busy, msg) {
  const wrap = $("#im-sdtd-cluster-wrap"); if (!wrap) return;
  if (busy) {
    wrap.innerHTML = `<div class="empty" style="padding:40px 16px">
      <div style="width:28px;height:28px;margin:0 auto 14px;border:3px solid rgba(255,107,26,.2);border-top-color:var(--accent);border-radius:50%;animation:im-dzm-spin .7s linear infinite"></div>
      <div style="font-size:13px">${esc(msg || 'working…')}</div>
      <div class="dim" style="font-size:11.5px;margin-top:6px">This takes ~30-60 s (stop → delete → recreate → start)</div>
    </div>`;
  }
}

// ---------------- ARK SAVE RESTORE tab ----------------
//
// ARK SA writes rolling backups in ShooterGame/Saved/SavedArks/<MapName>_WP/:
//   <MapName>_WP.ark                                — the ACTIVE save slot
//   <MapName>_WP_<dd.mm.yyyy>_<HH.MM.SS>.ark        — engine rolling backup
//   <MapName>_WP_<dd.mm.yyyy>_<HH.MM.SS>.arkrbf     — rolling backup (binary)
//   <MapName>_WP_<dd.mm.yyyy>_<HH.MM.SS>_replaced.ark — set aside by THIS feature
//   *.bak                                           — engine anti-corruption (skip)
//
// The user pattern that motivated this tab: when the active save is corrupt
// (engine pins on "Invalid readGuid for transform data" or similar), the
// fix is to swap a slightly-older rolling backup into the active slot. The
// engine boots cleanly on it. We preserve the previous active save under a
// "_replaced" name so even abandoned attempts are recoverable.
//
// Auto-discovery: we don't trust env vars to tell us the map directory —
// we list ShooterGame/Saved/SavedArks/ and pick subdirs ending in "_WP".
// Most ARK instances have exactly one; if multiple (cross-map clusters
// sharing a volume), the operator picks via a small selector.

const arkRestoreState = { subdir: null, subdirs: [], entries: [] };

async function loadArkRestoreTab() {
  const id = instanceModalState.id;
  const wrap = $("#im-arkrestore-wrap");
  if (!id || !wrap) return;
  wrap.innerHTML = loadingState("Scanning SavedArks…");
  try {
    const q = new URLSearchParams({ path: "SavedArks" });
    const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`);
    const subdirs = (entries || []).filter(e => e.is_dir && /_WP$/i.test(e.name)).map(e => e.name);
    arkRestoreState.subdirs = subdirs;
    if (subdirs.length === 0) {
      wrap.innerHTML = `<div class="empty">No <span class="mono">&lt;MapName&gt;_WP/</span> directory found under <span class="mono">SavedArks/</span>. Has the server booted at least once?</div>`;
      return;
    }
    if (!arkRestoreState.subdir || !subdirs.includes(arkRestoreState.subdir)) {
      arkRestoreState.subdir = subdirs[0];
    }
    await loadArkRestoreEntries();
  } catch (err) {
    wrap.innerHTML = errorState(`Error: ${err.message}`);
  }
}

async function loadArkRestoreEntries() {
  const id = instanceModalState.id;
  const wrap = $("#im-arkrestore-wrap");
  const subdir = arkRestoreState.subdir;
  if (!id || !wrap || !subdir) return;
  try {
    const q = new URLSearchParams({ path: `SavedArks/${subdir}` });
    const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`);
    arkRestoreState.entries = (entries || []).filter(e =>
      !e.is_dir && (/\.ark$/i.test(e.name) || /\.arkrbf$/i.test(e.name)));
    renderArkRestoreTab();
  } catch (err) {
    wrap.innerHTML = errorState(`Error: ${err.message}`);
  }
}

// parseArkSaveName extracts the timestamp embedded in an ARK rolling-save
// filename. Format: <stem>_<dd.mm.yyyy>_<HH.MM.SS>[_replaced].<ext>
// Returns { stamp: Date, isActive: bool, isReplaced: bool, isRbf: bool }.
function parseArkSaveName(name, stem) {
  const lower = name.toLowerCase();
  const isRbf = lower.endsWith(".arkrbf");
  const stemLower = stem.toLowerCase();
  // Active slot is exactly "<stem>.ark" (no timestamp).
  if (lower === stemLower + ".ark") return { stamp: null, isActive: true, isReplaced: false, isRbf: false };
  // Strip stem prefix + leading underscore + extension.
  const ext = isRbf ? ".arkrbf" : ".ark";
  if (!lower.startsWith(stemLower + "_") || !lower.endsWith(ext)) {
    return { stamp: null, isActive: false, isReplaced: false, isRbf };
  }
  let tail = name.slice(stem.length + 1, name.length - ext.length);
  let isReplaced = false;
  if (tail.endsWith("_replaced")) { tail = tail.slice(0, -"_replaced".length); isReplaced = true; }
  // Expected: dd.mm.yyyy_HH.MM.SS
  const m = tail.match(/^(\d{2})\.(\d{2})\.(\d{4})_(\d{2})\.(\d{2})\.(\d{2})$/);
  if (!m) return { stamp: null, isActive: false, isReplaced, isRbf };
  const [_, dd, mm, yyyy, HH, MM, SS] = m;
  // ARK encodes the rolling-save timestamp in UTC. Build the Date from
  // UTC components so the "how long ago" arithmetic is correct regardless
  // of the operator's local TZ. Without UTC parsing a player on PDT sees
  // a 9-hour-old backup labeled "2 hours ago" because the local-time
  // constructor reads the filename's "02.19.37" as 2 AM local.
  const d = new Date(Date.UTC(+yyyy, +mm - 1, +dd, +HH, +MM, +SS));
  return { stamp: isNaN(d) ? null : d, isActive: false, isReplaced, isRbf };
}

function arkHowLongAgo(date) {
  if (!date) return "—";
  const diff = (Date.now() - date.getTime()) / 1000;
  if (diff < 0) return "just now";
  if (diff < 60) return `${Math.round(diff)}s ago`;
  if (diff < 3600) return `${Math.round(diff/60)}m ago`;
  if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
  if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`;
  if (diff < 86400*30) return `${Math.round(diff/(86400*7))}w ago`;
  return `${Math.round(diff/(86400*30))}mo ago`;
}

function renderArkRestoreTab() {
  const wrap = $("#im-arkrestore-wrap");
  if (!wrap) return;
  const subdir = arkRestoreState.subdir;
  const subdirs = arkRestoreState.subdirs;
  const inst = allInstances.find(x => x.instance_id === instanceModalState.id);
  const isRunning = inst && inst.status === "running";

  // Annotate + sort: active first, then by stamp desc.
  const rows = arkRestoreState.entries.map(e => {
    const meta = parseArkSaveName(e.name, subdir);
    // Fallback to mod_time when the filename has no parsable stamp (engine
    // sometimes drops .arkrbf rolling files with non-standard suffixes).
    const stamp = meta.stamp || (e.mod_time ? new Date(e.mod_time) : null);
    return { e, meta, stamp };
  }).sort((a, b) => {
    if (a.meta.isActive !== b.meta.isActive) return a.meta.isActive ? -1 : 1;
    const ta = a.stamp ? a.stamp.getTime() : 0;
    const tb = b.stamp ? b.stamp.getTime() : 0;
    return tb - ta;
  });

  const subdirSelect = subdirs.length > 1
    ? `<select id="im-arkr-subdir" class="small">${subdirs.map(s =>
        `<option value="${esc(s)}"${s === subdir ? " selected" : ""}>${esc(s)}</option>`).join("")}</select>`
    : `<span class="mono dim">${esc(subdir)}</span>`;

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">
    <div class="panel" style="padding:14px">
      <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
        <span style="font-size:20px">⏪</span>
        <div>
          <div style="font-weight:600;font-size:14px">ARK auto-save restore</div>
          <div class="dim" style="font-size:12px">Roll back to a rolling backup when the active save is corrupt or you'd like to revert.</div>
        </div>
      </div>
      <div class="dim" style="font-size:12px;line-height:1.55;margin-top:6px">
        ARK writes rolling backups every few minutes. Restoring sets one of them as the active save (<span class="mono">${esc(subdir)}.ark</span>) and moves the previous active save aside under a <span class="mono">_replaced</span> name — nothing is deleted, so you can revert again later. The server must be stopped first.
      </div>
      <div style="display:flex;align-items:center;gap:10px;margin-top:10px;flex-wrap:wrap">
        <span class="dim" style="font-size:12px">Map dir:</span>
        ${subdirSelect}
        <button class="small" id="im-arkr-refresh" title="Re-scan">↻ Refresh</button>
        ${isRunning ? `<span class="badge" style="background:rgba(229,115,115,.15);color:var(--err);padding:3px 8px;border-radius:var(--radius-pill);font-size:11.5px">⚠ server running — stop it first to restore</span>` : ""}
      </div>
    </div>

    <div class="panel" style="padding:0;overflow:hidden">
      <table style="width:100%">
        <thead><tr>
          <th style="text-align:left;padding:10px 14px;width:42%">save</th>
          <th style="text-align:left;padding:10px 14px;width:18%">when</th>
          <th style="text-align:left;padding:10px 14px;width:14%">size</th>
          <th style="text-align:left;padding:10px 14px;width:10%">type</th>
          <th style="text-align:right;padding:10px 14px"></th>
        </tr></thead>
        <tbody>${rows.length ? rows.map(({ e, meta, stamp }) => {
          const tag = meta.isActive
            ? `<span class="badge" style="background:rgba(99,196,124,.18);color:var(--ok);padding:2px 7px;border-radius:var(--radius-pill);font-size:11px;margin-left:6px">ACTIVE</span>`
            : meta.isReplaced
              ? `<span class="badge" style="background:rgba(229,196,72,.18);color:var(--warn);padding:2px 7px;border-radius:var(--radius-pill);font-size:11px;margin-left:6px">REPLACED</span>`
              : meta.isRbf
                ? `<span class="badge" style="background:rgba(95,178,255,.18);color:var(--accent-2);padding:2px 7px;border-radius:var(--radius-pill);font-size:11px;margin-left:6px">RBF</span>`
                : "";
          const typeText = meta.isActive ? "active" : meta.isReplaced ? "set aside" : meta.isRbf ? "rolling .arkrbf" : "rolling .ark";
          const ago = stamp ? arkHowLongAgo(stamp) : "—";
          const stampStr = stamp ? stamp.toLocaleString() : "(unknown)";
          return `<tr style="border-top:1px solid var(--border)">
            <td style="padding:10px 14px"><span class="mono" style="font-size:12.5px">${esc(e.name)}</span>${tag}</td>
            <td style="padding:10px 14px"><span title="${esc(stampStr)}">${esc(ago)}</span></td>
            <td style="padding:10px 14px;font-size:12.5px">${esc(fmtBytes(e.size))}</td>
            <td style="padding:10px 14px;font-size:12.5px" class="dim">${esc(typeText)}</td>
            <td style="padding:10px 14px;text-align:right">
              ${meta.isActive ? "" : `<button class="small primary" data-arkr-restore="${esc(e.name)}"${isRunning ? " disabled title=\"Stop the server first\"" : ""}>⏪ Restore</button>`}
            </td>
          </tr>`;
        }).join("") : `<tr><td colspan="5" style="padding:24px;text-align:center" class="dim">(no .ark or .arkrbf files in this directory)</td></tr>`}</tbody>
      </table>
    </div>
  </div>`;

  if (subdirs.length > 1) {
    $("#im-arkr-subdir").addEventListener("change", e => {
      arkRestoreState.subdir = e.target.value;
      loadArkRestoreEntries();
    });
  }
  $("#im-arkr-refresh").addEventListener("click", loadArkRestoreTab);
  $$("#im-arkrestore-wrap [data-arkr-restore]").forEach(b => {
    b.addEventListener("click", () => arkRestoreApply(b.dataset.arkrRestore));
  });
}

async function arkRestoreApply(filename) {
  const id = instanceModalState.id;
  const subdir = arkRestoreState.subdir;
  if (!id || !subdir || !filename) return;
  if (!confirm(`Restore "${filename}" as the active save for ${subdir}?\n\nThe current active save will be moved aside under a "_replaced" name — nothing is deleted, you can revert again from this list.`)) return;
  try {
    const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/save-restore`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ saved_arks_subdir: subdir, target_filename: filename }),
    });
    const aside = res.aside_filename ? ` Previous active save preserved as ${res.aside_filename}.` : "";
    toast(`Restored ${filename}.${aside}`, "ok");
    await loadArkRestoreEntries();
  } catch (err) {
    toast(`Restore failed: ${err.message}`, "err");
  }
}

// ---------------- DAYZ WORKSHOP MODS tab ----------------
//
// CFTools-inspired mod manager. Search the Workshop, install by ID or URL,
// manage install order, uninstall. Install pipeline:
//   1. Controller runs `steamcmd +workshop_download_item 221100 <id>` into
//      the shared panel-dayz-workshop Docker volume.
//   2. Agent creates /game/@ModName -> /game/steamapps/workshop/content/221100/<id>
//      and copies bikeys from the mod's keys/ dir into /game/keys/.
//   3. Controller appends the @ModName to /game-saves/panel-mods.txt (or
//      panel-servermods.txt). The entrypoint reads those on next start.
// Steam credentials are cached encrypted on the controller (same row as the
// DayZ server install), so one login unlocks all future downloads.

const dayzModsState = {
  mods: [],            // [{workshop_id, folder_name, server_mod, order, title?, ...}]
  searchResults: [],   // last Workshop search hits
  searchTerm: "",
  jobs: [],            // active + recently-finished install jobs
  jobPollTimer: null,  // handle for the job-polling setInterval
  // Cutoff timestamp set when the Mods tab opens. Terminal jobs with
  // ended_at BEFORE this are hidden from the UI (stops stale errors
  // from previous sessions cluttering a fresh visit). A 2-second grace
  // catches jobs that literally just finished as we were opening.
  sinceOpenAt: 0,
};

async function loadDayzModsTab() {
  const id = instanceModalState.id; const wrap = $("#im-dzm-wrap"); if (!id) return;
  // Reset the visibility cutoff on every tab-open: everything that was
  // already terminal before now is hidden. 2s grace keeps jobs that
  // literally just finished visible on first paint.
  dayzModsState.sinceOpenAt = Date.now() - 2000;
  wrap.innerHTML = loadingState("Loading mod list…");
  try {
    const [mods, jobs] = await Promise.all([
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`),
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs`).catch(() => ({jobs:[]})),
    ]);
    dayzModsState.mods = mods.mods || [];
    dayzModsState.jobs = jobs.jobs || [];
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`);
    return;
  }
  renderDayzModsTab();
  // Start polling if there are non-terminal jobs, or if the user might start one.
  startDayzModJobPolling();
}

// visibleJobs applies the session cutoff + preserves any job the user
// started in this session (tracked by id against its start moment).
function visibleJobs() {
  const cutoff = dayzModsState.sinceOpenAt || 0;
  return (dayzModsState.jobs || []).filter(j => {
    if (!j.terminal) return true;
    if (!j.ended_at) return true;
    return new Date(j.ended_at).getTime() >= cutoff;
  });
}

// Poll the jobs endpoint while at least one job is non-terminal. Stops
// itself once everything settles. Called on tab open AND after starting
// an install (so we don't wait up to 1s for the next tick).
function startDayzModJobPolling() {
  if (dayzModsState.jobPollTimer) return;
  // Backoff: 800ms while the jobs payload is changing, 2s once it has been
  // identical for 10 consecutive ticks (steamcmd downloads emit no progress
  // deltas for long stretches). Any change snaps back to 800ms.
  let idleTicks = 0, lastJobsJSON = "", curMs = 800;
  const rearm = (ms) => {
    if (curMs === ms || !dayzModsState.jobPollTimer) return;
    curMs = ms;
    clearInterval(dayzModsState.jobPollTimer);
    dayzModsState.jobPollTimer = setInterval(tick, ms);
  };
  const tick = async () => {
    const id = instanceModalState.id;
    if (!id || instanceModalState.tab !== "dayzmods") { stopDayzModJobPolling(); return; }
    try {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs`);
      const prev = dayzModsState.jobs;
      dayzModsState.jobs = r.jobs || [];
      const jobsJSON = JSON.stringify(dayzModsState.jobs);
      if (jobsJSON === lastJobsJSON) {
        if (++idleTicks >= 10) rearm(2000);
      } else {
        lastJobsJSON = jobsJSON; idleTicks = 0; rearm(800);
      }
      // If any job just flipped to 'done', refresh the installed-mod list.
      const wasActive = id => prev.find(p => p.id === id && !p.terminal);
      for (const j of dayzModsState.jobs) {
        if (j.terminal && j.phase === "done" && wasActive(j.id)) {
          try {
            const m = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`);
            dayzModsState.mods = m.mods || [];
          } catch {}
          break;
        }
        if (j.terminal && j.phase === "login_needed" && wasActive(j.id)) {
          // Pop login modal so the operator can re-auth without hunting the Update button.
          const ok = await openSteamLoginModal(j.error || "Steam session expired.");
          if (ok) {
            // Retry the install automatically.
            runDayzModInstallRaw(j.workshop_id, j.server_mod);
          }
        }
      }
      renderDayzModJobs();
      // Keep polling if anything is still in-flight.
      if (!dayzModsState.jobs.some(j => !j.terminal)) {
        // One more tick to let finished cards render their 'done' state,
        // then stop.
        setTimeout(() => {
          if (!dayzModsState.jobs.some(j => !j.terminal)) stopDayzModJobPolling();
        }, 3000);
      }
    } catch (err) {
      // Network blip; keep polling, surface via log only.
      console.warn("job poll failed", err);
    }
  };
  tick();
  dayzModsState.jobPollTimer = setInterval(tick, 800);
}

function stopDayzModJobPolling() {
  if (dayzModsState.jobPollTimer) {
    clearInterval(dayzModsState.jobPollTimer);
    dayzModsState.jobPollTimer = null;
  }
}

function renderDayzModsTab() {
  const wrap = $("#im-dzm-wrap");
  const mods = dayzModsState.mods;
  const client = mods.filter(m => !m.server_mod);
  const server = mods.filter(m =>  m.server_mod);

  const installedRow = (m, kind) => `
    <div class="dzm-mod-row ${kind}">
      <div class="dzm-mod-row-main">
        <div class="dzm-mod-row-title">${esc(m.title || m.folder_name)}</div>
        <div class="dzm-mod-row-sub">
          <span class="mono">${esc(m.folder_name)}</span>
          ${m.workshop_id ? `<span>· </span><button class="small" data-mod-info="${esc(m.workshop_id)}" style="font-size:10.5px;padding:1px 7px" title="Mod info">${esc(m.workshop_id)}</button>` : ''}
          <span class="dzm-mod-row-kind ${kind}">${kind === 'client' ? '-mod=' : '-servermod='}</span>
        </div>
      </div>
      <button class="small danger" data-uninstall="${esc(m.folder_name)}" title="Uninstall">✕</button>
    </div>`;

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">

    <!-- Hero search. Single input that auto-switches between ID-lookup and keyword-search modes. -->
    <div class="panel" style="padding:14px">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px">
        <div style="flex:1;min-width:240px">
          <div style="font-weight:600;font-size:14px">Steam Workshop</div>
          <div class="dim" style="font-size:12px;margin-top:2px">Search by name, or paste a Workshop ID / URL to install directly.</div>
        </div>
      </div>
      <div class="dzm-search-wrap">
        <span class="dzm-search-icon">🔎</span>
        <input id="im-dzm-search-input" class="dzm-search-input" placeholder="Search for a mod — try Community-Online-Tools, Namalsk, Expansion…"
               value="${esc(dayzModsState.searchTerm)}" spellcheck="false" autocomplete="off" />
        <div class="dzm-search-spin" id="im-dzm-search-spin" aria-hidden="true"></div>
        <button class="dzm-search-clear" id="im-dzm-search-clear" title="Clear" style="display:${dayzModsState.searchTerm ? 'block':'none'}">✕</button>
      </div>
      <div class="dzm-search-opts">
        <label><input type="checkbox" id="im-dzm-install-server"> Install as <b style="color:var(--accent-2)">server-side</b> (<span class="mono">-servermod=</span>)</label>
      </div>
      <div id="im-dzm-search-results" style="margin-top:12px"></div>
    </div>

    <!-- Active + recent downloads. -->
    <div id="im-dzm-jobs"></div>

    <!-- Installed mods. -->
    <div class="panel" style="padding:14px">
      <div class="dzm-section">
        <h4>🎮 Client-side mods <span class="dim">(${client.length})</span></h4>
        <span class="dim" style="font-size:11px">loaded with <span class="mono">-mod=</span></span>
      </div>
      ${client.length
        ? `<div class="dzm-list">${client.map(m => installedRow(m, 'client')).join('')}</div>`
        : `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.server}</div>No client-side mods yet. Search above to add one.</div>`}

      <div class="dzm-section">
        <h4>⚙️ Server-side mods <span class="dim">(${server.length})</span></h4>
        <span class="dim" style="font-size:11px">loaded with <span class="mono">-servermod=</span></span>
      </div>
      ${server.length
        ? `<div class="dzm-list">${server.map(m => installedRow(m, 'server')).join('')}</div>`
        : `<div class="dzm-empty" style="padding:14px"><span class="dim" style="font-size:12px">Server-side mods are less common — admin tools like VPPAdminTools belong here.</span></div>`}
    </div>
  </div>`;

  const input = $("#im-dzm-search-input");
  input.addEventListener("input", onDayzSearchInput);
  input.addEventListener("keydown", e => {
    if (e.key === "Enter") {
      // Pressing Enter on a raw ID/URL installs directly.
      const v = input.value.trim();
      const m = v.match(/(\d{6,20})/);
      if (m) { runDayzModInstallRaw(m[1], !!$("#im-dzm-install-server")?.checked); input.value = ""; renderDayzSearchResults(); }
    } else if (e.key === "Escape") {
      input.value = ""; onDayzSearchInput({ target: input });
    }
  });
  $("#im-dzm-search-clear").addEventListener("click", () => {
    input.value = ""; input.focus(); onDayzSearchInput({ target: input });
  });
  $$("#im-dzm-wrap [data-uninstall]").forEach(b => b.addEventListener("click", () => runDayzModUninstall(b.dataset.uninstall)));
  $$("#im-dzm-wrap [data-mod-info]").forEach(b => b.addEventListener("click", () => openModInfoModal(b.dataset.modInfo)));

  renderDayzSearchResults();
  renderDayzModJobs();

  // Auto-focus the search on first paint only — not on every re-render,
  // otherwise typing would be interrupted whenever jobs poll triggers
  // renderDayzModJobs and its side-effects.
  if (!dayzModsState.searchTerm) setTimeout(() => input && input.focus(), 50);
}

// onDayzSearchInput: debounced autocomplete. Runs on every keystroke.
// Two modes:
//   - Input matches a Workshop ID / URL → fetch /workshop/detail,
//     render a single big preview card with an Install button.
//   - Otherwise → fetch /workshop/search?q=… , render grid of results.
// AbortController cancels in-flight requests so only the newest query lands.
function onDayzSearchInput(e) {
  const v = (e.target.value || "").trim();
  dayzModsState.searchTerm = v;
  const clear = $("#im-dzm-search-clear");
  if (clear) clear.style.display = v ? "block" : "none";
  clearTimeout(dayzModsState.searchDebounce);
  if (dayzModsState.searchController) { try { dayzModsState.searchController.abort(); } catch {} }
  if (!v) {
    dayzModsState.searchResults = [];
    dayzModsState.searchPreview = null;
    toggleDayzSearchSpin(false);
    renderDayzSearchResults();
    return;
  }
  toggleDayzSearchSpin(true);
  dayzModsState.searchDebounce = setTimeout(() => runDayzLiveSearch(v), 300);
}

function toggleDayzSearchSpin(on) {
  const s = $("#im-dzm-search-spin");
  if (s) s.classList.toggle("on", !!on);
}

async function runDayzLiveSearch(q) {
  const id = instanceModalState.id; if (!id) return;
  const ctrl = new AbortController();
  dayzModsState.searchController = ctrl;
  try {
    // Numeric-only or URL with an id → treat as direct lookup.
    const idMatch = q.match(/^\s*(?:https?:\/\/\S*\?id=)?(\d{6,20})\s*$/);
    if (idMatch) {
      const wsID = idMatch[1];
      const d = await fetch(`/api/workshop/detail?id=${encodeURIComponent(wsID)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      dayzModsState.searchPreview = d;
      dayzModsState.searchResults = [];
    } else {
      const d = await fetch(`/api/workshop/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      dayzModsState.searchResults = d.items || [];
      dayzModsState.searchPreview = null;
    }
  } catch (err) {
    if (err.name === "AbortError") return;
    console.warn("live search failed", err);
  } finally {
    if (dayzModsState.searchController === ctrl) {
      dayzModsState.searchController = null;
      toggleDayzSearchSpin(false);
    }
  }
  renderDayzSearchResults();
}

// renderDayzSearchResults paints the search-mode body: either a single
// detail preview card (for ID lookups) or the grid of search hits.
function renderDayzSearchResults() {
  const host = $("#im-dzm-search-results"); if (!host) return;
  const q = dayzModsState.searchTerm || "";
  if (!q) { host.innerHTML = ""; return; }
  if (dayzModsState.searchPreview) {
    host.innerHTML = renderDayzSearchPreview(dayzModsState.searchPreview);
    const btn = $("#im-dzm-preview-install");
    if (btn) btn.addEventListener("click", () => {
      const d = dayzModsState.searchPreview;
      runDayzModInstallRaw(d.workshop_id, !!$("#im-dzm-install-server")?.checked);
    });
    const info = $("#im-dzm-preview-info");
    if (info) info.addEventListener("click", () => openModInfoModal(dayzModsState.searchPreview.workshop_id));
    return;
  }
  const items = dayzModsState.searchResults || [];
  if (!items.length) {
    host.innerHTML = `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.search}</div>No results for "${esc(q)}". Try a shorter term, or paste a Workshop URL.</div>`;
    return;
  }
  const installed = new Set(dayzModsState.mods.map(m => String(m.workshop_id)).filter(Boolean));
  host.innerHTML = `<div class="dzm-grid">${items.slice(0, 30).map(it => dayzSearchCard(it, installed.has(String(it.id)))).join("")}</div>`;
  $$("#im-dzm-search-results [data-mod-card]").forEach(c =>
    c.addEventListener("click", e => {
      if (e.target.closest("[data-install-search]")) return; // button handled below
      openModInfoModal(c.dataset.modCard);
    }));
  $$("#im-dzm-search-results [data-install-search]").forEach(b =>
    b.addEventListener("click", e => {
      e.stopPropagation();
      runDayzModInstallRaw(b.dataset.installSearch, !!$("#im-dzm-install-server")?.checked);
    }));
}

function dayzSearchCard(it, isInstalled) {
  return `<div class="dzm-card" data-mod-card="${esc(it.id)}" title="Click for details">
    ${it.preview_url ? `<img class="dzm-card-img" src="${esc(it.preview_url)}" alt="">` : ''}
    <div class="dzm-card-body">
      <div class="dzm-card-title">${esc(it.title || '(untitled)')}</div>
      <div class="dzm-card-meta">
        <span class="mono">${esc(it.id)}</span>
      </div>
      <div class="dzm-card-foot">
        ${isInstalled
          ? `<span class="dzm-card-badge-installed">✓ Installed</span>`
          : `<button class="small primary" data-install-search="${esc(it.id)}">⬇ Install</button>`}
      </div>
    </div>
  </div>`;
}

function renderDayzSearchPreview(d) {
  const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; };
  const installed = dayzModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id));
  return `<div class="dzm-preview">
    ${d.preview_url ? `<img class="dzm-preview-img" src="${esc(d.preview_url)}" alt="">` : `<div class="dzm-preview-img" style="display:flex;align-items:center;justify-content:center;color:var(--muted)">no preview</div>`}
    <div class="dzm-preview-body">
      <div class="dzm-preview-title">${esc(d.title || 'Workshop #' + d.workshop_id)}</div>
      <div class="dzm-preview-meta">
        <span>📦 ${fmtSize(d.size)}</span>
        <span>🆔 <span class="mono">${esc(d.workshop_id)}</span></span>
        ${d.time_updated ? `<span>🕒 updated ${new Date(d.time_updated*1000).toLocaleDateString()}</span>` : ''}
      </div>
      <div class="dim" style="font-size:12px;margin-top:2px">Paste a URL or ID — we'll grab the metadata and install on click.</div>
    </div>
    <div class="dzm-preview-actions">
      ${installed
        ? `<span class="dzm-card-badge-installed" style="text-align:center;padding:8px 14px">✓ Installed</span>`
        : `<button class="primary" id="im-dzm-preview-install">⬇ Install</button>`}
      <button class="small" id="im-dzm-preview-info">ℹ Full details</button>
    </div>
  </div>`;
}

async function runDayzModInstall(overrideID) {
  const workshopID = typeof overrideID === "string"
    ? overrideID
    : (($("#im-dzm-install-id") || {}).value || "").trim();
  if (!workshopID) { toast("Paste a Workshop ID or URL first", "err"); return; }
  const serverMod = !!($("#im-dzm-install-server") && $("#im-dzm-install-server").checked);
  await runDayzModInstallRaw(workshopID, serverMod);
}

// runDayzModInstallRaw is the low-level version used both by the main
// Install button and by the mod-info modal's Install button. Returns once
// the job is QUEUED — the actual download runs in the background and the
// polling loop picks up progress. No blocking UI states here; the download
// card does all the feedback.
async function runDayzModInstallRaw(workshopID, serverMod) {
  const id = instanceModalState.id; if (!id) return;
  const body = JSON.stringify({ workshop_id: workshopID, server_mod: !!serverMod });
  try {
    let r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`,
      { method: "POST", headers: {"Content-Type":"application/json"}, body });
    if (r && r.steam_login_required) {
      const ok = await openSteamLoginModal(r.reason || "DayZ Workshop downloads require a Steam account that owns the game.");
      if (!ok) return;
      r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`,
        { method: "POST", headers: {"Content-Type":"application/json"}, body });
      if (r && r.steam_login_required) throw new Error("Steam login didn't take; try again.");
    }
    if (r.duplicate) {
      toast(`Already downloading this mod — see the download card`, "ok");
    } else {
      toast(`Queued ${r.title || workshopID}`, "ok");
    }
    // Immediately show a placeholder card so the user sees feedback without
    // waiting 1s for the poll tick to bring it in.
    dayzModsState.jobs = [{
      id: r.job_id, workshop_id: r.workshop_id, folder_name: r.folder_name,
      title: r.title, phase: "queued", percent: 0, lines: ["queued"], terminal: false,
    }, ...dayzModsState.jobs.filter(j => j.id !== r.job_id)];
    renderDayzModJobs();
    startDayzModJobPolling();
  } catch (err) {
    toast(`Install failed: ${err.message}`, "err");
  }
}

// renderDayzModJobs paints the active + recently-finished install jobs as
// a stack of cards with progress bars, phase pills, and a tail of recent
// steamcmd log lines. Called both on tab-open and from the polling loop.
function renderDayzModJobs() {
  const host = $("#im-dzm-jobs");
  if (!host) return;
  const jobs = visibleJobs();
  if (!jobs.length) { host.innerHTML = ""; return; }
  const phaseMeta = {
    queued:       { label: "Queued",      color: "var(--ink-3, #9ca3af)", spin: true  },
    downloading:  { label: "Downloading", color: "var(--accent-2)", spin: true  },
    linking:      { label: "Linking",     color: "var(--accent-2)", spin: true  },
    finalizing:   { label: "Finalizing",  color: "var(--accent-2)", spin: true  },
    done:         { label: "Done",        color: "var(--ok)", spin: false },
    error:        { label: "Failed",      color: "var(--err)", spin: false },
    login_needed: { label: "Sign in to Steam", color: "var(--warn)", spin: false },
  };
  const fmtSize = (b) => {
    if (!b) return "";
    const mb = b / 1048576;
    if (mb < 1)    return `${Math.round(b/1024)} KB`;
    if (mb < 1024) return `${mb.toFixed(1)} MB`;
    return `${(mb/1024).toFixed(2)} GB`;
  };
  const activeCount = jobs.filter(j=>!j.terminal).length;
  const recentCount = jobs.filter(j=>j.terminal).length;
  host.innerHTML = `<div class="panel" style="padding:14px">
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
      <div style="font-weight:600;font-size:13px">📦 Downloads <span class="dim" style="font-weight:400">(${activeCount} active · ${recentCount} recent)</span></div>
      ${recentCount ? `<button class="small" id="im-dzm-clear-recent" title="Dismiss all finished cards">✕ Clear finished</button>` : ''}
    </div>
    <div style="display:flex;flex-direction:column;gap:10px">
      ${jobs.map(j => {
        const meta = phaseMeta[j.phase] || { label: j.phase || "?", color: "#9ca3af", spin: false };
        const titleText = j.title || `Workshop #${j.workshop_id}`;
        const tail = (j.lines || []).slice(-3);
        const pct = Math.max(0, Math.min(100, Math.round(j.percent || 0)));
        const showBar = j.phase === "downloading" || j.phase === "linking" || j.phase === "finalizing" || j.phase === "done";
        const barInner = j.phase === "done"
          ? `<div style="width:100%;height:100%;background:${meta.color}"></div>`
          : (pct > 0
              ? `<div style="width:${pct}%;height:100%;background:${meta.color};transition:width .3s"></div>`
              : `<div style="width:40%;height:100%;background:linear-gradient(90deg,transparent 0%,${meta.color} 50%,transparent 100%);animation:im-dzm-shimmer 1.2s linear infinite"></div>`);
        // Button row: Dismiss is always available on terminal jobs.
        // Retry is only shown when the server classified the error as
        // recoverable (can_retry=true). Login-needed gets "Sign in" instead.
        let actions = "";
        if (j.terminal) {
          const parts = [];
          if (j.phase === "error" && j.can_retry) parts.push(`<button class="small" data-job-retry="${esc(j.id)}">↻ Retry</button>`);
          if (j.phase === "login_needed")        parts.push(`<button class="small primary" data-job-login="${esc(j.id)}">🎮 Sign in to Steam</button>`);
          parts.push(`<button class="small" data-job-dismiss="${esc(j.id)}">✕ Dismiss</button>`);
          actions = `<div style="margin-top:6px;display:flex;gap:6px;justify-content:flex-end">${parts.join("")}</div>`;
        }
        // Job-status class drives border + animation.
        const jobCls = j.terminal ? (j.phase === 'done' ? 'done' : (j.phase === 'login_needed' ? 'login_needed' : 'error')) : 'active';
        return `<div class="dzm-job ${jobCls}" data-job="${esc(j.id)}">
          <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:4px">
            <div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
              <span style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(titleText)}</span>
              <span class="dim" style="font-size:11px">${esc(j.folder_name || '')}</span>
              ${j.size ? `<span class="dim" style="font-size:11px">· ${fmtSize(j.size)}</span>` : ''}
              ${j.attempt && j.attempt > 1 && !j.terminal ? `<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-pill);background:rgba(251,191,36,.15);color:var(--warn);font-weight:600">retry #${j.attempt}</span>` : ''}
              ${j.permanent ? `<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-pill);background:rgba(248,113,113,.15);color:var(--err);font-weight:600">no retry</span>` : ''}
            </div>
            <span style="font-size:10.5px;padding:3px 10px;border-radius:var(--radius-pill);background:color-mix(in srgb, ${meta.color} 13%, transparent);color:${meta.color};font-weight:600;white-space:nowrap">${esc(meta.label)}${showBar && pct && j.phase!=="done" ? ` · ${pct}%` : ''}</span>
          </div>
          ${showBar ? `<div class="dzm-job-progress">${pct > 0 || j.phase === 'done'
              ? `<div style="width:${j.phase==='done'?100:pct}%"></div>`
              : `<div style="width:40%;background:linear-gradient(90deg,transparent 0%,${meta.color} 50%,transparent 100%);animation:im-dzm-shimmer 1.2s linear infinite"></div>`}</div>` : ''}
          ${j.phase === 'error' ? `<div style="font-size:12px;color:var(--err);padding:8px 10px;background:rgba(248,113,113,.08);border-left:3px solid var(--err);border-radius:4px;margin-bottom:6px">${esc(j.error || 'Unknown error')}</div>` : ''}
          ${j.phase === 'login_needed' ? `<div style="font-size:12px;color:var(--warn);padding:8px 10px;background:rgba(251,191,36,.08);border-left:3px solid var(--warn);border-radius:4px;margin-bottom:6px">${esc(j.error || 'Steam session expired')}</div>` : ''}
          ${tail.length ? `<pre class="dzm-job-log">${esc(tail.join("\n"))}</pre>` : ''}
          ${actions}
        </div>`;
      }).join('')}
    </div>
  </div>`;
  $$("#im-dzm-jobs [data-job-retry]").forEach(b => b.addEventListener("click", () => {
    const j = (dayzModsState.jobs || []).find(x => x.id === b.dataset.jobRetry);
    if (j) {
      // Retry spawns a FRESH job; dismiss the old card so the UI
      // doesn't carry stale lines through the new run's render.
      dismissDayzModJob(j.id, false);
      runDayzModInstallRaw(j.workshop_id, j.server_mod);
    }
  }));
  $$("#im-dzm-jobs [data-job-dismiss]").forEach(b => b.addEventListener("click", () => dismissDayzModJob(b.dataset.jobDismiss, true)));
  $$("#im-dzm-jobs [data-job-login]").forEach(b => b.addEventListener("click", async () => {
    const j = (dayzModsState.jobs || []).find(x => x.id === b.dataset.jobLogin);
    if (!j) return;
    const ok = await openSteamLoginModal(j.error || "Sign in to Steam to refresh the cached token.");
    if (ok) {
      dismissDayzModJob(j.id, false);
      runDayzModInstallRaw(j.workshop_id, j.server_mod);
    }
  }));
  const clearBtn = $("#im-dzm-clear-recent");
  if (clearBtn) clearBtn.addEventListener("click", async () => {
    const terminal = jobs.filter(j => j.terminal);
    for (const j of terminal) await dismissDayzModJob(j.id, false, /*quiet*/ true);
    renderDayzModJobs();
  });
}

async function dismissDayzModJob(jobID, rerender = true, quiet = false) {
  const id = instanceModalState.id; if (!id) return;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs/${encodeURIComponent(jobID)}`, { method: "DELETE" });
  } catch (err) {
    if (!quiet) toast(`Dismiss failed: ${err.message}`, "err");
    return;
  }
  dayzModsState.jobs = (dayzModsState.jobs || []).filter(j => j.id !== jobID);
  if (rerender) renderDayzModJobs();
}

async function runDayzModUninstall(folder) {
  const id = instanceModalState.id; if (!id) return;
  if (!confirm(`Uninstall ${folder}? This removes the symlink, bikeys, and its entry from the mod list (the Workshop cache is kept — other instances may use it).`)) return;
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods/${encodeURIComponent(folder)}`,
      { method: "DELETE" });
    const removed = (r.bikeys_removed || []).length;
    toast(`Removed ${folder}${removed ? ` (−${removed} bikey${removed === 1 ? '' : 's'})` : ''}`, "ok");
    await loadDayzModsTab();
  } catch (err) {
    toast(`Uninstall failed: ${err.message}`, "err");
  }
}

// Legacy runDayzModSearch + renderDayzModSearchResults were superseded by
// onDayzSearchInput + renderDayzSearchResults above (live/debounced). The
// old implementations are intentionally removed — anything that called them
// now funnels through the new pipeline via the hero search input.

// ---- mod info modal ----
//
// Opened from a Workshop search result card. Fetches full details from
// /api/workshop/detail and renders title, preview, description (BBCode-ish,
// rendered as plain text with URLs autolinked), stats, tags, and an Install
// button right inside the modal.

async function openModInfoModal(workshopID) {
  const body = $("#mi-body");
  $("#mi-title").textContent = "Mod info";
  body.innerHTML = loadingState("Loading Steam Workshop details…");
  showModal("#mod-info-modal");
  let d;
  try {
    d = await fetchJSON(`/api/workshop/detail?id=${encodeURIComponent(workshopID)}`);
  } catch (err) {
    body.innerHTML = errorState(`Couldn't load details: ${err.message}`);
    return;
  }
  $("#mi-title").textContent = d.title || "Mod info";
  const installed = dayzModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id));
  const fmtSize = (b) => {
    if (!b) return "?";
    const mb = b / 1048576;
    if (mb < 1) return `${Math.round(b/1024)} KB`;
    if (mb < 1024) return `${mb.toFixed(1)} MB`;
    return `${(mb/1024).toFixed(2)} GB`;
  };
  const fmtDate = (unix) => {
    if (!unix) return "—";
    const dt = new Date(unix * 1000);
    return dt.toLocaleDateString() + " " + dt.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
  };
  const fmtNum = (n) => {
    n = Number(n) || 0;
    if (n < 1000) return String(n);
    if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k";
    return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M";
  };
  // Workshop descriptions are BBCode-like with [b][i][url][img] tokens.
  // Render as plain text with some safe transforms:
  //   [url=X]Y[/url] → <a href=X>Y</a>  (only https:// URLs honored)
  //   [h1]..[/h1]   → <b>..</b>
  //   [b]..[/b]     → <b>..</b>
  //   [i]..[/i]     → <i>..</i>
  //   newlines → <br>
  // Drop all other bbcode tags (no [img] — too easy to abuse).
  const renderDesc = (s) => {
    if (!s) return "<i>No description.</i>";
    let t = esc(s);
    t = t.replace(/\[url=(https?:\/\/[^\s\]]+)\]([^\[]*)\[\/url\]/gi,
      (_, u, inner) => `<a href="${u}" target="_blank" rel="noopener">${inner || u}</a>`);
    t = t.replace(/\[h[1-6]\](.*?)\[\/h[1-6]\]/gi, "<b>$1</b>");
    t = t.replace(/\[b\](.*?)\[\/b\]/gi, "<b>$1</b>");
    t = t.replace(/\[i\](.*?)\[\/i\]/gi, "<i>$1</i>");
    t = t.replace(/\[u\](.*?)\[\/u\]/gi, "<u>$1</u>");
    // Strip any remaining tags.
    t = t.replace(/\[\/?[a-z][^\]]*\]/gi, "");
    t = t.replace(/\n/g, "<br>");
    return t;
  };

  // Find the installed folder name (if any) so we can scan for bundled XML.
  const installedMod = dayzModsState.mods.find(m => String(m.workshop_id) === String(d.workshop_id));

  body.innerHTML = `
    <div style="display:grid;grid-template-columns:240px 1fr;gap:16px;align-items:start">
      <div>
        ${d.preview_url ? `<img src="${esc(d.preview_url)}" alt="" style="width:100%;border-radius:var(--radius);background:#111;display:block">` : ''}
        <div style="margin-top:10px;display:flex;flex-direction:column;gap:6px;font-size:12px">
          <div style="display:flex;justify-content:space-between"><span class="dim">Size</span><span class="mono">${fmtSize(d.size)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Updated</span><span>${fmtDate(d.time_updated)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Created</span><span>${fmtDate(d.time_created)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Subscribers</span><span>${fmtNum(d.subscriptions)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Favorites</span><span>${fmtNum(d.favorited)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Views</span><span>${fmtNum(d.views)}</span></div>
        </div>
        <div style="margin-top:10px;font-size:12px">
          <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${esc(d.workshop_id)}" target="_blank" rel="noopener">↗ Open on Steam</a>
        </div>
      </div>
      <div style="min-width:0">
        ${(d.tags || []).length ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:6px">
          ${d.tags.map(tagChip).join("")}
        </div>` : ''}
        <div style="font-size:12.5px;line-height:1.55;max-height:340px;overflow:auto;padding-right:6px;white-space:normal">
          ${renderDesc(d.description)}
        </div>
      </div>
    </div>
    ${installedMod ? `
    <div id="mi-xml-section" style="margin-top:16px;padding-top:14px;border-top:1px solid var(--border)">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
        <div style="font-weight:600;font-size:13px">🧩 Bundled XML snippets</div>
        <span class="dim" style="font-size:11px">Central Economy drop-ins shipped inside this mod</span>
      </div>
      <div id="mi-xml-body" class="dim" style="font-size:12px">Scanning…</div>
    </div>` : ''}
    <div style="margin-top:16px;display:flex;gap:10px;justify-content:flex-end;align-items:center">
      <label style="font-size:12px;display:flex;align-items:center;gap:6px">
        <input type="checkbox" id="mi-server-mod"> server-side only
      </label>
      <button data-close>Close</button>
      ${installed
        ? installedBadge()
        : `<button class="primary" id="mi-install">⬇ Install</button>`}
    </div>`;

  if (installedMod) loadModXmlScan(instanceModalState.id, installedMod.folder_name);
  const btn = $("#mi-install");
  if (btn) btn.addEventListener("click", async () => {
    btn.disabled = true; btn.textContent = "⏳ Queuing…";
    try {
      const serverMod = !!($("#mi-server-mod") && $("#mi-server-mod").checked);
      await runDayzModInstallRaw(d.workshop_id, serverMod);
      hideModal("#mod-info-modal");
    } catch (err) {
      toast(`Install failed: ${err.message}`, "err");
    } finally {
      btn.disabled = false; btn.textContent = "⬇ Install";
    }
  });
}

// loadModXmlScan hits GET /dayz/mods/{folder}/xml and renders a row per
// discovered XML file with an "Integrate" button. Clicking the button reads
// the file from the container via the existing /files/read endpoint and
// funnels its content through the XML Integration tab's preview+commit flow.
async function loadModXmlScan(instanceID, folder) {
  const body = $("#mi-xml-body");
  if (!body) return;
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/dayz/mods/${encodeURIComponent(folder)}/xml`);
    const files = r.files || [];
    if (!files.length) {
      body.innerHTML = `<div style="font-size:12px;padding:4px 0">No XML files in this mod — it doesn't ship Central Economy snippets (common for QoL / admin mods).</div>`;
      return;
    }
    body.innerHTML = `
      <div style="display:flex;flex-direction:column;gap:6px">
        ${files.map((f, i) => `
          <div class="row" style="justify-content:space-between;padding:8px 10px;background:rgba(255,255,255,0.02);border:1px solid var(--border);border-radius:var(--radius);font-size:12px">
            <div style="display:flex;align-items:center;gap:10px;min-width:0">
              <span class="mono" style="font-weight:600">${esc(f.path)}</span>
              <span class="dim" style="font-size:11px">${Math.round((f.size||0)/1024)} KB</span>
              ${f.kind ? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--radius-pill);background:rgba(96,165,250,0.15);color:#60a5fa;font-weight:600">${esc(f.kind)}</span>` : `<span class="dim" style="font-size:10.5px">kind auto-detect</span>`}
            </div>
            <div style="display:flex;gap:6px">
              <button class="small" data-mi-xml-preview="${i}">🔍 Preview</button>
              <button class="small primary" data-mi-xml-integrate="${i}">✅ Integrate</button>
            </div>
          </div>`).join("")}
      </div>
      <div id="mi-xml-result" style="margin-top:10px"></div>`;
    const srcPath = (i) => `/game/${folder}/${files[i].path}`;
    const act = async (i, dryRun) => {
      const out = $("#mi-xml-result");
      out.innerHTML = `<div class="empty" style="padding:10px">reading + ${dryRun ? "previewing" : "committing"} ${esc(files[i].path)}…</div>`;
      try {
        const rd = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files/read?path=${encodeURIComponent(srcPath(i))}`);
        const snippet = rd.content || "";
        const body = JSON.stringify({ mission: "", kind: files[i].kind || "", snippet });
        const pr = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/dayz/xml/${dryRun ? "preview" : "commit"}`,
          { method: "POST", headers: {"Content-Type":"application/json"}, body });
        const plan = pr.plan || {};
        const bucket = (label, arr, color) => (arr && arr.length)
          ? `<div style="margin:4px 0"><b style="color:${color};font-size:12px">${label} (${arr.length})</b> <span class="mono" style="font-size:11px;color:var(--text-dim)">${arr.slice(0,10).map(esc).join(", ")}${arr.length>10?` (+${arr.length-10} more)`:""}</span></div>`
          : "";
        out.innerHTML = `
          <div class="panel" style="padding:12px">
            <div style="display:flex;justify-content:space-between;margin-bottom:6px">
              <div><b>${dryRun ? "Preview" : "Committed"} · ${esc(plan.kind || files[i].kind || "?")}</b> <span class="dim mono" style="font-size:11px">${esc(pr.path || "")}</span></div>
              <div class="dim" style="font-size:11.5px">+${(plan.added||[]).length} ~${(plan.replaced||[]).length} =${(plan.unchanged||[]).length}</div>
            </div>
            ${bucket("Added", plan.added, "var(--ok)")}
            ${bucket("Replaced", plan.replaced, "var(--warn)")}
            ${(plan.warnings||[]).length ? `<div style="margin-top:6px;padding:8px;background:rgba(245,158,11,0.08);border-left:3px solid #f59e0b;border-radius:4px;font-size:11.5px"><b>Warnings:</b> ${plan.warnings.map(esc).join(" · ")}</div>` : ""}
          </div>`;
        if (!dryRun) toast(`Integrated ${files[i].path} → ${pr.path} (backup: ${pr.backup_path})`, "ok");
      } catch (err) {
        out.innerHTML = `<div class="empty" style="padding:10px">Failed: ${esc(err.message)}</div>`;
      }
    };
    $$("#mi-xml-body [data-mi-xml-preview]").forEach(b => b.addEventListener("click", () => act(Number(b.dataset.miXmlPreview), true)));
    $$("#mi-xml-body [data-mi-xml-integrate]").forEach(b => b.addEventListener("click", () => act(Number(b.dataset.miXmlIntegrate), false)));
  } catch (err) {
    body.innerHTML = `<div style="font-size:12px;padding:4px 0">Couldn't scan mod for XML: ${esc(err.message)}</div>`;
  }
}

// ---------------- DAYZ XML MODS tab ----------------
//
// Lets operators drop in a mod's types.xml / events.xml / cfgspawnabletypes.xml
// snippet and merge it into the running server's mission files. Shows a dry-run
// preview (added/replaced/unchanged counts + first N names) before commit.
// Commit auto-backs up the prior file as <file>.bak.<timestamp>.

const dayzXmlState = {
  files: [],       // from /dayz/xml/files
  mission: "",
  snippet: "",
  pickedKind: "",  // override kind (empty = infer from snippet root)
  pickedPath: "",
  plan: null,      // last dry-run result
};

async function loadDayzXmlTab() {
  const id = instanceModalState.id; const wrap = $("#im-dzx-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Probing mission files…");
  try {
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/xml/files`);
    dayzXmlState.files = r.files || [];
    dayzXmlState.mission = r.mission || "";
  } catch (err) {
    wrap.innerHTML = `<div class="empty">Couldn't list XML files: ${esc(err.message)}<div class="hint">Make sure the server has started at least once so the mission folder exists.</div></div>`;
    return;
  }
  renderDayzXmlTab();
}

function renderDayzXmlTab() {
  const wrap = $("#im-dzx-wrap");
  const files = dayzXmlState.files;
  const existsCount = files.filter(f => f.exists).length;
  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">
    <div class="panel" style="padding:16px">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap">
        <div style="flex:1;min-width:260px">
          <div style="font-weight:600">DayZ XML merger</div>
          <div class="dim" style="font-size:12px;margin-top:4px">
            Drop a mod's snippet XML below. We'll preview the diff before touching your files, then commit with an auto-backup.
            Target: <span class="mono">${esc(dayzXmlState.mission)}</span> (${existsCount}/${files.length} supported files found)
          </div>
        </div>
      </div>
    </div>

    <div class="panel" style="padding:16px">
      <div class="form-grid" style="padding:0;grid-template-columns:160px 1fr">
        <label>Target file</label>
        <div class="row">
          <select id="im-dzx-target">
            <option value="">— auto-detect from snippet root —</option>
            ${files.map(f => `<option value="${esc(f.kind)}" ${f.exists ? '' : 'disabled'}>${esc(f.label)} ${f.exists ? '('+f.size+' B)' : '(missing)'}</option>`).join("")}
          </select>
          <span class="form-hint">Leave on auto-detect unless the snippet's root tag is ambiguous or wrong.</span>
        </div>

        <label>Mod snippet</label>
        <div class="row">
          <input id="im-dzx-file" type="file" accept=".xml,text/xml,application/xml" style="display:none">
          <button id="im-dzx-file-btn" class="small">📁 Pick .xml file…</button>
          <span id="im-dzx-file-name" class="dim" style="font-size:12px;margin-left:8px">no file selected</span>
          <span class="form-hint">Or paste below.</span>
        </div>

        <label>Snippet content</label>
        <div class="row">
          <textarea id="im-dzx-snippet" rows="12" spellcheck="false" style="font-family:var(--mono);font-size:11.5px;width:100%" placeholder="&lt;types&gt;&#10;    &lt;type name=&quot;MyModItem&quot;&gt;...&lt;/type&gt;&#10;&lt;/types&gt;"></textarea>
        </div>
      </div>
      <div style="margin-top:12px;display:flex;gap:10px;justify-content:flex-end">
        <button id="im-dzx-preview" class="primary">🔍 Preview merge</button>
        <button id="im-dzx-commit" style="display:none">✅ Commit merge</button>
      </div>
    </div>

    <div class="panel" id="im-dzx-result-wrap" style="display:none;padding:16px"></div>
  </div>`;

  $("#im-dzx-target").addEventListener("change", e => {
    dayzXmlState.pickedKind = e.target.value;
  });
  $("#im-dzx-file-btn").addEventListener("click", () => $("#im-dzx-file").click());
  $("#im-dzx-file").addEventListener("change", async e => {
    const f = e.target.files && e.target.files[0]; if (!f) return;
    $("#im-dzx-file-name").textContent = f.name;
    const text = await f.text();
    $("#im-dzx-snippet").value = text;
    dayzXmlState.snippet = text;
  });
  $("#im-dzx-snippet").addEventListener("input", e => { dayzXmlState.snippet = e.target.value; });
  $("#im-dzx-preview").addEventListener("click", () => runDayzXmlMerge(true));
  $("#im-dzx-commit").addEventListener("click", () => runDayzXmlMerge(false));
}

async function runDayzXmlMerge(dryRun) {
  const id = instanceModalState.id; if (!id) return;
  const snippet = ($("#im-dzx-snippet") || {}).value || dayzXmlState.snippet || "";
  if (!snippet.trim()) { toast("Paste or pick a snippet first", "err"); return; }
  const kind = ($("#im-dzx-target") || {}).value || "";
  const url = `/api/instances/${encodeURIComponent(id)}/dayz/xml/${dryRun ? "preview" : "commit"}`;
  const body = JSON.stringify({ mission: dayzXmlState.mission, kind, snippet });
  const btn = $(dryRun ? "#im-dzx-preview" : "#im-dzx-commit");
  btn.disabled = true; const old = btn.textContent;
  btn.textContent = dryRun ? "Previewing…" : "Committing…";
  try {
    const r = await fetchJSON(url, { method: "POST", headers: {"Content-Type":"application/json"}, body });
    dayzXmlState.plan = r.plan;
    renderDayzXmlResult(r, dryRun);
    if (!dryRun) {
      toast(`Merged into ${r.path} — backup saved to ${r.backup_path}`, "ok");
      $("#im-dzx-commit").style.display = "none";
    }
  } catch (err) {
    toast(`Merge ${dryRun ? "preview" : "commit"} failed: ${err.message}`, "err");
  } finally {
    btn.disabled = false; btn.textContent = old;
  }
}

function renderDayzXmlResult(r, wasDryRun) {
  const plan = r.plan || {};
  const wrap = $("#im-dzx-result-wrap");
  wrap.style.display = "";
  const bucket = (label, arr, accent) => {
    if (!arr || !arr.length) return "";
    const more = arr.length > 20 ? ` <span class="dim">(+${arr.length - 20} more)</span>` : "";
    return `<div style="margin:8px 0">
      <div style="font-weight:600;color:${accent};font-size:12px">${label} (${arr.length})</div>
      <div class="mono" style="font-size:11.5px;color:var(--text-dim);margin-top:4px">${arr.slice(0,20).map(esc).join(", ")}${more}</div>
    </div>`;
  };
  const appChildren = plan.appended_children || {};
  const appChildrenHtml = Object.keys(appChildren).length ? `<div style="margin:8px 0">
    <div style="font-weight:600;color:var(--accent);font-size:12px">Event children appended</div>
    ${Object.entries(appChildren).map(([k,v]) => `<div class="mono" style="font-size:11.5px;color:var(--text-dim);margin:2px 0">${esc(k)}: ${esc(v.join(", "))}</div>`).join("")}
  </div>` : "";
  const warnHtml = (plan.warnings || []).length ? `<div style="margin-top:10px;padding:10px;background:rgba(245,158,11,0.08);border-left:3px solid #f59e0b;border-radius:4px;font-size:12px">
    <b>Warnings:</b><br>${plan.warnings.map(w => `• ${esc(w)}`).join("<br>")}
  </div>` : "";
  const conflictsHtml = (plan.conflicts || []).length ? `<div style="margin-top:10px;padding:10px;background:rgba(239,68,68,0.08);border-left:3px solid var(--err);border-radius:4px;font-size:12px">
    <b>Conflicts (first wins in snippet):</b> ${plan.conflicts.map(esc).join(", ")}
  </div>` : "";

  wrap.innerHTML = `
    <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
      <div>
        <div style="font-weight:600">${wasDryRun ? "Preview" : "Committed"} · ${esc(plan.kind || "?")}</div>
        <div class="dim mono" style="font-size:11px">${esc(r.path)}</div>
      </div>
      <div style="font-size:12px;color:var(--text-dim)">
        +${(plan.added||[]).length} added · ~${(plan.replaced||[]).length} replaced · =${(plan.unchanged||[]).length} unchanged
      </div>
    </div>
    ${bucket("Added", plan.added, "var(--ok)")}
    ${bucket("Replaced", plan.replaced, "var(--warn)")}
    ${bucket("Unchanged", plan.unchanged, "var(--text-dim)")}
    ${appChildrenHtml}
    ${conflictsHtml}
    ${warnHtml}
  `;

  if (wasDryRun) {
    $("#im-dzx-commit").style.display = "";
  }
}

// ---------------- SETTINGS tab ----------------
//
// Schemas describe which fields from a game's config file we expose as
// a form. Unknown / un-listed fields in the file are preserved verbatim
// on save — we never rewrite the whole file, we patch only the keys the
// operator touched.
const configSchemas = {
  // VEIN — UE4 Game.ini spans multiple [/Script/...] sections, so it uses the
  // section-aware "ue_ini" format (parseUEIni/serializeUEIni). Each field
  // declares its `section` so a brand-new key lands under the right header.
  // The entrypoint also stamps the common knobs from env on boot.
  "vein": {
    file: "Vein/Saved/Config/LinuxServer/Game.ini",
    filePaths: [
      "Vein/Saved/Config/LinuxServer/Game.ini",
      "/game-saves/Vein/Saved/Config/LinuxServer/Game.ini",
    ],
    format: "ue_ini",
    groups: [
      { title: "Identity & Access", icon: "🏷️", description: "Player-facing identity + the public-browser listing. <strong>ServerName</strong> shows in the in-game browser; an empty <strong>Password</strong> means an open server.", fields: [
        { prop: "ServerName",        label: "Server name",        type: "string",   section: "[/Script/Vein.VeinGameSession]", hint: "Shown in the in-game server browser. Also stamped from the SERVER_NAME env on every boot." },
        { prop: "ServerDescription", label: "Description",         type: "textarea", rows: 2, section: "[/Script/Vein.VeinGameSession]" },
        { prop: "Password",          label: "Join password",      type: "password", section: "[/Script/Vein.VeinGameSession]", hint: "Empty = open server." },
        { prop: "MaxPlayers",        label: "Max players",         type: "number", min: 1, max: 128, step: 1, section: "[/Script/Engine.GameSession]" },
        { prop: "bPublic",           label: "List on public browser", type: "bool", section: "[/Script/Vein.VeinGameSession]" },
        { prop: "HeartbeatInterval", label: "Heartbeat interval (s)", type: "number", min: 0, step: 1, section: "[/Script/Vein.VeinGameSession]", hint: "How often the server pings the master server. Leave default unless you know you need it." },
      ]},
      { title: "Steam & Anti-Cheat", icon: "🛡️", description: "Steam-side options for the dedicated server.", fields: [
        { prop: "bVACEnabled", label: "VAC enabled", type: "bool", section: "[OnlineSubsystemSteam]", hint: "Valve Anti-Cheat. Disable only if you have a reason to." },
      ]},
      { title: "Server Display", icon: "🎯", description: "Cosmetic server-side toggles.", fields: [
        { prop: "GS_ShowScoreboardBadges", label: "Show scoreboard badges", type: "bool", section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "PvP & Raiding", icon: "⚔️", fields: [
        { prop: "GS_PVP", label: "PvP enabled", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_BaseRaiding", label: "Allow base raiding", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_OfflineRaiding", label: "Allow offline raiding", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_BuildObjectPVP", label: "PvP damage to structures", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_BuildStructureDecay", label: "Structure decay", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_BaseDamage", label: "Base damage multiplier", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_HeadshotDamageMultiplier", label: "Headshot damage multiplier", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_VehicleOutgoingPlayerDamage", label: "Vehicle to player damage multiplier", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Death & Survival", icon: "💀", fields: [
        { prop: "GS_Permadeath", label: "Permadeath", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_RetainItemsOnDeath", label: "Keep items on death", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_PersistentCorpses", label: "Persistent corpses", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_AlwaysBecomeZombie", label: "Always turn into zombie on death", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_NoSaves", label: "Disable saves", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_HungerMultiplier", label: "Hunger rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ThirstMultiplier", label: "Thirst rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_TiredMultiplier", label: "Tiredness rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ScarcityDifficulty", label: "Loot scarcity", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_MaxCharacters", label: "Max characters (total)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Zombies", icon: "🧟", fields: [
        { prop: "GS_ZombiesHealth", label: "Zombie health", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieDamageMultiplier", label: "Zombie damage", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieSpeedMultiplier", label: "Zombie speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieRunSpeedMultiplier", label: "Zombie run speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieWalkSpeedMultiplier", label: "Zombie walk speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieCrawlSpeedMultiplier", label: "Zombie crawl speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieSightMultiplier", label: "Zombie sight range", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieHearingMultiplier", label: "Zombie hearing range", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieSpawnCountMultiplier", label: "Zombie spawn count", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieWalkerPercentage", label: "Walker (slow) zombie fraction", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombieInfectionChance", label: "Infection chance per hit", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombiesCanClimb", label: "Zombies can climb", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ZombiesHeadshotOnly", label: "Zombies killable by headshot only", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_Hordes", label: "Hordes enabled", type: "bool", section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "World & Time", icon: "🌍", fields: [
        { prop: "GS_TimeMultiplier", label: "Day/night time scale", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_NightTimeMultiplier", label: "Night length scale", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_StartTime", label: "World start time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_TimeWithNoPlayers", label: "Time passes with no players", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ContainersRespawn", label: "Containers respawn loot", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_FurnitureRespawns", label: "Furniture respawns", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ItemActorSpawnersRespawn", label: "World item spawners respawn", type: "bool", section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Movement", icon: "🏃", fields: [
        { prop: "GS_RunSpeed", label: "Run speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_WalkSpeed", label: "Walk speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_CrouchWalkSpeed", label: "Crouch-walk speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_SwimSpeed", label: "Swim speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Combat Effects", icon: "🥊", fields: [
        { prop: "GS_StaggerOdds", label: "Stagger chance", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_StunLockChance", label: "Stun-lock chance", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_StunLockDuration", label: "Stun-lock duration (s)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Power & Wiring", icon: "🔌", fields: [
        { prop: "GS_PowerShutoffTime", label: "Power shutoff time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_WaterShutoffTime", label: "Water shutoff time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_MaxWireDistance", label: "Max wire distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_MaxTVWireDistance", label: "Max TV wire distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_MaxUtilityCabinets", label: "Max utility cabinets (0=unlimited)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_UtilityCabinetInterval", label: "Utility cabinet interval", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_UtilityCabinetRadiusMultiplier", label: "Utility cabinet radius", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" },
      ]},
      { title: "Misc", icon: "🎛️", fields: [
        { prop: "GS_AllowPickpocketing", label: "Allow pickpocketing", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_ClothingHideable", label: "Clothing hideable", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_AllowRemoteVideo", label: "Allow remote video (TVs)", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_SleepAdvancesTime", label: "Sleeping advances time", type: "bool", section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_MaxThirdPersonDistance", label: "Max 3rd-person camera distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" },
        { prop: "GS_IgnoreBuildObjectRestrictions", label: "Ignore build placement restrictions", type: "bool", section: "[/Script/Vein.ServerSettings]" },
      ]},
    ],
  },
  // 7DTD is the DURABLE editor: the entrypoint re-stamps serverconfig.xml
  // from the panel-rendered template (serverconfig.xml.rendered) on EVERY
  // boot, so a live-file write alone silently reverts on the next restart.
  // Each field carries a `tmplKey` — the config_values key the module
  // template reads as `.Values.<tmplKey>`. Apply pushes dirty fields through
  // POST /config-render (updates config_values + re-renders the boot
  // template on the agent, NO recreate) and then writes the live file so
  // the editor stays consistent. Fields with `locked: true` are panel-owned
  // (port allocator / RCON password) and render disabled.
  "7dtd": {
    file: "serverconfig.xml",
    filePaths: [
      "/game-saves/serverconfig.xml",
      "serverconfig.xml",
    ],
    format: "xml7dtd",
    durable: true,
    groups: [
      { title: "Identity", icon: "🏷️", description: "Server name, description, and visibility on the public server browser.", fields: [
        { prop: "ServerName",              label: "Server name",   type: "string", tmplKey: "server_name" },
        { prop: "ServerDescription",       label: "Description",   type: "string", tmplKey: "server_description" },
        { prop: "ServerWebsiteURL",        label: "Website URL",   type: "string", tmplKey: "server_website" },
        { prop: "ServerPassword",          label: "Join password", type: "password", tmplKey: "server_password", hint: "Leave blank for an open server" },
        { prop: "ServerLoginConfirmationText", label: "Login confirmation text", type: "string", tmplKey: "login_confirmation_text", hint: "Shown to players on connect" },
        { prop: "Region",                  label: "Region", type: "enum", tmplKey: "region",
          options: ["NorthAmericaEast","NorthAmericaWest","CentralAmerica","SouthAmerica","Europe","Russia","Asia","Oceania","Africa"] },
        { prop: "Language",                label: "Language", type: "enum", tmplKey: "language",
          options: ["English","German","Spanish","French","Italian","Japanese","Korean","Polish","Portuguese","Russian","Turkish","SimplifiedChinese","TraditionalChinese"] },
      ]},

      { title: "Slots & Network", icon: "🔌", description: "Player cap, visibility, and crossplay. Ports are panel-allocated — change them in the Network ports pane, not here.", fields: [
        { prop: "ServerMaxPlayerCount",    label: "Max players",     type: "number", min: 1, max: 64, tmplKey: "max_players" },
        { prop: "ServerReservedSlots",     label: "Reserved slots",  type: "number", min: 0, max: 64, tmplKey: "reserved_slots" },
        { prop: "ServerReservedSlotsPermission", label: "Reserved-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "reserved_slots_permission" },
        { prop: "ServerAdminSlots",        label: "Admin slots",     type: "number", min: 0, max: 8, tmplKey: "admin_slots" },
        { prop: "ServerAdminSlotsPermission", label: "Admin-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "admin_slots_permission" },
        { prop: "ServerVisibility",        label: "Visibility", type: "enum", tmplKey: "server_visibility",
          options: [["0","Private (LAN / direct only)"],["1","Friends only"],["2","Public"]] },
        { prop: "ServerAllowCrossplay",    label: "Allow crossplay", type: "bool", tmplKey: "allow_crossplay", hint: "Console + PC in the same game. Requires EAC on and vanilla-compatible mods only." },
        { prop: "ServerDisabledNetworkProtocols", label: "Disabled network protocols", type: "string", tmplKey: "disabled_network_protocols", hint: "Comma-separated: SteamNetworking, LiteNetLib. Default disables SteamNetworking." },
        { prop: "ServerMaxWorldTransferSpeedKiBs", label: "Max world transfer speed (KiB/s)", type: "number", min: 64, max: 8192, tmplKey: "max_world_transfer_speed" },
        { prop: "ServerPort",              label: "Server port", type: "number", min: 1, max: 65535, locked: true,
          hint: "🔒 Panel-owned: the port allocator assigns this and the entrypoint re-stamps it from env on every boot. Change it in the Network ports pane." },
      ]},

      { title: "World", icon: "🌍", description: "⚠ Map, seed, size, and save folder. Changing any of these starts a <b>new world save</b> on the next restart (the old save folder stays on disk). On a clustered server, re-pick the shared player world in the Cluster tab afterwards.", fields: [
        { prop: "GameWorld",      label: "World",       type: "enum", tmplKey: "world_name",
          options: ["Navezgane","PREGEN01","PREGEN02","PREGEN03","PREGEN4k_01","PREGEN4k_02","PREGEN4k_03","RWG"] },
        { prop: "WorldGenSeed",   label: "World seed",  type: "string", tmplKey: "world_seed", hint: "Anything — used for RWG worlds" },
        { prop: "WorldGenSize",   label: "World size",  type: "number", min: 4096, max: 16384, step: 1024, tmplKey: "world_size" },
        { prop: "GameName",       label: "Save name",   type: "string", tmplKey: "game_name", hint: "Folder name for this world" },
        { prop: "GameMode",       label: "Game mode",   type: "enum", tmplKey: "game_mode", options: ["GameModeSurvival"] },
        { prop: "PersistentPlayerProfiles", label: "Persistent player profiles", type: "bool", tmplKey: "persistent_player_profiles", hint: "Players always spawn as the same character profile" },
        { prop: "MaxChunkAge",    label: "Max chunk age (days, -1 = off)", type: "number", min: -1, max: 365, tmplKey: "max_chunk_age", hint: "Resets unvisited chunks after this many in-game days" },
        { prop: "SaveDataLimit",  label: "Save data limit (MB, -1 = off)", type: "number", min: -1, max: 65536, tmplKey: "save_data_limit" },
        { prop: "MaxUncoveredMapChunksPerPlayer", label: "Max uncovered map chunks / player", type: "number", min: 16384, max: 1048576, tmplKey: "max_uncovered_chunks" },
      ]},

      { title: "Difficulty & Pacing", icon: "⚡", description: "How hard the game is, how fast time passes, and XP rates.", fields: [
        { prop: "GameDifficulty",   label: "Difficulty", type: "enum", tmplKey: "difficulty",
          options: [["0","Scavenger"],["1","Adventurer"],["2","Nomad"],["3","Warrior"],["4","Survivalist"],["5","Insane"]] },
        { prop: "XPMultiplier",     label: "XP multiplier (%)", type: "number", min: 25, max: 500, step: 25, tmplKey: "xp_multiplier" },
        { prop: "BlockDamagePlayer",label: "Player block damage (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_player" },
        { prop: "BlockDamageAI",    label: "AI block damage (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_ai" },
        { prop: "BlockDamageAIBM",  label: "AI block damage during blood moon (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_ai_bm" },
        { prop: "DayNightLength",   label: "Day length (real minutes)", type: "number", min: 10, max: 240, tmplKey: "day_length_minutes" },
        { prop: "DayLightLength",   label: "Daylight hours (per 24)",   type: "number", min: 0, max: 24, tmplKey: "daylight_hours" },
        { prop: "BiomeProgression", label: "Biome progression", type: "bool", tmplKey: "biome_progression", hint: "V2.0 biome hazard/progression system" },
        { prop: "StormFreq",        label: "Storm frequency (%)", type: "number", min: 0, max: 500, tmplKey: "storm_freq", hint: "100 = vanilla; 0 disables biome storms" },
        { prop: "PlayerSafeZoneLevel", label: "Player safe-zone level",  type: "number", min: 0, max: 100, tmplKey: "player_safe_zone_level", hint: "Level required to leave the safe zone" },
        { prop: "PlayerSafeZoneHours", label: "Player safe-zone hours",  type: "number", min: 0, max: 72, tmplKey: "player_safe_zone_hours" },
        { prop: "DeathPenalty",     label: "Death penalty", type: "enum", tmplKey: "death_penalty",
          options: [["0","None"],["1","XP loss"],["2","Full XP loss"]] },
        { prop: "DropOnDeath",      label: "Death drop",   type: "enum", tmplKey: "drop_on_death",
          options: [["0","Nothing"],["1","Everything"],["2","Toolbelt only"],["3","Backpack only"],["4","Delete all"]] },
        { prop: "DropOnQuit",       label: "Quit drop",    type: "enum", tmplKey: "drop_on_quit",
          options: [["0","Nothing"],["1","Everything"],["2","Toolbelt only"],["3","Backpack only"],["4","Delete all"]] },
        { prop: "PlayerKillingMode",label: "PvP mode", type: "enum", tmplKey: "pk_mode",
          options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] },
        { prop: "BuildCreate",      label: "Creative mode (building)", type: "bool", tmplKey: "build_create" },
        { prop: "AllowSpawnNearFriend", label: "Spawn near friend", type: "number", min: 0, max: 2, tmplKey: "allow_spawn_near_friend", hint: "0 = off · 2 = vanilla default" },
        { prop: "CameraRestrictionMode", label: "Camera restriction mode", type: "number", min: 0, max: 2, tmplKey: "camera_restriction_mode", hint: "Limits 3rd-person camera abuse. 0 = off" },
        { prop: "JarRefund",        label: "Jar refund (%)", type: "number", min: 0, max: 100, tmplKey: "jar_refund" },
      ]},

      { title: "Zombies & Blood Moons", icon: "🧟", description: "Zombie counts, movement, and horde-night frequency.", fields: [
        { prop: "EnemySpawnMode",       label: "Enemy spawning", type: "bool", tmplKey: "enemy_spawn_mode", hint: "Off = no zombies at all" },
        { prop: "EnemyDifficulty",      label: "Enemy difficulty", type: "enum", tmplKey: "enemy_difficulty",
          options: [["0","Normal"],["1","Feral"]] },
        { prop: "MaxSpawnedZombies",    label: "Max zombies on server", type: "number", min: 8, max: 128, tmplKey: "max_spawned_zombies" },
        { prop: "MaxSpawnedAnimals",    label: "Max animals on server", type: "number", min: 0, max: 128, tmplKey: "max_spawned_animals" },
        { prop: "ServerMaxAllowedViewDistance", label: "Max view distance (chunks)", type: "number", min: 6, max: 12, tmplKey: "max_view_distance" },
        { prop: "MaxQueuedMeshLayers",  label: "Max queued mesh layers", type: "number", min: 100, max: 5000, tmplKey: "max_queued_mesh_layers", hint: "Chunk mesh generation queue — higher = faster loading, more RAM" },
        { prop: "ZombieFeralSense",     label: "Feral sense", type: "enum", tmplKey: "zombie_feral_sense",
          options: [["0","Off"],["1","Day only"],["2","Night only"],["3","Always"]] },
        { prop: "ZombieMove",           label: "Zombie day speed", type: "enum", tmplKey: "zombie_move",
          options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] },
        { prop: "ZombieMoveNight",      label: "Zombie night speed", type: "enum", tmplKey: "zombie_move_night",
          options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] },
        { prop: "ZombieFeralMove",      label: "Feral zombie speed", type: "enum", tmplKey: "zombie_feral_move",
          options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] },
        { prop: "ZombieBMMove",         label: "Blood moon zombie speed", type: "enum", tmplKey: "zombie_bm_move",
          options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] },
        { prop: "AISmellMode",          label: "AI smell mode", type: "number", min: 0, max: 3, tmplKey: "ai_smell_mode", hint: "Zombie smell sensing. 0 = off · 3 = vanilla default" },
        { prop: "BloodMoonFrequency",   label: "Blood moon every (days)", type: "number", min: 0, max: 30, tmplKey: "blood_moon_frequency" },
        { prop: "BloodMoonRange",       label: "± random days",           type: "number", min: 0, max: 7, tmplKey: "blood_moon_range" },
        { prop: "BloodMoonWarning",     label: "Blood moon warning at hour", type: "number", min: -1, max: 23, tmplKey: "blood_moon_warning" },
        { prop: "BloodMoonEnemyCount",  label: "Zombies per player on blood moon", type: "number", min: 0, max: 128, tmplKey: "blood_moon_enemy_count" },
      ]},

      { title: "Loot & Economy", icon: "💰", description: "Loot abundance, respawn time, and airdrops.", fields: [
        { prop: "LootAbundance",          label: "Loot abundance (%)",            type: "number", min: 25, max: 400, step: 25, tmplKey: "loot_abundance" },
        { prop: "LootRespawnDays",        label: "Loot respawn (days)",           type: "number", min: 0, max: 365, tmplKey: "loot_respawn_days" },
        { prop: "AirDropFrequency",       label: "Airdrop every (hours, 0=off)",  type: "number", min: 0, max: 168, tmplKey: "airdrop_frequency" },
        { prop: "AirDropMarker",          label: "Show airdrop marker",           type: "bool", tmplKey: "airdrop_marker" },
        { prop: "PartySharedKillRange",   label: "Party-shared kill range",       type: "number", min: 0, max: 1000, tmplKey: "party_shared_kill_range" },
        { prop: "QuestProgressionDailyLimit", label: "Quest progression daily limit", type: "number", min: 0, max: 100, tmplKey: "quest_daily_limit", hint: "Quests counting toward tier progression per day" },
        { prop: "TwitchServerPermission", label: "Twitch integration permission level", type: "number", min: 0, max: 1000, tmplKey: "twitch_server_permission" },
        { prop: "TwitchBloodMoonAllowed", label: "Twitch actions during blood moon", type: "bool", tmplKey: "twitch_blood_moon_allowed" },
      ]},

      { title: "Land Claims", icon: "📍", description: "Land-claim block size, counts, decay rates, and dynamic mesh.", fields: [
        { prop: "LandClaimCount",                  label: "Land claims per player",           type: "number", min: 1, max: 25, tmplKey: "land_claim_count" },
        { prop: "LandClaimSize",                   label: "Claim block size (blocks)",        type: "number", min: 5, max: 151, tmplKey: "land_claim_size", hint: "Radius around the claim block; must be odd" },
        { prop: "LandClaimDeadZone",               label: "Deadzone between claims (blocks)", type: "number", min: 5, max: 100, tmplKey: "land_claim_dead_zone" },
        { prop: "LandClaimExpiryTime",             label: "Claim expiry (days offline)",      type: "number", min: 1, max: 365, tmplKey: "land_claim_expiry_time" },
        { prop: "LandClaimDecayMode",              label: "Claim decay mode",                 type: "enum", tmplKey: "land_claim_decay_mode",
          options: [["0","Slow"],["1","Fast"],["2","None (always protected)"]] },
        { prop: "LandClaimOnlineDurabilityModifier", label: "Online durability multiplier",   type: "number", min: 1, max: 128, tmplKey: "land_claim_online_durability_modifier" },
        { prop: "LandClaimOfflineDurabilityModifier",label: "Offline durability multiplier",  type: "number", min: 1, max: 128, tmplKey: "land_claim_offline_durability_modifier" },
        { prop: "LandClaimOfflineDelay",           label: "Offline delay (minutes)",          type: "number", min: 0, max: 1440, tmplKey: "land_claim_offline_delay" },
        { prop: "BedrollDeadZoneSize",             label: "Bedroll deadzone size",            type: "number", min: 0, max: 100, tmplKey: "bedroll_dead_zone_size" },
        { prop: "BedrollExpiryTime",               label: "Bedroll expiry (days offline)",    type: "number", min: 1, max: 365, tmplKey: "bedroll_expiry_time" },
        { prop: "DynamicMeshEnabled",              label: "Dynamic mesh",                     type: "bool", tmplKey: "dynamic_mesh_enabled", hint: "Renders player builds at long distance. RAM/CPU heavy." },
        { prop: "DynamicMeshLandClaimOnly",        label: "Dynamic mesh in claims only",      type: "bool", tmplKey: "dynamic_mesh_land_claim_only" },
        { prop: "DynamicMeshLandClaimBuffer",      label: "Dynamic mesh claim buffer",        type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_land_claim_buffer" },
        { prop: "DynamicMeshMaxItemCache",         label: "Dynamic mesh max item cache",      type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_max_item_cache" },
      ]},

      { title: "Anti-Cheat & Telnet", icon: "🛡️", description: "EAC + telnet/RCON. The panel auto-generates a <code>TelnetPassword</code> on first boot and reads it for console access — the panel-owned fields are locked.", fields: [
        { prop: "EACEnabled",               label: "EasyAntiCheat",            type: "bool", tmplKey: "eac" },
        { prop: "IgnoreEOSSanctions",       label: "Ignore EOS sanctions",     type: "bool", tmplKey: "ignore_eos_sanctions", hint: "Let players sanctioned on Epic Online Services join anyway" },
        { prop: "HideCommandExecutionLog",  label: "Hide command execution log", type: "enum", tmplKey: "hide_command_log",
          options: [["0","Show everything"],["1","Hide only from Telnet/CP"],["2","Also hide from remote clients"],["3","Hide everything"]] },
        { prop: "TelnetEnabled",            label: "Telnet enabled",           type: "bool", tmplKey: "telnet_enabled", hint: "⚠ The panel's console + RCON ride telnet — disabling it breaks the Console tab and player lists." },
        { prop: "TelnetFailedLoginLimit",   label: "Telnet failed-login limit", type: "number", min: 1, max: 100, tmplKey: "telnet_failed_login_limit" },
        { prop: "TelnetFailedLoginsBlocktime", label: "Telnet failed-login block (sec)", type: "number", min: 1, max: 86400, tmplKey: "telnet_failed_login_blocktime" },
        { prop: "TerminalWindowEnabled",    label: "Terminal window enabled",  type: "bool", tmplKey: "terminal_window_enabled" },
        { prop: "TelnetPort",               label: "Telnet port",              type: "number", min: 1, max: 65535, locked: true,
          hint: "🔒 Panel-owned: allocated per instance and re-stamped from env on every boot." },
        { prop: "TelnetPassword",           label: "Telnet password",          type: "password", locked: true,
          hint: "🔒 Panel-owned: auto-generated on first boot and persisted — the panel's RCON reads it. Changing it here would break console access." },
        { prop: "AdminFileName",            label: "Admin file name",          type: "string", locked: true,
          hint: "🔒 Fixed to serveradmin.xml by the panel template." },
      ]},

      { title: "Web Dashboard & Map", icon: "🗺️", description: "Built-in 7DTD web dashboard (replaces the old ControlPanel — removed in V2.6) and the map renderer it feeds. <strong>Map rendering must be on for the <code>rendermap</code> console command to work.</strong> The panel has its own dashboard; the in-game one is only needed if you want players to view the live map in a browser.", fields: [
        { prop: "WebDashboardEnabled",  label: "Web dashboard enabled", type: "bool", tmplKey: "webdash_enabled", hint: "7DTD's built-in browser-based dashboard. Off by default." },
        { prop: "WebDashboardUrl",      label: "Web dashboard external URL", type: "string", tmplKey: "webdash_url", hint: "Only set if behind a reverse proxy. Leave blank to use server's public IP." },
        { prop: "EnableMapRendering",   label: "Enable map rendering",  type: "bool", tmplKey: "enable_map_rendering", hint: "Required for the <code>rendermap</code> command and the dashboard's live map tile view." },
        { prop: "WebDashboardPort",     label: "Web dashboard port",    type: "number", min: 1, max: 65535, locked: true,
          hint: "🔒 Panel-owned: allocated per instance and re-stamped from env on every boot." },
      ]},
    ],
  },
  // 7DTD V3.0 "Dead Hot Summer" variant. V3.0 removed ~30 gameplay properties
  // from serverconfig.xml and folded them into one encoded SandboxCode (the 150
  // in-game Sandbox Options). This schema keeps ONLY the properties that survive
  // in the real 3.0 serverconfig.xml (verified against the shipped file), and
  // adds a special "Sandbox Options" group (kind:"sandbox") rendered by a custom
  // panel — paste-a-code + presets + per-option controls driven by
  // /api/7dtd/sandbox/schema and round-tripped through the Go codec. Selected in
  // loadInstanceConfig() when the instance's provider_id is a 3.0 branch.
  "7dtd-v3": {
    file: "serverconfig.xml",
    filePaths: ["/game-saves/serverconfig.xml", "serverconfig.xml"],
    format: "xml7dtd",
    durable: true,
    groups: [
      { title: "Identity", icon: "🏷️", description: "Server name, description, and visibility on the public server browser.", fields: [
        { prop: "ServerName",              label: "Server name",   type: "string", tmplKey: "server_name" },
        { prop: "ServerDescription",       label: "Description",   type: "string", tmplKey: "server_description" },
        { prop: "ServerWebsiteURL",        label: "Website URL",   type: "string", tmplKey: "server_website" },
        { prop: "ServerPassword",          label: "Join password", type: "password", tmplKey: "server_password", hint: "Leave blank for an open server" },
        { prop: "ServerLoginConfirmationText", label: "Login confirmation text", type: "string", tmplKey: "login_confirmation_text" },
        { prop: "Region",                  label: "Region", type: "enum", tmplKey: "region",
          options: ["NorthAmericaEast","NorthAmericaWest","CentralAmerica","SouthAmerica","Europe","Russia","Asia","MiddleEast","Africa","Oceania"] },
        { prop: "Language",                label: "Language", type: "string", tmplKey: "language" },
      ]},
      { title: "Slots & Network", icon: "🔌", description: "Player cap, visibility, crossplay. Ports are panel-allocated — change them in the Network ports pane.", fields: [
        { prop: "ServerMaxPlayerCount",    label: "Max players",     type: "number", min: 1, max: 64, tmplKey: "max_players" },
        { prop: "ServerReservedSlots",     label: "Reserved slots",  type: "number", min: 0, max: 64, tmplKey: "reserved_slots" },
        { prop: "ServerReservedSlotsPermission", label: "Reserved-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "reserved_slots_permission" },
        { prop: "ServerAdminSlots",        label: "Admin slots",     type: "number", min: 0, max: 8, tmplKey: "admin_slots" },
        { prop: "ServerAdminSlotsPermission", label: "Admin-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "admin_slots_permission" },
        { prop: "ServerVisibility",        label: "Visibility", type: "enum", tmplKey: "server_visibility",
          options: [["0","Private (LAN / direct only)"],["1","Friends only"],["2","Public"]] },
        { prop: "ServerAllowCrossplay",    label: "Allow crossplay", type: "bool", tmplKey: "allow_crossplay", hint: "Console + PC. Requires EAC on." },
        { prop: "ServerDisabledNetworkProtocols", label: "Disabled network protocols", type: "string", tmplKey: "disabled_network_protocols" },
        { prop: "ServerMaxWorldTransferSpeedKiBs", label: "Max world transfer speed (KiB/s)", type: "number", min: 64, max: 8192, tmplKey: "max_world_transfer_speed" },
      ]},
      { title: "World", icon: "🌍", description: "⚠ Map, seed, size, and save folder. Changing any of these starts a <b>new world save</b> on the next restart.", fields: [
        { prop: "GameWorld",      label: "World",       type: "enum", tmplKey: "world_name",
          options: ["Navezgane","Pregen06k01","Pregen06k02","Pregen08k01","Pregen08k02","Pregen10k01","Pregen10k02","RWG"] },
        { prop: "WorldGenSeed",   label: "World seed",  type: "string", tmplKey: "world_seed", hint: "Anything — used for RWG worlds" },
        { prop: "WorldGenSize",   label: "World size",  type: "number", min: 6144, max: 10240, step: 2048, tmplKey: "world_size", hint: "6144 / 8192 / 10240" },
        { prop: "GameName",       label: "Save name",   type: "string", tmplKey: "game_name" },
        { prop: "GameMode",       label: "Game mode",   type: "enum", tmplKey: "game_mode", options: ["GameModeSurvival"] },
        { prop: "PersistentPlayerProfiles", label: "Persistent player profiles", type: "bool", tmplKey: "persistent_player_profiles" },
        { prop: "MaxChunkAge",    label: "Max chunk age (days, -1 = off)", type: "number", min: -1, max: 365, tmplKey: "max_chunk_age" },
        { prop: "SaveDataLimit",  label: "Save data limit (MB, -1 = off)", type: "number", min: -1, max: 65536, tmplKey: "save_data_limit" },
        { prop: "MaxUncoveredMapChunksPerPlayer", label: "Max uncovered map chunks / player", type: "number", min: 16384, max: 1048576, tmplKey: "max_uncovered_chunks" },
      ]},
      // The 150 difficulty/gameplay options that V3.0 moved out of serverconfig.
      // The single SandboxCode field is the durable carrier — the per-option UI
      // (renderSandboxPane) computes its value. Listed in fields[] (rendered
      // hidden) so the existing durable config-render + xml7dtd serializer pin
      // and write it exactly like any other field; tmplKey maps it to the v3
      // template's {{ .Values.sandbox_code }}.
      { title: "Sandbox Options", icon: "🎲", kind: "sandbox", description: "All 150 V3.0 gameplay options — difficulty, zombies, blood moon, loot, crafting, traders, quests — are now one encoded <code>SandboxCode</code>. Set them per-option below, paste a code from the game, or pick an official preset.", fields: [
        { prop: "SandboxCode", label: "Sandbox code", type: "string", tmplKey: "sandbox_code", hidden: true },
      ] },
      { title: "Survival & Spawn (kept)", icon: "⚡", description: "Gameplay properties that V3.0 KEPT in serverconfig.xml (everything else moved to Sandbox Options above).", fields: [
        { prop: "PlayerSafeZoneLevel", label: "Player safe-zone level",  type: "number", min: 0, max: 100, tmplKey: "player_safe_zone_level" },
        { prop: "PlayerSafeZoneHours", label: "Player safe-zone hours",  type: "number", min: 0, max: 72, tmplKey: "player_safe_zone_hours" },
        { prop: "BuildCreate",      label: "Creative mode (building)", type: "bool", tmplKey: "build_create" },
        { prop: "BedrollDeadZoneSize", label: "Bedroll deadzone size", type: "number", min: 0, max: 100, tmplKey: "bedroll_dead_zone_size" },
        { prop: "BedrollExpiryTime", label: "Bedroll expiry (days offline)", type: "number", min: 1, max: 365, tmplKey: "bedroll_expiry_time" },
        { prop: "AllowSpawnNearFriend", label: "Spawn near friend", type: "number", min: 0, max: 2, tmplKey: "allow_spawn_near_friend", hint: "0 = off · 2 = forest only" },
        { prop: "CameraRestrictionMode", label: "Camera restriction mode", type: "number", min: 0, max: 2, tmplKey: "camera_restriction_mode" },
        { prop: "PlayerKillingMode",label: "PvP mode", type: "enum", tmplKey: "pk_mode",
          options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] },
        { prop: "PartySharedKillRange",   label: "Party-shared kill range",       type: "number", min: 0, max: 1000, tmplKey: "party_shared_kill_range" },
      ]},
      { title: "Performance", icon: "📈", description: "Server-side limits (kept in serverconfig.xml).", fields: [
        { prop: "MaxSpawnedZombies",    label: "Max zombies on server", type: "number", min: 8, max: 128, tmplKey: "max_spawned_zombies" },
        { prop: "MaxSpawnedAnimals",    label: "Max animals on server", type: "number", min: 0, max: 128, tmplKey: "max_spawned_animals" },
        { prop: "ServerMaxAllowedViewDistance", label: "Max view distance (chunks)", type: "number", min: 6, max: 12, tmplKey: "max_view_distance" },
        { prop: "MaxQueuedMeshLayers",  label: "Max queued mesh layers", type: "number", min: 100, max: 5000, tmplKey: "max_queued_mesh_layers" },
      ]},
      { title: "Land Claims & Dynamic Mesh", icon: "📍", description: "Land-claim and dynamic-mesh settings (kept in serverconfig.xml).", fields: [
        { prop: "LandClaimCount",                  label: "Land claims per player",           type: "number", min: 1, max: 25, tmplKey: "land_claim_count" },
        { prop: "LandClaimSize",                   label: "Claim block size (blocks)",        type: "number", min: 5, max: 151, tmplKey: "land_claim_size", hint: "must be odd" },
        { prop: "LandClaimDeadZone",               label: "Deadzone between claims (blocks)", type: "number", min: 5, max: 100, tmplKey: "land_claim_dead_zone" },
        { prop: "LandClaimExpiryTime",             label: "Claim expiry (days offline)",      type: "number", min: 1, max: 365, tmplKey: "land_claim_expiry_time" },
        { prop: "LandClaimDecayMode",              label: "Claim decay mode",                 type: "enum", tmplKey: "land_claim_decay_mode",
          options: [["0","Slow"],["1","Fast"],["2","None (always protected)"]] },
        { prop: "LandClaimOnlineDurabilityModifier", label: "Online durability multiplier",   type: "number", min: 0, max: 128, tmplKey: "land_claim_online_durability_modifier" },
        { prop: "LandClaimOfflineDurabilityModifier",label: "Offline durability multiplier",  type: "number", min: 0, max: 128, tmplKey: "land_claim_offline_durability_modifier" },
        { prop: "LandClaimOfflineDelay",           label: "Offline delay (minutes)",          type: "number", min: 0, max: 1440, tmplKey: "land_claim_offline_delay" },
        { prop: "DynamicMeshEnabled",              label: "Dynamic mesh",                     type: "bool", tmplKey: "dynamic_mesh_enabled" },
        { prop: "DynamicMeshLandClaimOnly",        label: "Dynamic mesh in claims only",      type: "bool", tmplKey: "dynamic_mesh_land_claim_only" },
        { prop: "DynamicMeshLandClaimBuffer",      label: "Dynamic mesh claim buffer",        type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_land_claim_buffer" },
        { prop: "DynamicMeshMaxItemCache",         label: "Dynamic mesh max item cache",      type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_max_item_cache" },
      ]},
      { title: "Anti-Cheat & Telnet", icon: "🛡️", description: "EAC + telnet/RCON. Panel-owned fields are locked.", fields: [
        { prop: "EACEnabled",               label: "EasyAntiCheat",            type: "bool", tmplKey: "eac" },
        { prop: "IgnoreEOSSanctions",       label: "Ignore EOS sanctions",     type: "bool", tmplKey: "ignore_eos_sanctions" },
        { prop: "HideCommandExecutionLog",  label: "Hide command execution log", type: "enum", tmplKey: "hide_command_log",
          options: [["0","Show everything"],["1","Hide only from Telnet/CP"],["2","Also hide from remote clients"],["3","Hide everything"]] },
        { prop: "TelnetEnabled",            label: "Telnet enabled",           type: "bool", tmplKey: "telnet_enabled", hint: "⚠ The panel's console + RCON ride telnet." },
        { prop: "TelnetFailedLoginLimit",   label: "Telnet failed-login limit", type: "number", min: 1, max: 100, tmplKey: "telnet_failed_login_limit" },
        { prop: "TelnetFailedLoginsBlocktime", label: "Telnet failed-login block (sec)", type: "number", min: 1, max: 86400, tmplKey: "telnet_failed_login_blocktime" },
        { prop: "TerminalWindowEnabled",    label: "Terminal window enabled",  type: "bool", tmplKey: "terminal_window_enabled" },
      ]},
      { title: "Web Dashboard & Map", icon: "🗺️", description: "7DTD's built-in web dashboard + map renderer.", fields: [
        { prop: "WebDashboardEnabled",  label: "Web dashboard enabled", type: "bool", tmplKey: "webdash_enabled" },
        { prop: "WebDashboardUrl",      label: "Web dashboard external URL", type: "string", tmplKey: "webdash_url" },
        { prop: "EnableMapRendering",   label: "Enable map rendering",  type: "bool", tmplKey: "enable_map_rendering" },
        { prop: "TwitchServerPermission", label: "Twitch integration permission level", type: "number", min: 0, max: 1000, tmplKey: "twitch_server_permission" },
        { prop: "TwitchBloodMoonAllowed", label: "Twitch actions during blood moon", type: "bool", tmplKey: "twitch_blood_moon_allowed" },
      ]},
    ],
  },
  "ark-sa": {
    file: "Config/WindowsServer/GameUserSettings.ini",
    filePaths: [
      "Config/WindowsServer/GameUserSettings.ini",
    ],
    format: "ini_ark",
    groups: [
      { title: "Identity", icon: "🏷️", description: "MOTD, hardcore, spectator password. <strong>Session name + join + admin passwords moved to Game launch settings above</strong> — ARK's launch args override these INI keys, so editing them here had no effect.", fields: [
        { prop: "SpectatorPassword",   label: "Spectator password", type: "password" },
        { prop: "Message",             label: "MOTD (login message)", type: "string" },
        { prop: "ServerHardcore",      label: "Hardcore mode", type: "bool" },
      ]},

      { title: "Slots & Network", icon: "🔌", description: "Reserved slots + RCON tuning. <strong>Max players moved to Game launch settings above</strong> — env-driven launch args override the INI value. Port changes need a container rebuild (Settings → Maintenance).", fields: [
        { prop: "ReservedPlayerSlots", label: "Reserved player slots", type: "number", min: 0, max: 64, hint: "Slots held open for specific Steam IDs" },
        { prop: "RCONEnabled",         label: "RCON enabled", type: "bool" },
        { prop: "RCONPort",            label: "RCON port", type: "number", min: 1, max: 65535 },
        { prop: "RCONServerGameLogBuffer", label: "RCON log buffer", type: "number", min: 100, max: 10000 },
      ]},

      { title: "Rates", icon: "⚡", description: "All the multipliers. 1.0 = vanilla, higher = faster. Cluster conventions: 2×–5× is casual, 10×+ is arcade.", fields: [
        { prop: "HarvestAmountMultiplier",     label: "Harvest amount",     type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "XPMultiplier",                label: "XP gain",             type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "TamingSpeedMultiplier",       label: "Taming speed",        type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "MatingIntervalMultiplier",    label: "Mating interval",     type: "number", step: 0.01, min: 0.001, max: 1, hint: "Lower = mate more often. 0.1 is common" },
        { prop: "EggHatchSpeedMultiplier",     label: "Egg hatch speed",     type: "number", step: 0.1, min: 0.1, max: 100 },
        { prop: "BabyMatureSpeedMultiplier",   label: "Baby mature speed",   type: "number", step: 0.1, min: 0.1, max: 100 },
        { prop: "BabyCuddleIntervalMultiplier",label: "Baby cuddle interval",type: "number", step: 0.1, min: 0, max: 100, hint: "Lower = cuddle sooner" },
        { prop: "BabyImprintAmountMultiplier", label: "Imprint amount/cuddle", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "BabyFoodConsumptionSpeedMultiplier", label: "Baby food consumption", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "CropGrowthSpeedMultiplier",   label: "Crop growth speed",    type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DayCycleSpeedScale",          label: "Day/night cycle speed", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DayTimeSpeedScale",           label: "Day length scale",     type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "NightTimeSpeedScale",         label: "Night length scale",   type: "number", step: 0.1, min: 0, max: 100 },
      ]},

      { title: "Players", icon: "🧍", description: "Stats + survival rates for human characters.", fields: [
        { prop: "PlayerDamageMultiplier",             label: "Damage dealt",      type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerResistanceMultiplier",         label: "Damage resistance", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerCharacterFoodDrainMultiplier", label: "Food drain rate",   type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerCharacterWaterDrainMultiplier",label: "Water drain rate",  type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerCharacterStaminaDrainMultiplier", label: "Stamina drain",  type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerCharacterHealthRecoveryMultiplier", label: "Health recovery", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "XPMultiplier",                       label: "XP multiplier",     type: "number", step: 0.1, min: 0, max: 100 },
      ]},

      { title: "Dinos", icon: "🦖", description: "Wild + tamed dinosaur behaviour and difficulty.", fields: [
        { prop: "DifficultyOffset",                   label: "Difficulty offset",    type: "number", step: 0.01, min: 0, max: 1, hint: "1.0 = official difficulty (wild dinos up to level 150)" },
        { prop: "OverrideOfficialDifficulty",         label: "Override official difficulty", type: "number", step: 0.1, min: 0, max: 50, hint: "5.0 = wild dinos up to level 150; 10.0 = up to 300" },
        { prop: "DinoCountMultiplier",                label: "Dino spawn count",     type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DinoDamageMultiplier",               label: "Wild dino damage",     type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "TamedDinoDamageMultiplier",          label: "Tamed dino damage",    type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DinoResistanceMultiplier",           label: "Wild dino resistance", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "TamedDinoResistanceMultiplier",      label: "Tamed dino resistance", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DinoCharacterFoodDrainMultiplier",   label: "Dino food drain",      type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DinoCharacterStaminaDrainMultiplier",label: "Dino stamina drain",   type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "DinoCharacterHealthRecoveryMultiplier", label: "Dino health recovery", type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "MaxTamedDinos",                      label: "Server-wide tamed dino cap", type: "number", min: 0, max: 20000 },
        { prop: "MaxPersonalTamedDinos",              label: "Per-tribe tamed dino cap", type: "number", min: 0, max: 2000 },
        { prop: "RaidDinoCharacterFoodDrainMultiplier", label: "Raid dino food drain", type: "number", step: 0.1, min: 0, max: 100 },
      ]},

      { title: "Structures", icon: "🏛️", description: "Build limits, decay, and quality-of-life around placement and pickup.", fields: [
        { prop: "StructureDamageMultiplier",          label: "Structure damage dealt",    type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "StructureResistanceMultiplier",      label: "Structure damage taken",    type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "TheMaxStructuresInRange",            label: "Max structures in range",   type: "number", min: 100, max: 100000 },
        { prop: "bDisableStructureDecayPvE",          label: "Disable structure decay (PvE)", type: "bool" },
        { prop: "AlwaysAllowStructurePickup",         label: "Always allow pickup",       type: "bool", hint: "Skip the 30s pickup window — pickup forever" },
        { prop: "StructurePickupTimeAfterPlacement", label: "Pickup window (seconds)",   type: "number", min: 0, max: 3600 },
        { prop: "StructurePickupHoldDuration",        label: "Pickup hold duration (sec)", type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "bPvEAllowStructuresAtSupplyDrops",   label: "PvE: allow builds near supply drops", type: "bool" },
        { prop: "PvPStructureDecay",                  label: "PvP: structure decay",      type: "bool" },
        { prop: "DestroyTamesOverTheSoftTameLimit",   label: "Auto-destroy tames over soft limit", type: "bool" },
      ]},

      { title: "Combat & PvP", icon: "⚔️", description: "Combat modes and friendly-fire rules. <strong>Server mode</strong> is the master toggle — PvP allows player-on-player damage and base raiding; PvE blocks both. Default (when unset) is PvP.", fields: [
        // ServerPVE is the load-bearing toggle. Empty INI value =
        // PvP (ARK default). Setting this to True flips the server to
        // PvE on the next restart. Launch args can override (?ServerPVE=)
        // but the acekorneya image doesn't pass that, so the INI wins.
        { prop: "ServerPVE",                           label: "Server mode", type: "enum",
          options: [["","PvP (default)"],["True","PvE"],["False","PvP (explicit)"]],
          hint: "PvE prevents player-vs-player damage and structure raiding. Wild dinos can still attack. Takes effect on next server restart." },
        { prop: "PlayerKillingMode",                   label: "Player killing mode", type: "enum",
          options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] },
        { prop: "bDisableFriendlyFire",                label: "Disable friendly fire (global)", type: "bool" },
        { prop: "bPvEDisableFriendlyFire",             label: "Disable friendly fire (PvE only)", type: "bool" },
        { prop: "AllowHideDamageSourceFromLogs",       label: "Allow hide damage source from logs", type: "bool" },
        { prop: "bIncreasePvPRespawnInterval",         label: "Increase PvP respawn cooldown", type: "bool" },
        { prop: "IncreasePvPRespawnIntervalCheckPeriod", label: "PvP respawn check period (sec)", type: "number", min: 30, max: 3600 },
        { prop: "IncreasePvPRespawnIntervalMultiplier", label: "PvP respawn multiplier", type: "number", step: 0.1, min: 1, max: 10 },
      ]},

      { title: "Tribes & Alliances", icon: "🛡️", description: "Tribe size, alliances, and log retention.", fields: [
        { prop: "TribeNameChangeCooldown",             label: "Tribe rename cooldown (min)", type: "number", min: 0, max: 1440 },
        { prop: "MaxTribeLogs",                        label: "Max tribe log entries",        type: "number", min: 0, max: 10000 },
        { prop: "MaxNumbersofPlayersInTribe",          label: "Max players per tribe",        type: "number", min: 1, max: 500 },
        { prop: "MaxAlliancesPerTribe",                label: "Max alliances per tribe",      type: "number", min: 0, max: 100 },
        { prop: "MaxTribesPerAlliance",                label: "Max tribes per alliance",      type: "number", min: 0, max: 100 },
        { prop: "TribeLogDestroyedEnemyStructures",    label: "Log destroyed enemy structures", type: "bool" },
      ]},

      { title: "Quality of Life", icon: "✨", description: "Stuff most servers turn on without thinking.", fields: [
        { prop: "ShowMapPlayerLocation",               label: "Show player location on map",   type: "bool" },
        { prop: "AllowThirdPersonPlayer",              label: "Allow 3rd-person camera",       type: "bool" },
        { prop: "ServerCrosshair",                     label: "Enable crosshair",              type: "bool" },
        { prop: "bAllowFlyerCarryPvE",                 label: "Allow flyer carry (PvE)",       type: "bool" },
        { prop: "bAllowUnlimitedRespecs",              label: "Unlimited mindwipe respecs",    type: "bool" },
        { prop: "bDisableDinoRiding",                  label: "Disable dino riding",           type: "bool" },
        { prop: "bDisableDinoTaming",                  label: "Disable dino taming",           type: "bool" },
        { prop: "bFlyerPlatformAllowUnalignedDinoBasing", label: "Unaligned dinos on flyer platforms", type: "bool" },
        { prop: "bAllowCustomRecipes",                 label: "Allow custom recipes",          type: "bool" },
        { prop: "bAllowRaidDinoFeeding",               label: "Allow raid dino feeding",       type: "bool" },
      ]},

      { title: "Cluster transfers", icon: "🔄", description: "Per-server transfer permissions. Only relevant when this server is in a cluster. <b>Upload</b> = sending FROM this server INTO the cluster (player at obelisk → cluster storage). <b>Download</b> = pulling FROM the cluster ONTO this server (arrivals on this map). Default off = transfers permitted both ways.", fields: [
        { prop: "PreventUploadSurvivors",              label: "Prevent survivor uploads",      type: "bool", hint: "Survivors on this server cannot leave to the cluster." },
        { prop: "PreventUploadDinos",                  label: "Prevent dino uploads",          type: "bool", hint: "Dinos on this server cannot leave to the cluster." },
        { prop: "PreventUploadItems",                  label: "Prevent item uploads",          type: "bool", hint: "Items on this server cannot leave to the cluster." },
        { prop: "PreventDownloadSurvivors",            label: "Prevent survivor downloads",    type: "bool", hint: "Survivors from the cluster cannot arrive on this server." },
        { prop: "PreventDownloadDinos",                label: "Prevent dino downloads",        type: "bool", hint: "Dinos from the cluster cannot arrive on this server." },
        { prop: "PreventDownloadItems",                label: "Prevent item downloads",        type: "bool", hint: "Items from the cluster cannot arrive on this server." },
      ]},

      { title: "Mods", icon: "🧩", description: `<div style="padding:10px 12px;margin-bottom:10px;background:rgba(95,178,255,.06);border-left:3px solid var(--accent);border-radius:4px;font-size:12.5px;line-height:1.55">
        💡 <b>Mod IDs are managed in the 🧩 Mods tab</b>, not here. That tab has CurseForge search, previews, and one-click install — it writes to the launch-arg <span class="mono">-mods=</span> / <span class="mono">-passivemods=</span> lists, which is <em>the</em> way ASA loads CurseForge mods (per Wildcard's own docs).
        <br><br>
        The older <span class="mono">ActiveMods=</span> / <span class="mono">ActiveModsList=</span> entries in <span class="mono">GameUserSettings.ini</span> were the ASE / Steam-Workshop mechanism — <b>ASA ignores them completely</b>. That's why those fields aren't exposed in this form.
        </div>
        The fields below are mod-adjacent server tuning that isn't mod IDs.`, fields: [
        { prop: "PreventHibernation", label: "Prevent hibernation", type: "bool", hint: "Some mods (big maps, heavy gameplay tweaks) need this ON so tamed dinos stay active in unloaded regions." },
      ]},

      { title: "Admin & Logs", icon: "🛠️", description: "Logging, admin-command visibility, chat behaviour.", fields: [
        { prop: "AdminLogging",                        label: "Admin command logging",         type: "bool" },
        { prop: "LogRCONChat",                         label: "Log RCON chat",                 type: "bool" },
        { prop: "NotifyAdminCommandsInChat",           label: "Announce admin commands in chat", type: "bool" },
        { prop: "ServerChatAutoTime",                  label: "Show time in chat",             type: "bool" },
        { prop: "AllowAnyoneBabyImprintCuddle",        label: "Anyone can imprint babies",     type: "bool" },
        { prop: "DisableImprintDinoBuff",              label: "Disable imprint ride-damage buff", type: "bool" },
        { prop: "ClampResourceHarvestDamage",          label: "Clamp resource harvest damage", type: "bool" },
      ]},
    ],
  },
  "empyrion": {
    file: "dedicated.yaml",
    filePaths: ["dedicated.yaml", "/game-saves/dedicated.yaml", "/game/dedicated.yaml"],
    format: "yaml_empyrion",
    groups: [
      { title: "Identity", icon: "🏷️", description: "Server name, description, password, and visibility.", fields: [
        { prop: "Srv_Name",        label: "Server name",    type: "string" },
        { prop: "Srv_Description", label: "Description",    type: "string" },
        { prop: "Srv_Password",    label: "Join password",  type: "password", hint: "Leave blank for an open server" },
        { prop: "Srv_Public",      label: "Publicly listed", type: "bool" },
      ]},
      { title: "Slots & Network", icon: "🔌", description: "Player cap and the ports Empyrion listens on.", fields: [
        { prop: "Srv_MaxPlayers",       label: "Max players",       type: "number", min: 1, max: 128 },
        { prop: "Srv_Port",             label: "Server port (UDP)", type: "number", min: 1, max: 65535 },
        { prop: "Srv_ReservePlayfields", label: "Reserved playfields", type: "number", min: 0, max: 8 },
        { prop: "Srv_StopPeriod",       label: "Auto-stop after N min idle (0 = off)", type: "number", min: 0 },
        { prop: "EACActive",            label: "EasyAntiCheat enabled", type: "bool" },
        { prop: "TelnetEnabled",        label: "Telnet admin enabled",  type: "bool" },
        { prop: "TelnetPort",           label: "Telnet port",           type: "number", min: 1, max: 65535 },
        { prop: "TelnetPassword",       label: "Telnet password",       type: "password" },
      ]},
      { title: "World", icon: "🌍", description: "Game mode, scenario, and seed. Changing these usually requires a fresh save.", fields: [
        { prop: "GameName",       label: "Save name",            type: "string", hint: "Folder under Saves/Games" },
        { prop: "Mode",           label: "Game mode",            type: "enum", options: ["Survival","Creative","Freedom"] },
        { prop: "Seed",           label: "Seed",                 type: "string", hint: "Numeric seed; blank = random" },
        { prop: "CustomScenario", label: "Custom scenario",      type: "string", hint: "Folder under Content/Scenarios; blank = Default" },
      ]},
      { title: "Tuning", icon: "⚙️", description: "Heartbeats, log detail, and blueprint policy.", fields: [
        { prop: "HeartbeatServer",             label: "Server heartbeat (s)", type: "number", min: 5, max: 120 },
        { prop: "HeartbeatClient",             label: "Client heartbeat (s)", type: "number", min: 5, max: 120 },
        { prop: "LogFlags",                    label: "Log flags (bitmask)",  type: "number", min: 0, max: 4095 },
        { prop: "ChatChannelPermission",       label: "Chat-channel permission", type: "number", min: 0, max: 9 },
        { prop: "MaxAllowedSizeClass",         label: "Max allowed structure size class", type: "number", min: 1, max: 20 },
        { prop: "AllowedBlueprints",           label: "Allowed blueprints", type: "enum", options: ["All","StockOnly","None"] },
        { prop: "PlayerLoginFullServerParallelCount", label: "Parallel logins to full server", type: "number", min: 1, max: 20 },
      ]},
    ],
  },
  "valheim": {
    // Panel-native Valheim reads /game-saves/server.cfg (shell-style
    // KEY=value lines) on every boot — the entrypoint sources it and
    // passes the values to the game binary as CLI flags.
    file: "server.cfg",
    filePaths: ["server.cfg", "/game-saves/server.cfg"],
    format: "properties",
    groups: [
      { title: "Identity", icon: "🏷️", description: "How your server shows up in the server browser and who can join.", fields: [
        { prop: "SERVER_NAME",     label: "Server name",    type: "string" },
        { prop: "SERVER_WORLD",    label: "World name",     type: "string", hint: "Becomes the save folder + .db/.fwl filenames under /game-saves" },
        { prop: "SERVER_PASSWORD", label: "Join password",  type: "password", hint: "Valheim requires ≥5 chars, and it can't contain the server or world name" },
        { prop: "SERVER_PUBLIC",   label: "Publicly listed", type: "enum", options: [["1","Public (crossplay)"],["0","Unlisted"]] },
      ]},
    ],
  },
  "minecraft-java": {
    file: "server.properties",
    filePaths: ["server.properties", "/data/server.properties"],
    format: "properties",
    groups: [
      { title: "Identity", fields: [
        { prop: "motd",         label: "MOTD (server description)", type: "string" },
        { prop: "server-port",  label: "Server port", type: "number", min: 1, max: 65535 },
        { prop: "max-players",  label: "Max players", type: "number", min: 1, max: 250 },
      ]},
      { title: "Gameplay", fields: [
        { prop: "difficulty", label: "Difficulty", type: "enum", options: ["peaceful","easy","normal","hard"] },
        { prop: "gamemode",   label: "Default gamemode", type: "enum", options: ["survival","creative","adventure","spectator"] },
        { prop: "hardcore",   label: "Hardcore", type: "bool" },
        { prop: "pvp",        label: "PvP", type: "bool" },
        { prop: "allow-flight", label: "Allow flight (survival)", type: "bool" },
        { prop: "force-gamemode", label: "Force default gamemode on join", type: "bool" },
      ]},
      { title: "World", fields: [
        { prop: "level-name", label: "World folder", type: "string" },
        { prop: "level-seed", label: "World seed", type: "string", hint: "Blank = random" },
        { prop: "level-type", label: "World type", type: "enum",
          options: ["minecraft:normal","minecraft:flat","minecraft:large_biomes","minecraft:amplified","minecraft:single_biome_surface"] },
        { prop: "generate-structures", label: "Generate structures", type: "bool" },
        { prop: "allow-nether", label: "Allow Nether", type: "bool" },
        { prop: "spawn-protection", label: "Spawn protection (blocks)", type: "number", min: 0, max: 1000 },
      ]},
      { title: "Players & auth", fields: [
        { prop: "online-mode", label: "Online mode (require Mojang auth)", type: "bool" },
        { prop: "white-list",  label: "Enable whitelist", type: "bool" },
        { prop: "enforce-whitelist", label: "Kick non-whitelisted already online", type: "bool" },
        { prop: "op-permission-level", label: "Default op level", type: "number", min: 1, max: 4 },
      ]},
      { title: "Performance", fields: [
        { prop: "view-distance", label: "View distance (chunks)", type: "number", min: 3, max: 32 },
        { prop: "simulation-distance", label: "Simulation distance (chunks)", type: "number", min: 5, max: 32 },
        { prop: "entity-broadcast-range-percentage", label: "Entity broadcast range (%)", type: "number", min: 10, max: 500, step: 10 },
      ]},
      { title: "Commands & protection", fields: [
        { prop: "enable-command-block", label: "Enable command blocks", type: "bool" },
        { prop: "function-permission-level", label: "/function permission level", type: "number", min: 1, max: 4 },
      ]},
    ],
  },

  // Palworld stores its config inside a single OptionSettings=(…) tuple
  // in PalWorldSettings.ini. The entrypoint patches identity / port / RCON
  // fields from env on every boot, so this schema deliberately omits them
  // to keep the source of truth where the operator expects it (the panel's
  // Settings tab edits them via env, the entrypoint applies them).
  "palworld": {
    file: "PalWorldSettings.ini",
    filePaths: [
      "Pal/Saved/Config/LinuxServer/PalWorldSettings.ini",
      "/game/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini",
    ],
    format: "ini_palworld",
    groups: [
      { title: "World", icon: "🌎", description: "World rules — difficulty + the speed multipliers Palworld exposes for day/night length, work speed, and breeding/egg pacing.", fields: [
        { prop: "Difficulty",                 label: "Difficulty",                 type: "enum", options: ["None","Casual","Normal","Hard"], hint: "<code>None</code> means \"use the per-field overrides below\"" },
        { prop: "DayTimeSpeedRate",           label: "Daytime speed",              type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "NightTimeSpeedRate",         label: "Nighttime speed",            type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "ExpRate",                    label: "Experience gain",            type: "number", step: 0.1, min: 0.1, max: 20 },
        { prop: "WorkSpeedRate",              label: "Player work speed",          type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "EnableInvaderEnemy",         label: "Invader raids",              type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "DeathPenalty",               label: "Death penalty",              type: "enum", options: ["None","Item","ItemAndEquipment","All"] },
      ]},
      { title: "Pals", icon: "🐾", description: "Pal rates — capture odds, spawn density, drop bonuses, breeding/egg cadence.", fields: [
        { prop: "PalCaptureRate",             label: "Capture rate",               type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalSpawnNumRate",            label: "Pal spawn density",          type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalDamageRateAttack",        label: "Pal attack damage",          type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalDamageRateDefense",       label: "Pal defense (incoming dmg multiplier)", type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalStomachDecreaseRate",     label: "Pal hunger drain",           type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PalStaminaDecreaseRate",     label: "Pal stamina drain",          type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PalAutoHPRegeneRate",        label: "Pal HP regen (active)",      type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalAutoHPRegeneRateInSleep", label: "Pal HP regen (sleeping)",    type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PalEggDefaultHatchingTime",  label: "Egg hatch time (hours)",     type: "number", step: 0.5, min: 0.5, max: 200 },
        { prop: "DropItemMultiplier",         label: "Item drop multiplier",       type: "number", step: 0.1, min: 0.1, max: 20 },
      ]},
      { title: "Player", icon: "🧑", description: "Player-side rates that don't fit \"world\" — damage, hunger/stamina, sanity.", fields: [
        { prop: "PlayerDamageRateAttack",     label: "Player attack damage",       type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PlayerDamageRateDefense",    label: "Player defense (incoming dmg multiplier)", type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PlayerStomachDecreaseRate",  label: "Player hunger drain",        type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PlayerStaminaDecreaseRate",  label: "Player stamina drain",       type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PlayerAutoHPRegeneRate",     label: "Player HP regen (active)",   type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "PlayerAutoHPRegeneRateInSleep", label: "Player HP regen (sleeping)", type: "number", step: 0.1, min: 0.1, max: 10 },
      ]},
      { title: "Base / Guild", icon: "🏠", description: "Pal-base capacity, guild player limits, build worker counts.", fields: [
        { prop: "BaseCampMaxNumInGuild",      label: "Bases per guild",            type: "number", min: 1, max: 50 },
        { prop: "BaseCampWorkerMaxNum",       label: "Workers per base",           type: "number", min: 1, max: 50 },
        { prop: "GuildPlayerMaxNum",          label: "Players per guild",          type: "number", min: 1, max: 100 },
        { prop: "bIsMultiplay",               label: "Multiplayer",                type: "enum", options: [["True","Multiplayer"],["False","Single-player only"]] },
        { prop: "bIsPvP",                     label: "PvP enabled",                type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "bHardcore",                  label: "Hardcore (no respawn)",      type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "bPalLost",                   label: "Lose pals on death",         type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "bCharacterRecreateInHardcore", label: "Recreate character in hardcore", type: "enum", options: [["True","On"],["False","Off"]] },
      ]},
      { title: "Resources", icon: "⛏️", description: "Tunables for collected/dropped resource rates and chest stack sizes.", fields: [
        { prop: "CollectionDropRate",         label: "Collection drop rate",       type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "CollectionObjectHpRate",     label: "Collection object HP",       type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "CollectionObjectRespawnSpeedRate", label: "Collection respawn speed", type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "EnemyDropItemRate",          label: "Enemy drop rate",            type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "BuildObjectHpRate",          label: "Built object HP",            type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "BuildObjectDamageRate",      label: "Built object damage taken",  type: "number", step: 0.1, min: 0.1, max: 10 },
        { prop: "BuildObjectDeteriorationDamageRate", label: "Building decay rate", type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "ItemContainerForceMarkDirtyInterval", label: "Container save interval (s)", type: "number", min: 0, max: 600 },
      ]},
      { title: "Chat / Notes", icon: "💬", description: "Operator-visible toggles that are NOT also patched from env on each boot. Server name / description / max players / RCON live in the Settings → Configuration tab (env-driven, applied on boot).", fields: [
        { prop: "Region",                     label: "Region tag",                 type: "string", hint: "Free-form tag, surfaces in the public listing" },
        { prop: "bUseAuth",                   label: "Require Steam auth",         type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "BanListURL",                 label: "Shared ban list URL",        type: "string", hint: "Leave default unless you're running a community ban-list server" },
      ]},
    ],
  },

  // Windrose — ServerDescription.json is the whole config surface. Field
  // list mirrors cytech-services' AMP template (windroseconfig.json) so
  // operators migrating from AMP get the same 7-field view. Fields not in
  // AMP's template (UseDirectConnection, UserSelectedRegion, etc.) are
  // preserved verbatim by the JSON serializer but aren't exposed here —
  // Windrose connects every client through its own P2P coop gateway, and
  // direct-connection mode is not the intended path per-game.
  //
  // Invite code + world ID are auto-generated by Windrose on first boot
  // when the corresponding field is empty, so auto-populate just works.
  "windrose": {
    file: "R5/ServerDescription.json",
    filePaths: [
      "R5/ServerDescription.json",
    ],
    format: "json",
    groups: [
      { title: "Identity", icon: "🏷️", description: "What players see when they browse or join. <strong>Server name</strong> is the label in-game; <strong>Note</strong> is an extra line to disambiguate servers with similar invite codes. Password protection + a password together gate who can join.", fields: [
        { prop: "ServerDescription_Persistent.ServerName",          label: "Server name",        type: "string", hint: "Shown in the in-game server browser" },
        { prop: "ServerDescription_Persistent.Note",                label: "Server note",        type: "string", hint: "Optional extra description — useful when invite codes collide" },
        { prop: "ServerDescription_Persistent.IsPasswordProtected", label: "Enable password protection", type: "bool" },
        { prop: "ServerDescription_Persistent.Password",            label: "Server password",    type: "password", hint: "Only enforced when password protection is on" },
        { prop: "ServerDescription_Persistent.MaxPlayerCount",      label: "Player limit",       type: "number", min: 1, max: 10, hint: "More than 4 may cause performance issues, especially with large frigates" },
      ]},

      { title: "Invite & World", icon: "🧭", description: "<strong>Invite code</strong> is what players type in-game to find this server — Windrose routes them through its P2P coop gateway, so no port forwarding is required. Clearing the field and restarting forces Windrose to mint a fresh one. <strong>World island ID</strong> selects which save folder under <code>R5/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/&lt;id&gt;/</code> the server loads; clearing it creates a fresh world on next boot.", fields: [
        { prop: "ServerDescription_Persistent.InviteCode",    label: "Invite code",     type: "string", hint: "At least 6 alphanumeric characters, case-sensitive. Empty = regenerate on next start." },
        { prop: "ServerDescription_Persistent.WorldIslandId", label: "World island ID", type: "string", hint: "Must match the ID in that world's WorldDescription.json. Empty = new world on next start." },
      ]},
    ],
  },

  // Conan Exiles — settings live in ConanSandbox/Saved/Config/<subdir>/
  // ServerSettings.ini under a single [ServerSettings] header. The subdir is
  // edition-aware: Enhanced (UE5, native Linux) writes to LinuxServer/,
  // Legacy (UE4, Wine) writes to WindowsServer/. The schema's `file`
  // (display) and `filePaths` (lookup) get rewritten at openCfg() time based
  // on the instance's config_values.EDITION. Same shape as ARK SA's
  // GameUserSettings.ini so we reuse the ini_ark parser/serializer: updates
  // land under [ServerSettings], stranded keys in other sections get healed
  // automatically. Server name + admin password also live here, but panel
  // ALSO sets SERVER_NAME / RCON_PASSWORD via env at launch — env wins for
  // those two specifically, so editing them here is informational only (the
  // launch arg overrides on next start).
  "conan-exiles": {
    file: "ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini",
    filePaths: [
      "ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini",
      "ConanSandbox/Saved/Config/WindowsServer/ServerSettings.ini",
    ],
    format: "ini_ark",
    groups: [
      { title: "Identity & Access", icon: "🏷️", description: "Player-facing identity + login gates. <strong>ServerName</strong> is what shows up in the in-game server browser. <strong>AdminPassword</strong> is the in-game admin auth (used by <code>/makeme admin</code>); RCON has its own password set via env.", fields: [
        { prop: "ServerName",               label: "Server name",            type: "string", hint: "Shown in the in-game server browser. Stamped into [ServerSettings] ServerName= on every boot from the SERVER_NAME env (set via the instance's config_values)." },
        { prop: "AdminPassword",            label: "In-game admin password", type: "password" },
        { prop: "ServerPassword",           label: "Join password",          type: "password", hint: "Empty = open server" },
        { prop: "ServerMessageOfTheDay",    label: "MOTD",                    type: "string" },
        { prop: "MaxNudity",                label: "Max nudity tier",         type: "enum", options: [["0","None"],["1","Partial"],["2","Full"]] },
        { prop: "ServerCommunity",          label: "Community tag",           type: "enum", options: [["0","No tag"],["1","Purist"],["2","Relaxed"],["3","Hard core"],["4","Role playing"],["5","Experimental"]] },
        { prop: "serverRegion",             label: "Region",                   type: "enum", options: [["0","EU"],["1","NA"],["2","Asia"],["3","Oceania"],["4","SA"]] },
      ]},

      { title: "PvP & Combat", icon: "⚔️", description: "Master toggle is <strong>PVPEnabled</strong>. PvE servers leave it False. The various time-restricted PvP windows below only apply when PVPEnabled is True.", fields: [
        { prop: "PVPEnabled",                       label: "PvP enabled",                  type: "bool" },
        { prop: "RestrictPVPTime",                  label: "Restrict PvP to time windows", type: "bool" },
        { prop: "RestrictPVPBuildingDamageTime",    label: "Restrict PvP building damage to time windows", type: "bool" },
        { prop: "FriendlyFireDamageMultiplier",     label: "Friendly fire damage", type: "number", step: 0.05, min: 0, max: 5 },
        { prop: "CombatModeModifier",               label: "Combat mode modifier", type: "enum", options: [["0","Default"],["1","Targeting"],["2","Soft tab target"]] },
        { prop: "CanDamagePlayerOwnedStructures",   label: "Damage player-owned structures (PvE-only flag)", type: "bool" },
      ]},

      { title: "Player Rates", icon: "🧍", description: "Per-player damage / movement / XP scaling. 1.0 = vanilla.", fields: [
        { prop: "PlayerDamageMultiplier",         label: "Player damage dealt",         type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerDamageTakenMultiplier",    label: "Player damage taken",          type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerKnockbackMultiplier",      label: "Player knockback",              type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerMovementSpeedScale",       label: "Movement speed",                type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PlayerSprintSpeedScale",         label: "Sprint speed",                  type: "number", step: 0.1, min: 0.1, max: 5 },
        { prop: "PlayerStaminaCostMultiplier",    label: "Stamina cost (general)",        type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerStaminaCostSprintMultiplier", label: "Stamina cost (sprint)",      type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerStaminaRegenSpeedScale",   label: "Stamina regen speed",            type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerHealthRegenSpeedScale",    label: "Health regen speed",             type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerEncumbranceMultiplier",    label: "Encumbrance",                    type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "PlayerXPRateMultiplier",         label: "XP gain (overall)",              type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerXPKillMultiplier",         label: "XP from kills",                  type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerXPHarvestMultiplier",      label: "XP from harvest",                type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerXPCraftMultiplier",        label: "XP from crafting",               type: "number", step: 0.1, min: 0, max: 100 },
        { prop: "PlayerXPTimeMultiplier",         label: "Idle XP time multiplier",        type: "number", step: 0.1, min: 0, max: 100 },
      ]},

      { title: "NPCs, Thralls & Pets", icon: "🐾", description: "NPC + minion (thrall, pet) tuning. Thrall decay can be globally disabled via the dedicated toggle.", fields: [
        { prop: "NPCDamageMultiplier",          label: "NPC damage dealt",       type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "NPCDamageTakenMultiplier",     label: "NPC damage taken",        type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "NPCHealthMultiplier",          label: "NPC health",              type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "NPCKnockbackMultiplier",       label: "NPC knockback",            type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "NPCRespawnMultiplier",         label: "NPC respawn rate",         type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "NPCMaxSpawnCapMultiplier",     label: "NPC spawn cap",            type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "MinionDamageMultiplier",       label: "Thrall/pet damage dealt", type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "MinionDamageTakenMultiplier",  label: "Thrall/pet damage taken",  type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "ThrallDamageToPlayersMultiplier", label: "Thrall dmg to players", type: "number", step: 0.05, min: 0, max: 5 },
        { prop: "ThrallDamageToNPCsMultiplier",    label: "Thrall dmg to NPCs",    type: "number", step: 0.05, min: 0, max: 5 },
        { prop: "ThrallConversionMultiplier",   label: "Thrall conversion speed",  type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "DisableThrallDecay",           label: "Disable thrall decay",      type: "bool" },
        { prop: "ThrallDecayTime",              label: "Thrall decay time (s)",     type: "number", min: 60, max: 5184000 },
        { prop: "AnimalPenCraftingTimeMultiplier", label: "Animal pen craft time", type: "number", step: 0.1, min: 0, max: 10 },
      ]},

      { title: "Building & Decay", icon: "🏛️", description: "Structure tuning + abandonment / decay. <strong>DisableBuildingAbandonment</strong> is the master toggle for keeping bases up indefinitely; the multipliers below modify decay speed when abandonment is on.", fields: [
        { prop: "StructureDamageMultiplier",        label: "Structure damage dealt", type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "StructureHealthMultiplier",        label: "Structure health",        type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "BuildingDamageMultiplier",         label: "Building damage taken",   type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "DisableBuildingAbandonment",       label: "Disable building abandonment", type: "bool" },
        { prop: "MaxBuildingDecayTime",             label: "Max decay time (s)",       type: "number", min: 0, max: 31536000 },
        { prop: "MaxDecayTimeToAutoDemolish",       label: "Auto-demolish time (s)",   type: "number", min: 0, max: 31536000 },
        { prop: "BuildingDecayTimePerScore",        label: "Decay time per score",     type: "number", min: 60, max: 86400 },
        { prop: "BuildingDecayTimeMultiplier",      label: "Decay time multiplier",    type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "DecayShowBuildingScore",           label: "Show building score",      type: "bool" },
        { prop: "DynamicBuildingDamage",            label: "Dynamic building damage",  type: "bool" },
        { prop: "BuildingPickupEnabled",            label: "Allow building pickup",    type: "bool" },
        { prop: "AllowBuildingAnywhere",            label: "Build anywhere (no biome restrictions)", type: "bool" },
        { prop: "LandClaimRadiusMultiplier",        label: "Landclaim radius mult",    type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "DisableLandclaimNotifications",    label: "Disable landclaim notifications", type: "bool" },
        { prop: "CampsIgnoreLandclaim",             label: "Camps ignore landclaim",   type: "bool" },
        { prop: "ContainersIgnoreOwnership",        label: "Containers ignore ownership", type: "bool" },
        { prop: "StabilityLossMultiplier",          label: "Stability loss",            type: "number", step: 0.1, min: 0, max: 10 },
      ]},

      { title: "Crafting & Resources", icon: "⚒️", description: "Time, cost and conversion rates for crafting + harvesting.", fields: [
        { prop: "CraftingCostMultiplier",     label: "Crafting cost",          type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "ItemConvertionMultiplier",   label: "Item conversion speed", type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "FuelBurnTimeMultiplier",     label: "Fuel burn time",          type: "number", step: 0.1, min: 0, max: 10 },
        { prop: "ItemRepairMinimumDurability", label: "Min durability for repair", type: "number", step: 0.05, min: 0, max: 1 },
      ]},

      { title: "Anti-Cheat & Network", icon: "🛡️", description: "BattlEye + VAC + ping/family-sharing gates. Conan on Linux/Wine usually leaves BattlEye off (the kernel driver is Windows-only).", fields: [
        { prop: "IsBattlEyeEnabled",          label: "BattlEye enabled",        type: "bool" },
        { prop: "IsVACEnabled",               label: "VAC enabled",              type: "bool" },
        { prop: "MaxAllowedPing",             label: "Max allowed ping (ms)",    type: "number", min: 0, max: 1000, hint: "0 = no limit" },
        { prop: "AllowFamilySharedAccount",   label: "Allow family-shared accounts", type: "bool" },
        { prop: "ServerTransferEnabled",      label: "Allow ServerTransfer",     type: "bool" },
        { prop: "EnableLoginQueue",           label: "Login queue",              type: "bool" },
        { prop: "DisconnectionGraceTime",     label: "Reconnect grace (s)",      type: "number", min: 0, max: 600 },
      ]},

      { title: "AFK & Population", icon: "💤", description: "Idle player handling + thrall/follower limits.", fields: [
        { prop: "KickAFKTime",            label: "AFK kick time (s)",       type: "number", min: 60, max: 86400 },
        { prop: "KickAFKPercentage",      label: "AFK kick threshold (% full)", type: "number", min: 0, max: 100 },
        { prop: "ShowOnlinePlayers",      label: "Show online players",      type: "enum", options: [["0","Hide"],["1","Show"]] },
        { prop: "MinionPopulationBaseValue", label: "Minion population base", type: "number", min: 0, max: 1000 },
        { prop: "MinionPopulationPerPlayer", label: "Minion population per player", type: "number", min: 0, max: 100 },
        { prop: "UseMinionPopulationLimit",  label: "Use minion population limit", type: "bool" },
      ]},

      { title: "Misc", icon: "🧰", description: "Less-touched but occasionally useful flags.", fields: [
        { prop: "AmbientLifeEnabled",       label: "Ambient life enabled",       type: "bool" },
        { prop: "EnableTargetLock",         label: "Enable target lock",          type: "bool" },
        { prop: "EnableFatalities",         label: "Enable fatalities",            type: "bool" },
        { prop: "AvatarsDisabled",          label: "Disable avatars (god summons)", type: "bool" },
        { prop: "AvatarLifetime",           label: "Avatar lifetime (s)",          type: "number", min: 60, max: 3600 },
        { prop: "AvatarSummonTime",         label: "Avatar summon cast (s)",       type: "number", min: 5, max: 600 },
        { prop: "EnableClanMarkers",        label: "Enable clan markers",          type: "bool" },
        { prop: "DisableChatFormatting",    label: "Disable chat formatting",      type: "bool" },
        { prop: "EventSystemEnabled",       label: "Event system enabled",         type: "bool" },
        { prop: "serverVoiceChat",          label: "Voice chat",                   type: "enum", options: [["0","Off"],["1","Proximity"],["2","Faction"],["3","Global"]] },
      ]},
    ],
  },

  // Rust — server.cfg uses Facepunch's "convar" syntax, NOT INI:
  //   server.hostname "Server Name"
  //   server.maxplayers 100
  //   server.url ""
  // Key tokens are dot-namespaced; values are either bare numbers or quoted
  // strings. We use a dedicated cfg_rust parser (parseCfgRust /
  // serializeCfgRust below) since INI/properties parsers mangle the quoting.
  // Most tunables also live as launch args (set via env vars in the Settings
  // → Launch tab); server.cfg is best for the small set of "live editable"
  // settings that don't justify a per-instance container recreate.
  "rust": {
    file: "server/$SERVER_IDENTITY/cfg/server.cfg",
    filePaths: [
      "server/my_server_identity/cfg/server.cfg",
      "server/panel-rust/cfg/server.cfg",
    ],
    format: "cfg_rust",
    groups: [
      { title: "Identity & Branding", icon: "🏷️", description: "What players see in the in-game server browser. <strong>Hostname</strong> is the row title; <strong>Description</strong> wraps to the right-side panel; <strong>Header image</strong> must be a 512×256 PNG/JPG hosted publicly.", fields: [
        { prop: "server.hostname",         label: "Server name",                 type: "string" },
        { prop: "server.description",      label: "Description",                  type: "string" },
        { prop: "server.url",              label: "Website URL",                  type: "string" },
        { prop: "server.headerimage",      label: "Header image URL",             type: "string", hint: "512×256 PNG/JPG, must be HTTPS" },
        { prop: "server.tags",             label: "Tags",                          type: "string", hint: "Comma-separated, e.g. monthly,vanilla,pve" },
      ]},

      { title: "World & Capacity", icon: "🌍", description: "Most of these are also baked into the launch args via env (SERVER_SEED / SERVER_WORLD_SIZE / MAX_PLAYERS / SERVER_LEVEL). Editing them here only takes effect after a stop+start; for permanent changes prefer the env-side fields under Launch settings.", fields: [
        { prop: "server.maxplayers",       label: "Max players",                  type: "number", min: 1, max: 500 },
        { prop: "server.worldsize",        label: "World size",                    type: "number", min: 1000, max: 6000, hint: "1000–6000. Bigger = longer procgen + more RAM." },
        { prop: "server.seed",             label: "World seed",                    type: "number", min: 0, max: 2147483647 },
        { prop: "server.saveinterval",     label: "Auto-save interval (s)",       type: "number", min: 60, max: 3600 },
        { prop: "server.radiation",        label: "Radiation enabled",             type: "bool" },
      ]},

      { title: "Gameplay Tuning", icon: "🎮", description: "Common gameplay knobs operators tune live without restarting.", fields: [
        { prop: "decay.scale",             label: "Decay scale",                   type: "number", step: 0.1, min: 0, max: 10, hint: "0 = no decay; 1 = vanilla." },
        { prop: "smelting.speed",          label: "Smelting speed",                 type: "number", step: 0.1, min: 0.1, max: 100 },
        { prop: "craft.instant_craft_max_seconds", label: "Instant-craft cap (s)", type: "number", min: 0, max: 60 },
        { prop: "server.gathermultiplier", label: "Gather multiplier",             type: "number", step: 0.1, min: 0.1, max: 100 },
        { prop: "server.airdrops",         label: "Airdrops on/off",                type: "bool" },
      ]},

      { title: "Anti-Cheat & Auth", icon: "🛡️", description: "EAC + Steam auth gates. Most installs leave these on.", fields: [
        { prop: "server.secure",           label: "Steam VAC secure",              type: "bool" },
        { prop: "server.eac",              label: "Easy Anti-Cheat",                type: "bool" },
        { prop: "server.queryport",        label: "Query port",                     type: "number", min: 1, max: 65535, hint: "Usually GAME_PORT + 1." },
      ]},
    ],
  },

  // DayZ — serverDZ.cfg uses Bohemia's own config syntax: one `key = value;`
  // per line, strings double-quoted, numbers bare, plus nested `class` blocks
  // (Missions) that we must never touch. Dedicated cfg_dayz parser/serializer
  // (parseCfgDayz / serializeCfgDayz below) — INI/properties parsers would
  // mangle the trailing semicolons and the class block. The entrypoint seeds
  // this file on FIRST boot only, so live edits stick across restarts.
  // On/off flags are 0/1 integers in DayZ (NOT true/false) — modelled as
  // enums so the serializer never writes a boolean word the engine rejects.
  "dayz": {
    file: "serverDZ.cfg",
    filePaths: [
      "serverDZ.cfg",
      "/game-saves/serverDZ.cfg",
    ],
    format: "cfg_dayz",
    groups: [
      { title: "Identity & Access", icon: "🏷️", description: "Server-browser identity and join gates. <strong>hostname</strong> is what players see; an empty <strong>password</strong> means an open server.", fields: [
        { prop: "hostname",       label: "Server name",     type: "string", hint: "Shown in the DayZ launcher / in-game browser. Seeded from SERVER_NAME env on first boot only — edit here afterwards." },
        { prop: "password",       label: "Join password",   type: "password", hint: "Empty = open server." },
        { prop: "passwordAdmin",  label: "Admin password",  type: "password", hint: "Required for in-game admin (#login) commands." },
        { prop: "maxPlayers",     label: "Max players",     type: "number", min: 1, max: 127, hint: "Vanilla cap is 60; hard engine ceiling 127." },
        { prop: "enableWhitelist", label: "Whitelist", type: "enum", options: [["0","Off"],["1","On (whitelist.txt)"]] },
        { prop: "disableBanlist",  label: "Disable ban list", type: "enum", options: [["0","No (bans enforced)"],["1","Yes"]] },
        { prop: "verifySignatures", label: "Verify mod signatures", type: "enum", options: [["0","Off (insecure)"],["1","v1 (legacy)"],["2","v2 (recommended)"]], hint: "2 = verify against keys/ — leave on unless debugging a mod." },
        { prop: "forceSameBuild",  label: "Force same client build", type: "enum", options: [["0","Off"],["1","On"]] },
      ]},

      { title: "Time & World", icon: "🌍", description: "In-game clock behaviour. Acceleration multiplies real time (12 = one full day/night in 2 real hours at default daylight split).", fields: [
        { prop: "serverTime",                  label: "Server start time", type: "string", hint: "\"SystemTime\" or a date like \"2015/4/8/17/23\" (Y/M/D/H/Min)." },
        { prop: "serverTimeAcceleration",      label: "Time acceleration", type: "number", min: 0, max: 64, step: 0.1, hint: "Multiplier for the in-game clock. 0 = frozen, 1 = realtime, 12 = default." },
        { prop: "serverNightTimeAcceleration", label: "Night acceleration (extra)", type: "number", min: 0.1, max: 64, step: 0.1, hint: "Multiplies on TOP of time acceleration at night (12 × 4 = 48× nights)." },
        { prop: "serverTimePersistent",        label: "Persistent time", type: "enum", options: [["0","Off (reset on restart)"],["1","On (clock survives restarts)"]] },
        { prop: "lightingConfig",              label: "Night lighting", type: "enum", options: [["0","Bright (vanilla)"],["1","Dark (hardcore)"]] },
      ]},

      { title: "Gameplay", icon: "🎮", description: "Player-facing rules: camera, HUD, voice, respawn.", fields: [
        { prop: "disable3rdPerson", label: "Disable 3rd-person camera", type: "enum", options: [["0","No (3PP allowed)"],["1","Yes (1PP only)"]] },
        { prop: "disableCrosshair", label: "Disable crosshair", type: "enum", options: [["0","No"],["1","Yes"]] },
        { prop: "disableVoN",       label: "Disable voice (VoN)", type: "enum", options: [["0","No (voice on)"],["1","Yes"]] },
        { prop: "vonCodecQuality",  label: "Voice codec quality", type: "number", min: 0, max: 30, hint: "0–30. Higher = clearer voice, more bandwidth." },
        { prop: "respawnTime",      label: "Respawn delay (s)", type: "number", min: 0, max: 3600 },
        { prop: "motdInterval",     label: "MOTD interval (s)", type: "number", min: 1, max: 3600, hint: "Rotation interval for motd[] lines (edit the motd[] array itself via the Files tab — array syntax isn't form-editable)." },
      ]},

      { title: "Queue & Network", icon: "🔌", description: "Login queue and connection quality gates. The Steam query port is panel-allocated — change ports in the Network ports pane.", fields: [
        { prop: "loginQueueConcurrentPlayers", label: "Login queue: concurrent logins", type: "number", min: 1, max: 64, hint: "How many players may spawn in simultaneously. 5 = vanilla." },
        { prop: "loginQueueMaxPlayers",        label: "Login queue: max size", type: "number", min: 1, max: 1000 },
        { prop: "maxPing",                     label: "Max ping (ms)", type: "number", min: 0, max: 1000, hint: "Players above this get kicked. 0 disables the check." },
        { prop: "guaranteedUpdates",           label: "Guaranteed updates", type: "enum", options: [["0","Off"],["1","On"]], hint: "Communication protocol for object updates — leave at 1." },
        { prop: "steamQueryPort",              label: "Steam query port", type: "number", min: 1, max: 65535, locked: true,
          hint: "🔒 Panel-owned: allocated with the game port. Change it in the Network ports pane." },
      ]},

      { title: "Persistence & Logs", icon: "💾", description: "Save integrity and the admin-log (.ADM) verbosity written under profiles/.", fields: [
        { prop: "instanceId",             label: "Instance ID", type: "number", min: 1, max: 9999, hint: "Selects the storage_N persistence folder in the mission. Change = fresh persistence." },
        { prop: "storageAutoFix",         label: "Storage auto-fix", type: "enum", options: [["0","Off"],["1","On (repair corrupt persistence)"]] },
        { prop: "timeStampFormat",        label: "Log timestamp format", type: "enum", options: ["Short","Full"] },
        { prop: "logAverageFps",          label: "Log average FPS (s interval)", type: "number", min: 0, max: 3600, hint: "0 disables. Writes to the RPT log." },
        { prop: "logMemory",              label: "Log memory usage (s interval)", type: "number", min: 0, max: 3600 },
        { prop: "logPlayers",             label: "Log player count (s interval)", type: "number", min: 0, max: 3600 },
        { prop: "logFile",                label: "Console log file", type: "string" },
        { prop: "adminLogPlayerHitsOnly", label: "Admin log: player hits only", type: "enum", options: [["0","All damage"],["1","PvP hits only"]] },
        { prop: "adminLogPlacement",      label: "Admin log: placements", type: "enum", options: [["0","Off"],["1","On (traps/tents)"]] },
        { prop: "adminLogBuildActions",   label: "Admin log: build actions", type: "enum", options: [["0","Off"],["1","On (build/dismantle)"]] },
        { prop: "adminLogPlayerList",     label: "Admin log: periodic player list", type: "enum", options: [["0","Off"],["1","On (every 5 min)"]] },
        { prop: "BattlEye",               label: "BattlEye anti-cheat", type: "enum", options: [["0","Off"],["1","On"]], hint: "Also gates RCON — the panel's console uses BattlEye RCON, so leave on." },
      ]},
    ],
  },

  // RuneScape: Dragonwilds — DedicatedServer.ini, one UE5 section
  // ([/Script/Dominion.DedicatedServerSettings]; "Dominion" is the internal
  // project name). ue_ini format so a brand-new key lands under the right
  // header. The entrypoint seeds this file from env on FIRST boot only —
  // after that the file is authoritative, so live edits here stick.
  "dragonwilds": {
    file: "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini",
    filePaths: [
      "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini",
      "/game-saves/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini",
    ],
    format: "ue_ini",
    groups: [
      { title: "Server Settings", icon: "🐉", description: "Jagex's dedicated-server settings. ⚠ <strong>OwnerId is REQUIRED</strong> — without it the server boots but <strong>refuses every login</strong>. Changes apply on the next restart.", fields: [
        { prop: "ServerName",       label: "Server name", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Shown in the in-game server list. Seeded from SERVER_NAME env on first boot only." },
        { prop: "DefaultWorldName", label: "Default world name", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Save-slot name for the world. Changing it starts a fresh world (the old save stays on disk)." },
        { prop: "OwnerId",          label: "Owner ID (REQUIRED)", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "⚠ REQUIRED — the Jagex account ID that owns this server. The server launches without it but refuses ALL logins until it's set. Find yours in the Dragonwilds client → server settings." },
        { prop: "AdminPassword",    label: "Admin password", type: "password", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "In-game admin console access." },
        { prop: "WorldPassword",    label: "World join password", type: "password", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Empty = open server." },
      ]},
    ],
  },

  // Factorio — config/server-settings.json under the saves volume. Plain
  // JSON via parseJSON/serializeJSON: unknown keys (including Factorio's
  // `_comment_*` documentation strings) survive untouched because the
  // serializer patches a structuredClone of the parsed root. Nested keys
  // use dotted paths (visibility.public). The entrypoint seeds the file
  // from server-settings.example.json on first boot only, so edits stick.
  "factorio": {
    file: "config/server-settings.json",
    filePaths: [
      "config/server-settings.json",
      "/game-saves/config/server-settings.json",
    ],
    format: "json",
    groups: [
      { title: "Identity & Visibility", icon: "🏷️", description: "Server-browser identity. <strong>Public</strong> listing requires a factorio.com username + token (next group) — without them the server still works for direct-IP and LAN joins.", fields: [
        { prop: "name",             label: "Server name",  type: "string" },
        { prop: "description",      label: "Description",  type: "textarea", rows: 2 },
        { prop: "tags",             label: "Tags",          type: "string", hint: "JSON array, e.g. [\"vanilla\", \"pve\"] — shown as filters in the public browser." },
        { prop: "max_players",      label: "Max players",  type: "number", min: 0, max: 65535, hint: "0 = unlimited." },
        { prop: "visibility.public", label: "List on public browser", type: "bool", hint: "Requires factorio.com credentials below; without them the log shows \"Missing token\" and the server stays direct-IP-only." },
        { prop: "visibility.lan",    label: "List on LAN",  type: "bool" },
        { prop: "game_password",     label: "Join password", type: "password", hint: "Empty = open server." },
        { prop: "require_user_verification", label: "Verify users against factorio.com", type: "bool", hint: "Blocks pirated/unverified clients. Leave on for public servers." },
      ]},

      { title: "factorio.com Account", icon: "🔑", description: "Only needed when <strong>List on public browser</strong> is on. Get the token from factorio.com → profile.", fields: [
        { prop: "username", label: "factorio.com username", type: "string" },
        { prop: "password", label: "factorio.com password", type: "password", hint: "Leave empty when using a token (preferred)." },
        { prop: "token",    label: "factorio.com token",    type: "password" },
      ]},

      { title: "Autosave & Pause", icon: "💾", description: "Server-side autosave cadence and auto-pause behaviour.", fields: [
        { prop: "autosave_interval",       label: "Autosave interval (min)", type: "number", min: 1, max: 1440 },
        { prop: "autosave_slots",          label: "Autosave slots",          type: "number", min: 1, max: 100, hint: "Rotated — oldest slot is overwritten." },
        { prop: "autosave_only_on_server", label: "Autosave only on server", type: "bool", hint: "Off makes every connected client autosave too — usually leave on." },
        { prop: "non_blocking_saving",     label: "Non-blocking saving",     type: "bool", hint: "Forks the process to save without freezing the game. Experimental but widely used on big bases." },
        { prop: "auto_pause",              label: "Pause when empty",        type: "bool" },
        { prop: "auto_pause_when_players_connect", label: "Pause while a player connects", type: "bool" },
        { prop: "only_admins_can_pause_the_game",  label: "Only admins can pause", type: "bool" },
      ]},

      { title: "Network & Limits", icon: "🔌", description: "Connection quality gates and command access.", fields: [
        { prop: "allow_commands", label: "Console commands", type: "enum", options: [["true","Everyone"],["admins-only","Admins only"],["false","Nobody"]], hint: "Lua commands disable achievements for the save — admins-only is the usual choice." },
        { prop: "afk_autokick_interval", label: "AFK autokick (min)", type: "number", min: 0, max: 1440, hint: "0 = never kick." },
        { prop: "ignore_player_limit_for_returning_players", label: "Returning players bypass player limit", type: "bool" },
        { prop: "max_upload_in_kilobytes_per_second", label: "Max upload (KB/s)", type: "number", min: 0, max: 1048576, hint: "Map upload rate to joining players. 0 = unlimited." },
        { prop: "max_upload_slots", label: "Max upload slots", type: "number", min: 0, max: 255, hint: "Concurrent map uploads. 0 = unlimited." },
        { prop: "minimum_latency_in_ticks", label: "Minimum latency (ticks)", type: "number", min: 0, max: 600, hint: "One tick = 16 ms. 0 = no artificial floor." },
        { prop: "max_heartbeats_per_second", label: "Max heartbeats/s", type: "number", min: 1, max: 240, hint: "Network tick rate ceiling. 60 = default." },
      ]},
    ],
  },

  // Minecraft Bedrock — itzg/minecraft-bedrock-server stamps every
  // env-managed property (server-name, gamemode, difficulty, max-players,
  // ports, …) into /data/server.properties on EVERY container start, so
  // those keys belong to the Launch settings card (ENV_CONFIG_SCHEMA) —
  // editing them here would silently revert on the next boot. This file
  // schema exposes only the properties the module's env block does NOT
  // manage. Same "properties" parser/serializer as minecraft-java.
  "minecraft-bedrock": {
    file: "server.properties",
    filePaths: ["server.properties", "/data/server.properties"],
    format: "properties",
    groups: [
      { title: "World & Players", icon: "🌍", description: "Properties NOT stamped from env on boot — durable file edits. Server name, gamemode, difficulty, max players, cheats, view/tick distance and ports live in <b>Launch settings</b> (env-managed; itzg rewrites them into this file on every start).", fields: [
        { prop: "level-seed",        label: "World seed", type: "string", hint: "Blank = random. Only used when the world is first generated." },
        { prop: "force-gamemode",    label: "Force gamemode on join", type: "bool" },
        { prop: "allow-list",        label: "Enable allow list (whitelist)", type: "bool", hint: "Players must be in allowlist.json (Files tab) to join." },
        { prop: "player-idle-timeout", label: "Idle kick (minutes)", type: "number", min: 0, max: 1440, hint: "0 = never kick idlers." },
      ]},
      { title: "Chat & Interaction", icon: "💬", fields: [
        { prop: "chat-restriction", label: "Chat restriction", type: "enum", options: [["None","None"],["Dropped","Chat messages dropped"],["Disabled","Chat UI disabled"]] },
        { prop: "disable-player-interaction", label: "Ignore player interaction", type: "bool" },
        { prop: "disable-custom-skins", label: "Disable custom skins", type: "bool" },
        { prop: "texturepack-required", label: "Require texture packs", type: "bool", hint: "Also settable via env — the module doesn't stamp it unless TEXTUREPACK_REQUIRED is changed in Launch settings." },
      ]},
      { title: "Performance & Anti-cheat", icon: "🛡️", description: "Movement authority + server-side performance knobs (Bedrock's server-authoritative anti-cheat family).", fields: [
        { prop: "server-authoritative-movement", label: "Movement authority", type: "enum", options: [["client-auth","Client authoritative"],["server-auth","Server simulates, accepts client (default)"],["server-auth-with-rewind","Server replays & corrects (strictest)"]] },
        { prop: "player-position-acceptance-threshold", label: "Position tolerance", type: "number", min: 0, step: 0.05 },
        { prop: "player-movement-action-direction-threshold", label: "Attack/look direction tolerance", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "server-authoritative-block-breaking", label: "Server-authoritative block breaking", type: "bool" },
        { prop: "max-threads",       label: "Max CPU threads", type: "number", min: 0, max: 64, hint: "0 = use as many as possible." },
        { prop: "compression-threshold", label: "Compression threshold (bytes)", type: "number", min: 0, max: 65535 },
        { prop: "compression-algorithm", label: "Compression algorithm", type: "enum", options: ["zlib","snappy"] },
        { prop: "client-side-chunk-generation-enabled", label: "Client-side chunk generation", type: "bool" },
        { prop: "content-log-file-enabled", label: "Log content errors to file", type: "bool" },
      ]},
    ],
  },

  // V Rising — game RULES live in Settings/ServerGameSettings.json on the
  // saves volume (the entrypoint seeds it from StreamingAssets on first
  // boot). HOST settings (name/password/slots/ports/rcon) are driven by
  // CLI flags the entrypoint assembles from env — CLI > JSON in V Rising's
  // precedence — so they live in the Launch settings card
  // (ENV_CONFIG_SCHEMA["v-rising"]), NOT here. Field list follows
  // Stunlock's dedicated-server instructions + AMP's v-risingconfig.json;
  // dotted props ride the same parseJSON/serializeJSON as factorio.
  //
  // ⚠ A non-empty GAME_SETTINGS_PRESET launch flag silently overrides this
  // entire file (Stunlock gotcha) — the group description warns about it.
  "v-rising": {
    file: "Settings/ServerGameSettings.json",
    filePaths: [
      "Settings/ServerGameSettings.json",
      "/game-saves/Settings/ServerGameSettings.json",
    ],
    format: "json",
    groups: [
      { title: "Game Mode", icon: "⚔️", description: "Core rules. ⚠ These only apply while <b>Game settings preset</b> in Launch settings is EMPTY — a preset overrides this whole file silently.", fields: [
        { prop: "GameModeType",       label: "Game mode", type: "enum", options: ["PvE","PvP"] },
        { prop: "GameDifficulty",     label: "Difficulty", type: "string", hint: "Relaxed / Normal / Brutal (older builds used 0/1/2 — the serializer keeps whatever type the file already has)." },
        { prop: "ClanSize",           label: "Clan size", type: "number", min: 1, max: 10 },
        { prop: "PlayerDamageMode",   label: "Player damage", type: "enum", options: ["Always","TimeRestricted"] },
        { prop: "PvPProtectionMode",  label: "New-player PvP protection", type: "enum", options: [["Disabled","Disabled"],["VeryShort","Very short (15 min)"],["Short","Short (30 min)"],["Medium","Medium (1 h)"],["Long","Long (2 h)"]] },
        { prop: "DeathContainerPermission", label: "Who can loot your death bag", type: "enum", options: ["Anyone","ClanMembers","OnlySelf"] },
        { prop: "CanLootEnemyContainers", label: "Loot enemy containers", type: "bool" },
        { prop: "BloodBoundEquipment", label: "Blood-bound equipment (keep gear on death)", type: "bool" },
        { prop: "TeleportBoundItems", label: "Items block waypoint teleport", type: "bool" },
        { prop: "AllowGlobalChat",    label: "Global chat", type: "bool" },
        { prop: "AllWaypointsUnlocked", label: "All waypoints unlocked", type: "bool" },
        { prop: "RelicSpawnType",     label: "Soul shard spawns", type: "enum", options: ["Unique","Plentiful"] },
        { prop: "InactivityKillEnabled", label: "Kill inactive players", type: "bool" },
      ]},
      { title: "Castles & Sieges", icon: "🏰", fields: [
        { prop: "CastleDamageMode",   label: "Castle damage", type: "enum", options: ["Always","Never","TimeRestricted"] },
        { prop: "CastleHeartDamageMode", label: "Castle heart damage", type: "enum", options: [["CanBeDestroyedOnlyWhenDecaying","Only when decaying"],["CanBeDestroyedByPlayers","Destroyable by players"],["CanBeSeizedOrDestroyedByPlayers","Seizable or destroyable"]] },
        { prop: "SiegeWeaponHealth",  label: "Siege golem health", type: "enum", options: ["VeryLow","Low","Normal","High","VeryHigh","MegaHigh","UltraHigh","CrazyHigh","Max"] },
        { prop: "CastleRelocationEnabled", label: "Castle relocation", type: "bool" },
        { prop: "FreeCastleClaim",    label: "Free castle claim", type: "bool" },
        { prop: "FreeCastleDestroy",  label: "Free castle destroy", type: "bool" },
        { prop: "CastleDecayRateModifier", label: "Castle decay rate", type: "number", min: 0, step: 0.1 },
        { prop: "CastleBloodEssenceDrainModifier", label: "Heart essence drain", type: "number", min: 0, step: 0.1 },
        { prop: "CastleStatModifiers_Global.CastleLimit", label: "Castles per player/clan", type: "number", min: 1, max: 10 },
        { prop: "CastleStatModifiers_Global.CastleHeartLimitType", label: "Castle limit counted per", type: "enum", options: [["User","Player"],["Clan","Clan"]] },
      ]},
      { title: "Rates & Yields", icon: "📈", description: "1 = vanilla for every modifier.", fields: [
        { prop: "InventoryStacksModifier", label: "Stack size multiplier", type: "number", min: 0, step: 0.5 },
        { prop: "DropTableModifier_General", label: "Loot drop multiplier", type: "number", min: 0, step: 0.5 },
        { prop: "MaterialYieldModifier_Global", label: "Material yield", type: "number", min: 0, step: 0.5 },
        { prop: "BloodEssenceYieldModifier", label: "Blood essence yield", type: "number", min: 0, step: 0.5 },
        { prop: "CraftRateModifier",  label: "Craft speed", type: "number", min: 0, step: 0.5 },
        { prop: "RefinementRateModifier", label: "Refinement speed", type: "number", min: 0, step: 0.5 },
        { prop: "ResearchCostModifier", label: "Research cost", type: "number", min: 0, step: 0.5 },
        { prop: "BuildCostModifier",  label: "Build cost", type: "number", min: 0, step: 0.5 },
        { prop: "RecipeCostModifier", label: "Recipe cost", type: "number", min: 0, step: 0.5 },
        { prop: "RepairCostModifier", label: "Repair cost", type: "number", min: 0, step: 0.5 },
        { prop: "ServantConvertRateModifier", label: "Servant conversion speed", type: "number", min: 0, step: 0.5 },
        { prop: "BloodDrainModifier", label: "Blood drain rate", type: "number", min: 0, step: 0.5 },
        { prop: "DurabilityDrainModifier", label: "Durability drain", type: "number", min: 0, step: 0.5 },
      ]},
      { title: "World Hazards & Death", icon: "☀️", fields: [
        { prop: "SunDamageModifier",  label: "Sun damage", type: "number", min: 0, step: 0.5 },
        { prop: "GarlicAreaStrengthModifier", label: "Garlic area strength", type: "number", min: 0, step: 0.5 },
        { prop: "HolyAreaStrengthModifier", label: "Holy area strength", type: "number", min: 0, step: 0.5 },
        { prop: "SilverStrengthModifier", label: "Silver strength", type: "number", min: 0, step: 0.5 },
        { prop: "Death_DurabilityFactorLoss", label: "Durability loss on death", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "PvPVampireRespawnModifier", label: "PvP respawn time", type: "number", min: 0, step: 0.5 },
      ]},
      { title: "Time & Blood Moon", icon: "🌙", fields: [
        { prop: "GameTimeModifiers.DayDurationInSeconds", label: "Day length (s)", type: "number", min: 60, max: 86400 },
        { prop: "GameTimeModifiers.DayStartHour", label: "Daylight starts (hour)", type: "number", min: 0, max: 23 },
        { prop: "GameTimeModifiers.DayEndHour",   label: "Daylight ends (hour)", type: "number", min: 0, max: 23 },
        { prop: "GameTimeModifiers.BloodMoonFrequency_Min", label: "Blood moon every (min days)", type: "number", min: 1, max: 30 },
        { prop: "GameTimeModifiers.BloodMoonFrequency_Max", label: "Blood moon every (max days)", type: "number", min: 1, max: 30 },
        { prop: "GameTimeModifiers.BloodMoonBuff", label: "Blood moon buff strength", type: "number", min: 0, max: 1, step: 0.05 },
      ]},
      { title: "Player & Unit Stats", icon: "🧛", fields: [
        { prop: "VampireStatModifiers.MaxHealthModifier", label: "Vampire max health", type: "number", min: 0, step: 0.1 },
        { prop: "VampireStatModifiers.PhysicalPowerModifier", label: "Vampire physical power", type: "number", min: 0, step: 0.1 },
        { prop: "VampireStatModifiers.SpellPowerModifier", label: "Vampire spell power", type: "number", min: 0, step: 0.1 },
        { prop: "VampireStatModifiers.DamageReceivedModifier", label: "Vampire damage taken", type: "number", min: 0, step: 0.1 },
        { prop: "UnitStatModifiers_Global.MaxHealthModifier", label: "Enemy max health", type: "number", min: 0, step: 0.1 },
        { prop: "UnitStatModifiers_Global.PowerModifier", label: "Enemy power", type: "number", min: 0, step: 0.1 },
        { prop: "UnitStatModifiers_VBlood.MaxHealthModifier", label: "V Blood boss health", type: "number", min: 0, step: 0.1 },
        { prop: "UnitStatModifiers_VBlood.PowerModifier", label: "V Blood boss power", type: "number", min: 0, step: 0.1 },
      ]},
    ],
  },

  // Satisfactory — the file-config surface is genuinely tiny: nearly all
  // server settings live in the HTTPS admin API's binary
  // ServerSettings.<port>.sav (not text-editable), and AMP passes its
  // handful of knobs as -ini: command-line overrides rather than file
  // writes. The one setting operators reliably file-edit is MaxPlayers in
  // Game.ini ([/Script/Engine.GameSession] — AMP's ini:Game target).
  // HOME=/game-saves, so UE's config dir is on the saves volume.
  "satisfactory": {
    file: ".config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini",
    filePaths: [
      ".config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini",
      "/game-saves/.config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini",
    ],
    format: "ue_ini",
    groups: [
      { title: "Server", icon: "🏭", description: "Satisfactory keeps almost all settings in the in-game Server Manager (stored in the binary <code>ServerSettings.&lt;port&gt;.sav</code>) — this file only carries the player cap. Ports are panel-allocated (Network ports pane).", fields: [
        { prop: "MaxPlayers", label: "Max players", type: "number", min: 1, max: 64, section: "[/Script/Engine.GameSession]", hint: "Default 4. Applied on next restart." },
      ]},
    ],
  },

  // Barotrauma — serversettings.xml keeps EVERYTHING as attributes on the
  // root <serversettings> element (format "xml_attrs" — parse/serialize
  // below). The entrypoint seeds the file on first boot and sed-patches
  // port=/queryport= on EVERY boot (panel port allocator owns them →
  // locked). The server rewrites the whole file on exit, so edits should
  // be made while the server is STOPPED. No RCON exists for this game.
  "barotrauma": {
    file: "serversettings.xml",
    filePaths: ["serversettings.xml", "/game-saves/serversettings.xml"],
    format: "xml_attrs",
    groups: [
      { title: "Identity & Access", icon: "🏷️", description: "⚠ Barotrauma rewrites this file when the server shuts down — apply edits while the server is <b>stopped</b>, or they may be overwritten.", fields: [
        { prop: "name",        label: "Server name", type: "string" },
        { prop: "ServerMessage", label: "Server message (MOTD)", type: "textarea", rows: 2 },
        { prop: "password",    label: "Join password", type: "password", hint: "Empty = open server." },
        { prop: "MaxPlayers",  label: "Max players", type: "number", min: 1, max: 32 },
        { prop: "IsPublic",    label: "List publicly (Steam)", type: "enum", options: [["True","Public"],["False","Private"]], hint: "Also gates the A2S query port — with False the query port never binds." },
        { prop: "PlayStyle",   label: "Play style tag", type: "enum", options: ["Serious","Casual","Roleplay","Rampage","SomethingDifferent"] },
        { prop: "Language",    label: "Server language", type: "string", hint: "e.g. English, German, French — must match a language Barotrauma ships." },
        { prop: "port",        label: "Game port", type: "number", locked: true, hint: "🔒 Panel-owned — the entrypoint re-stamps it from the port allocator on every boot. Change it in the Network ports pane." },
        { prop: "queryport",   label: "Query port", type: "number", locked: true, hint: "🔒 Panel-owned — see Network ports pane." },
      ]},
      { title: "Rounds & Game Mode", icon: "🚢", fields: [
        { prop: "GameModeIdentifier", label: "Default game mode", type: "enum", options: [["sandbox","Sandbox"],["mission","Mission"],["pvp","PvP"],["multiplayercampaign","Campaign"]] },
        { prop: "ModeSelectionMode",  label: "Mode selection", type: "enum", options: ["Manual","Random","Vote"] },
        { prop: "SubSelectionMode",   label: "Submarine selection", type: "enum", options: ["Manual","Random","Vote"] },
        { prop: "LevelDifficulty",    label: "Level difficulty (%)", type: "number", min: 0, max: 100 },
        { prop: "StartWhenClientsReady", label: "Start when players ready", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "StartWhenClientsReadyRatio", label: "Ready-ratio to start", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "AllowSpectating",    label: "Allow spectating", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "AllowEndVoting",     label: "Allow round-end vote", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "RandomizeSeed",      label: "Randomize seed between rounds", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "TickRate",           label: "Server tick rate", type: "number", min: 10, max: 60 },
      ]},
      { title: "Respawning & Death", icon: "💀", fields: [
        { prop: "AllowRespawn",       label: "Allow respawning", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "RespawnInterval",    label: "Respawn interval (s)", type: "number", min: 0, max: 3600 },
        { prop: "MinRespawnRatio",    label: "Min dead ratio to respawn", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "UseRespawnShuttle",  label: "Use respawn shuttle", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "MaxTransportTime",   label: "Max shuttle transport time (s)", type: "number", min: 0, max: 3600 },
        { prop: "SkillLossPercentageOnDeath", label: "Skill loss on death (%)", type: "number", min: 0, max: 100 },
        { prop: "KillDisconnectedTime", label: "Kill disconnected after (s)", type: "number", min: 0, max: 3600 },
        { prop: "KickAFKTime",        label: "AFK kick after (s)", type: "number", min: 0, max: 36000 },
      ]},
      { title: "Traitors & PvP", icon: "🗡️", fields: [
        { prop: "TraitorProbability", label: "Traitor probability", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "TraitorsMinPlayerCount", label: "Min players for traitors", type: "number", min: 1, max: 16 },
        { prop: "AllowFriendlyFire",  label: "Friendly fire", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "KillableNPCs",       label: "Killable outpost NPCs", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "DestructibleOutposts", label: "Destructible outposts", type: "enum", options: [["True","On"],["False","Off"]] },
      ]},
      { title: "Voice, Votes & Moderation", icon: "🗳️", fields: [
        { prop: "VoiceChatEnabled",   label: "Voice chat", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "AllowVoteKick",      label: "Allow vote-kick", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "KickVoteRequiredRatio", label: "Vote-kick ratio", type: "number", min: 0, max: 1, step: 0.05 },
        { prop: "AutoBanTime",        label: "Vote-kick ban duration (s)", type: "number", min: 0, max: 604800 },
        { prop: "BanAfterWrongPassword", label: "Ban after wrong password", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "MaxPasswordRetriesBeforeBan", label: "Password retries before ban", type: "number", min: 1, max: 10 },
        { prop: "KarmaEnabled",       label: "Karma system", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "AllowFileTransfers", label: "Share files with players (mods)", type: "enum", options: [["True","On"],["False","Off"]] },
        { prop: "AllowModDownloads",  label: "Allow mod downloads", type: "enum", options: [["True","On"],["False","Off"]] },
      ]},
    ],
  },

  // Enshrouded — enshrouded_server.json on the saves volume. The
  // entrypoint sed-stamps name/password/slotCount/queryPort from env on
  // EVERY boot (those live in Launch settings / Network ports), so this
  // schema is the gameplay half: gameSettingsPreset + the gameSettings
  // object (shape per the server's own generated file / AMP's
  // enshroudedserver.json). Individual gameSettings only take effect when
  // gameSettingsPreset is "Custom".
  "enshrouded": {
    file: "enshrouded_server.json",
    filePaths: ["enshrouded_server.json", "/game-saves/enshrouded_server.json"],
    format: "json",
    groups: [
      { title: "Preset & Chat", icon: "🌫️", description: "Server name, join password, slots and the port are env-managed (Launch settings / Network ports) — the entrypoint re-stamps them on every boot. Set <b>preset</b> to <code>Custom</code> for the individual settings below to apply.", fields: [
        { prop: "gameSettingsPreset", label: "Game settings preset", type: "enum", options: [["Default","Default"],["Relaxed","Relaxed"],["Hard","Hard"],["Survival","Survival"],["Custom","Custom (use fields below)"]] },
        { prop: "enableVoiceChat",    label: "Voice chat", type: "bool" },
        { prop: "voiceChatMode",      label: "Voice chat mode", type: "enum", options: ["Proximity","Global"] },
        { prop: "enableTextChat",     label: "Text chat", type: "bool" },
      ]},
      { title: "Player", icon: "🧑", description: "All factors: 1 = vanilla. Require preset = Custom.", fields: [
        { prop: "gameSettings.playerHealthFactor",  label: "Player health", type: "number", min: 0.25, max: 4, step: 0.25 },
        { prop: "gameSettings.playerManaFactor",    label: "Player mana", type: "number", min: 0.25, max: 4, step: 0.25 },
        { prop: "gameSettings.playerStaminaFactor", label: "Player stamina", type: "number", min: 0.25, max: 4, step: 0.25 },
        { prop: "gameSettings.enableDurability",    label: "Weapon durability", type: "bool" },
        { prop: "gameSettings.enableStarvingDebuff", label: "Hunger & starvation", type: "bool" },
        { prop: "gameSettings.foodBuffDurationFactor", label: "Food buff duration", type: "number", min: 0.5, max: 2, step: 0.25 },
        { prop: "gameSettings.shroudTimeFactor",    label: "Shroud time", type: "number", min: 0.5, max: 2, step: 0.25 },
        { prop: "gameSettings.tombstoneMode",       label: "Death penalty", type: "enum", options: [["AddBackpackMaterials","Lose backpack materials"],["Everything","Lose everything"],["NoTombstone","Keep everything"]] },
        { prop: "gameSettings.enableGliderTurbulences", label: "Glider turbulence", type: "bool" },
      ]},
      { title: "World & Economy", icon: "🌍", fields: [
        { prop: "gameSettings.weatherFrequency",    label: "Weather frequency", type: "enum", options: ["Disabled","Rare","Normal","Often"] },
        { prop: "gameSettings.miningDamageFactor",  label: "Mining effectiveness", type: "number", min: 0.5, max: 5, step: 0.5 },
        { prop: "gameSettings.plantGrowthSpeedFactor", label: "Plant growth speed", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.resourceDropStackAmountFactor", label: "Resource gain", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.factoryProductionSpeedFactor", label: "Workstation speed", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.fishingDifficulty",   label: "Fishing difficulty", type: "enum", options: ["VeryEasy","Easy","Normal","Hard","VeryHard"] },
        { prop: "gameSettings.experienceCombatFactor", label: "Combat XP", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.experienceMiningFactor", label: "Mining XP", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.experienceExplorationQuestsFactor", label: "Exploration/quest XP", type: "number", min: 0.25, max: 5, step: 0.25 },
      ]},
      { title: "Enemies", icon: "👹", fields: [
        { prop: "gameSettings.randomSpawnerAmount", label: "Enemy amount", type: "enum", options: ["Few","Normal","Many","Extreme"] },
        { prop: "gameSettings.aggroPoolAmount",     label: "Simultaneous attackers", type: "enum", options: ["Few","Normal","Many","Extreme"] },
        { prop: "gameSettings.enemyDamageFactor",   label: "Enemy damage", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.enemyHealthFactor",   label: "Enemy health", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.enemyPerceptionRangeFactor", label: "Enemy perception", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.bossDamageFactor",    label: "Boss damage", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.bossHealthFactor",    label: "Boss health", type: "number", min: 0.25, max: 5, step: 0.25 },
        { prop: "gameSettings.pacifyAllEnemies",    label: "Pacify all enemies", type: "bool" },
        { prop: "gameSettings.tamingStartleRepercussion", label: "Taming failure penalty", type: "enum", options: [["KeepProgress","Keep all progress"],["LoseSomeProgress","Lose some progress"],["LoseAllProgress","Lose all progress"]] },
      ]},
    ],
  },

  // Terraria — serverconfig.txt (flat key=value → "properties" format).
  // The entrypoint SEEDS this file on first boot from env and never
  // rewrites it after, so file edits are durable — EXCEPT port, which the
  // entrypoint passes as -port on the CLI every boot (locked). World
  // identity keys only matter at world-creation time; the `world=` path
  // is what actually selects the loaded world.
  "terraria": {
    file: "serverconfig.txt",
    filePaths: ["serverconfig.txt", "/game-saves/serverconfig.txt"],
    format: "properties",
    groups: [
      { title: "Identity & Access", icon: "🏷️", fields: [
        { prop: "motd",       label: "MOTD", type: "string", hint: "Shown to players on join." },
        { prop: "password",   label: "Join password", type: "password", hint: "Empty = open server." },
        { prop: "maxplayers", label: "Max players", type: "number", min: 1, max: 255 },
        { prop: "secure",     label: "Cheat protection", type: "enum", options: [["1","On"],["0","Off"]] },
        { prop: "language",   label: "Language", type: "enum", options: [["en-US","English"],["de-DE","German"],["it-IT","Italian"],["fr-FR","French"],["es-ES","Spanish"],["ru-RU","Russian"],["zh-Hans","Chinese"],["pt-BR","Portuguese"],["pl-PL","Polish"]] },
        { prop: "port",       label: "Port", type: "number", locked: true, hint: "🔒 Panel-owned — the entrypoint passes -port from the allocator on every boot, overriding this line. Change it in the Network ports pane." },
      ]},
      { title: "World", icon: "🌳", description: "⚠ <code>world</code> is the actual save file the server loads — changing it points the server at a different (or brand-new) world. The creation-time keys (name/size/seed/difficulty) only apply when the file at <code>world</code> doesn't exist yet.", fields: [
        { prop: "world",      label: "World file path", type: "string", hint: "e.g. /game-saves/Worlds/MyWorld.wld" },
        { prop: "worldname",  label: "New-world name", type: "string" },
        { prop: "autocreate", label: "New-world size", type: "enum", options: [["1","Small"],["2","Medium"],["3","Large"]] },
        { prop: "seed",       label: "New-world seed", type: "string", hint: "Blank = random. Only used at world creation." },
        { prop: "difficulty", label: "New-world difficulty", type: "enum", options: [["0","Classic"],["1","Expert"],["2","Master"],["3","Journey"]] },
      ]},
      { title: "Performance", icon: "⚡", fields: [
        { prop: "npcstream",  label: "NPC stream rate", type: "number", min: 0, max: 240, hint: "Lower = less enemy skipping, more bandwidth. 0 = off." },
        { prop: "priority",   label: "Process priority", type: "enum", options: [["0","Realtime"],["1","High"],["2","Above normal"],["3","Normal"],["4","Below normal"],["5","Idle"]] },
        { prop: "slowliquids", label: "Slower liquids (lag reduction)", type: "enum", options: [["1","On"],["0","Off"]] },
      ]},
    ],
  },

  // Project Zomboid — ~/Zomboid/Server/<SERVER_NAME>.ini on the saves
  // volume. The filename tracks the SERVER_NAME env (default "panel");
  // loadInstanceConfig rewrites file/filePaths per-instance the same way
  // conan-exiles does for EDITION. The entrypoint enforces
  // DefaultPort/UDPPort/RCONPort/RCONPassword/UPnP on EVERY boot
  // (replace-or-append), so those render locked. Everything else here is
  // a durable file edit, applied on next restart.
  "project-zomboid": {
    file: "Zomboid/Server/panel.ini",
    filePaths: ["Zomboid/Server/panel.ini", "/game-saves/Zomboid/Server/panel.ini"],
    format: "properties",
    groups: [
      { title: "Identity & Access", icon: "🏷️", fields: [
        { prop: "PublicName",        label: "Public server name", type: "string" },
        { prop: "PublicDescription", label: "Public description", type: "string" },
        { prop: "Public",            label: "List on public browser", type: "enum", options: [["true","Listed"],["false","Unlisted"]] },
        { prop: "Password",          label: "Join password", type: "password", hint: "Empty = open server." },
        { prop: "MaxPlayers",        label: "Max players", type: "number", min: 1, max: 100 },
        { prop: "Open",              label: "Open server (no whitelist account needed)", type: "enum", options: [["true","Open"],["false","Whitelist only"]] },
        { prop: "AutoCreateUserInWhiteList", label: "Auto-whitelist joining players", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "DropOffWhiteListAfterDeath", label: "Remove from whitelist on death", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "MaxAccountsPerUser", label: "Max accounts per user", type: "number", min: 0, max: 100, hint: "0 = unlimited." },
        { prop: "ServerWelcomeMessage", label: "Welcome message", type: "textarea", rows: 3, hint: "Use <LINE> for line breaks." },
      ]},
      { title: "PvP & Safety", icon: "⚔️", fields: [
        { prop: "PVP",               label: "PvP enabled", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "SafetySystem",      label: "PvP toggle (safety system)", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "ShowSafety",        label: "Show players' PvP status", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "SafetyToggleTimer", label: "Safety toggle timer (s)", type: "number", min: 0, max: 1000 },
        { prop: "SafetyCooldownTimer", label: "Safety cooldown (s)", type: "number", min: 0, max: 1000 },
        { prop: "PVPMeleeDamageModifier", label: "PvP melee damage (%)", type: "number", min: 0, max: 500 },
        { prop: "PVPFirearmDamageModifier", label: "PvP firearm damage (%)", type: "number", min: 0, max: 500 },
        { prop: "Faction",           label: "Factions", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "AnnounceDeath",     label: "Announce player deaths", type: "enum", options: [["true","On"],["false","Off"]] },
      ]},
      { title: "World & Gameplay", icon: "🌍", fields: [
        { prop: "Map",               label: "Map(s)", type: "string", hint: "Semicolon-separated mod map names; vanilla = Muldraugh, KY." },
        { prop: "Mods",              label: "Mods (load order)", type: "textarea", rows: 2, hint: "Semicolon-separated mod IDs." },
        { prop: "WorkshopItems",     label: "Workshop items", type: "textarea", rows: 2, hint: "Semicolon-separated Steam Workshop IDs to download." },
        { prop: "SpawnPoint",        label: "Spawn point", type: "string", hint: "x,y,z — 0,0,0 = use spawn regions." },
        { prop: "SpawnItems",        label: "Starter items", type: "string", hint: "Comma-separated item IDs given to new characters." },
        { prop: "nightlengthmodifier", label: "Night length modifier", type: "number", min: 0, max: 10, step: 0.1 },
        { prop: "NoFire",            label: "Disable fire spread", type: "enum", options: [["true","Fire disabled"],["false","Fire enabled"]] },
        { prop: "HoursForLootRespawn", label: "Loot respawn (in-game hours, 0=off)", type: "number", min: 0, max: 8760 },
        { prop: "MaxItemsForLootRespawn", label: "Max items for loot respawn", type: "number", min: 0, max: 1000 },
        { prop: "PlayerRespawnWithSelf", label: "Respawn at death location", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "PauseEmpty",        label: "Pause when empty", type: "enum", options: [["true","On"],["false","Off"]] },
      ]},
      { title: "Saves & Network", icon: "💾", fields: [
        { prop: "SaveWorldEveryMinutes", label: "Autosave interval (min, 0=default)", type: "number", min: 0, max: 1440 },
        { prop: "BackupsCount",      label: "Backups to keep", type: "number", min: 0, max: 100 },
        { prop: "BackupsOnStart",    label: "Backup on start", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "PingLimit",         label: "Ping limit (ms, 100=default)", type: "number", min: 0, max: 2000 },
        { prop: "SteamVAC",          label: "Valve Anti-Cheat", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "DoLuaChecksum",     label: "Kick on Lua checksum mismatch", type: "enum", options: [["true","On"],["false","Off"]] },
        { prop: "DefaultPort",       label: "Game port", type: "number", locked: true, hint: "🔒 Panel-owned — enforced from the port allocator on every boot. Change it in the Network ports pane." },
        { prop: "UDPPort",           label: "Direct UDP port", type: "number", locked: true, hint: "🔒 Panel-owned — see Network ports pane." },
        { prop: "RCONPort",          label: "RCON port", type: "number", locked: true, hint: "🔒 Panel-owned." },
        { prop: "UPnP",              label: "UPnP", type: "string", locked: true, hint: "🔒 Forced false by the entrypoint (default true can hang router detection)." },
      ]},
    ],
  },
};

function renderInstanceSettings() {
  const id = instanceModalState.id; const wrap = $("#im-set-wrap"); if (!id) return;
  const i = allInstances.find(x => x.instance_id === id); if (!i) return;
  const mod = (allModules.find(m => m.id === i.module_id) || {});

  wrap.innerHTML = `
    <div class="im-set-grid">
      <section class="im-set-card">
        <header>Overview</header>
        <div class="body">
          <div class="kv"><label>Server ID</label><div class="mono">${esc(i.instance_id)}</div></div>
          <div class="kv"><label>Game</label><div>${esc(mod.name || i.module_id)}<span class="dim" style="margin-left:8px">(${esc(i.module_id)})</span></div></div>
          <div class="kv"><label>Agent</label><div class="mono">${esc(i.agent_id || "—")}</div></div>
          <div class="kv"><label>Status</label><div><span class="status-pill ${esc(i.status)}">${esc(i.status)}</span></div></div>
          <div class="kv"><label>Created</label><div class="dim">${esc(fmtTime(i.created_at))}</div></div>
        </div>
      </section>

      <section class="im-set-card">
        <header>Maintenance</header>
        <div class="body">
          <div class="dim" style="font-size:12px;margin-bottom:10px">Rebuild the container using the latest module image — useful after a module update, a Dockerfile change, or to pick up a new entrypoint. Volumes (world data, configs, saves) are preserved; only the container itself is replaced.</div>
          <button id="im-set-rebuild">🔄 Rebuild container (keep data)</button>
        </div>
      </section>

      <!-- Bundled mods. Loaded async via /api/mods/installable filtered
           to this instance's module. Hidden when no mods are available
           for the current game (= section just doesn't render). -->
      <section class="im-set-card" id="im-set-mods-card" style="display:none">
        <header>Bundled mods</header>
        <div class="body">
          <div class="dim" style="font-size:12px;margin-bottom:10px">Mods the panel ships embedded. One-click install drops the files into the right folder inside the container; restart the server to load.</div>
          <div id="im-set-mods-list" style="display:flex;flex-direction:column;gap:10px"></div>
        </div>
      </section>

      <section class="im-set-card danger">
        <header>⚠ Danger zone</header>
        <div class="body">
          <div class="dim" style="font-size:12px;margin-bottom:10px">Deleting removes the container AND wipes Docker volumes for this server. World data, saves, and configs will be gone. Backups are NOT automatically kept — make one first.</div>
          <button class="danger" id="im-set-del">🗑 Delete server &amp; purge data</button>
        </div>
      </section>
    </div>`;

  // Load bundled-mods catalog filtered to this instance's module.
  // Card stays hidden when there's nothing applicable.
  (async () => {
    try {
      const r = await fetchJSON(`/api/mods/installable?module=${encodeURIComponent(i.module_id)}`);
      const mods = r.mods || [];
      if (!mods.length) return;
      const card = $("#im-set-mods-card");
      const list = $("#im-set-mods-list");
      if (!card || !list) return;
      card.style.display = "";
      list.innerHTML = mods.map(m => `
        <div class="im-set-mod-row" style="display:flex;align-items:center;gap:12px;padding:10px;border:1px solid var(--border-soft);border-radius:var(--radius)">
          <div style="font-size:24px">🧩</div>
          <div style="flex:1;min-width:0">
            <div style="font-weight:600;font-size:13.5px">${esc(m.display_name)}</div>
            <div class="dim" style="font-size:11.5px;margin-top:2px">${esc(m.description || "")}</div>
            <div class="dim mono" style="font-size:10.5px;margin-top:3px">→ ${esc(m.target_path)}</div>
          </div>
          <button class="primary" data-install-mod="${esc(m.id)}">Install</button>
        </div>`).join("");
      list.addEventListener("click", async e => {
        const modID = e.target.dataset.installMod;
        if (!modID) return;
        if (!confirm(`Install ${modID}? Files will be written into the container; restart the server to load.`)) return;
        e.target.disabled = true;
        e.target.textContent = "Installing…";
        try {
          const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/install-mod`, {
            method: "POST", headers: {"Content-Type":"application/json"},
            body: JSON.stringify({ mod_id: modID }),
          });
          toast(`${modID} installed: ${r.files} files (${fmtBytes(r.bytes)}). ${r.restart_after ? "Restart to load." : ""}`, "ok", 9000);
          e.target.textContent = "Reinstall";
          e.target.disabled = false;
        } catch (err) {
          toast(`Install failed: ${err.message}`, "err", 9000);
          e.target.disabled = false;
          e.target.textContent = "Install";
        }
      });
    } catch (err) {
      // Silent — operator just doesn't see the card.
    }
  })();

  $("#im-set-del").addEventListener("click", async () => {
    if (!confirm(`Delete "${id}" and purge all its data?\n\nThis is irreversible. Volumes will be removed.\n\nAre you absolutely sure?`)) return;
    try { await fetchJSON(`/api/instances/${encodeURIComponent(id)}?purge=true`, { method: "DELETE" }); toast(`Deleting ${id}…`, "ok"); hideModal("#instance-modal"); }
    catch (err) { toast(`Delete failed: ${err.message}`, "err"); }
  });

  $("#im-set-rebuild").addEventListener("click", async e => {
    if (!confirm(`Rebuild "${id}"?\n\nThe container will be stopped, removed, and re-created from the latest module image. Docker volumes (your world, saves, configs) are preserved.\n\nAny currently-connected players will be disconnected briefly.`)) return;
    const btn = e.target;
    btn.disabled = true; btn.textContent = "Rebuilding…";
    try {
      // Server-side recreate via /rebuild — same path as env-config recreate
      // with an empty updates set. This is the ONLY recreate path that
      // preserves config_values + assigned_ports + mount_overrides (the §6
      // recreate-dance invariants). The old client-side stop/delete/POST-
      // /api/instances flow dropped all three, silently reverting map +
      // cluster + mods to module defaults — root caused 2026-05-20 when a
      // Rebuild on a Ragnarok ARK instance booted as The Island.
      const resp = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/rebuild`, {
        method: "POST",
        headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ updates: {} }),
      });
      toast(`Rebuilt ${id}${resp && resp.restarted ? " — restarted" : ""}`, "ok");
      pollInstances();
    } catch (err) {
      toast("Rebuild failed: " + err.message, "err");
    } finally {
      btn.disabled = false; btn.textContent = "🔄 Rebuild container (keep data)";
    }
  });
}

// ENV_CONFIG_SCHEMA: per-module env-var fields that show up in the
// "Game launch settings" card on the Config tab. Each field is the
// name of a config_values key (= the container env var) plus rendering
// hints. Changing any of these triggers a recreate-with-env-config —
// world data is preserved (volumes not purged), the container is
// reborn with new env, and starts again if it was running.
//
// Same shape as MODULE_CREATE_OPTIONS (the create-form extras), so
// extending one usually means duplicating into the other.
const ENV_CONFIG_SCHEMA = {
  // 7 Days to Die deliberately has NO entry here anymore. Its entire
  // serverconfig is edited through the DURABLE serverconfig.xml editor
  // (configSchemas["7dtd"], each field carrying a tmplKey): Apply posts to
  // /config-render, which merges into config_values + re-renders the boot
  // template on the agent with NO recreate — changes land on the next
  // restart. The old 10-field env card here forced a full container
  // recreate for the exact same config_values keys. Container-level 7dtd
  // settings that genuinely need a recreate (CLUSTER_ID,
  // CLUSTER_PLAYER_SAVE, ports) are owned by the Cluster tab and the
  // Network ports pane.

  // ARK SA env-driven settings. The acekorneya image reads these env
  // vars at boot and assembles the ARK launch command (?SessionName=,
  // ?ServerAdminPassword=, ?Port=, ?QueryPort=, etc.) from them. ARK's
  // launch args BEAT GameUserSettings.ini values — so editing those
  // fields in the INI is a no-op against env defaults. Setting them
  // here is the only way to actually change them. Each apply runs a
  // stop → delete-preserve → create → start dance (~30s).
  "ark-sa": [{
    // The acekorneya image's launch_ASA.sh reads MAP_NAME (NOT SERVER_MAP)
    // and assembles the launch arg as `${MAP_NAME}_WP` for unknown maps,
    // or passes the value through verbatim if it already ends in _WP. So
    // setting MAP_NAME=Ragnarok_WP gives `ArkAscendedServer.exe Ragnarok_WP?listen…`.
    prop: "MAP_NAME",
    label: "Map",
    type: "select",
    default: "TheIsland_WP",
    hint: "Changing the map recreates the container with the new world. ★ = community map (mods required).",
    options: [
      ["TheIsland_WP",       "The Island"],
      ["TheCenter_WP",       "The Center"],
      ["ScorchedEarth_WP",   "Scorched Earth"],
      ["Aberration_WP",      "Aberration"],
      ["Extinction_WP",      "Extinction"],
      ["LostColony_WP",      "Lost Colony"],
      ["Astraeos_WP",        "Astraeos ★"],
      ["Ragnarok_WP",        "Ragnarok ★"],
      ["Valguero_WP",        "Valguero ★"],
      ["BobsMissions_WP",    "Club ARK (BobsMissions)"],
    ],
    // Paths are relative to the manifest's browseable_root, which for
    // ARK SA is .../ShooterGame/Saved — so "SavedArks" wipes
    // .../ShooterGame/Saved/SavedArks (every map's saves on this server).
    wipe_paths: ["SavedArks"],
    wipe_label: "Wipe existing world data (SavedArks/)",
    wipe_hint: "Deletes every map's saves on this server, not just the old map's. Defaults on — most operators treat a map switch as a fresh server. Uncheck to keep old saves around for switching back later.",
  }, {
    prop: "SESSION_NAME",
    label: "Session name",
    type: "string",
    default: "Panel ARK SA Server",
    hint: "What players see in the Steam server browser. Changing this requires a container recreate (world data preserved).",
  }, {
    prop: "SERVER_ADMIN_PASSWORD",
    label: "Admin password",
    type: "password",
    default: "changeme-please",
    hint: "Used for RCON + in-game admin commands. Empty values aren't allowed by ARK — keep something here.",
  }, {
    prop: "SERVER_PASSWORD",
    label: "Join password",
    type: "password",
    default: "",
    hint: "Leave blank for an open server. Players are prompted for this on join.",
  }, {
    prop: "MAX_PLAYERS",
    label: "Max players",
    type: "string",
    default: "70",
    hint: "Hard cap on concurrent players. ARK SA's official caps are 70 (vanilla) and 100+ (modded). Numeric only.",
  }, {
    prop: "BATTLEEYE",
    label: "BattlEye anti-cheat",
    type: "select",
    default: "FALSE",
    hint: "BattlEye under Wine/Proton on Linux containers tends to crash the server 2-3 minutes after start (silent UE5 death). Default FALSE; flip to TRUE only if you know it works on your image.",
    options: [["FALSE","Off (recommended on Linux/Wine)"],["TRUE","On"]],
  }, {
    prop: "MOD_IDS",
    label: "Mod IDs",
    type: "string",
    default: "",
    hint: "Comma-separated CurseForge mod IDs (the panel's Mods tab manages this for you with previews + one-click install — usually edit there, not here).",
  }, {
    prop: "PASSIVE_MODS",
    label: "Passive Mod IDs",
    type: "string",
    default: "",
    hint: "Mods loaded for clients but not enforced server-side (cosmetic / mapper mods).",
  }, {
    prop: "PUBLIC_IP_FOR_EPIC",
    label: "Public IP for EOS (server browser)",
    type: "string",
    default: "",
    hint: "Pin the IPv4 ASA advertises to Epic Online Services for the in-game server browser. Leave blank to let ASA auto-detect (the default — fine on a single-NIC host). Set to the WAN IPv4 players actually reach (your public edge IP) if servers periodically vanish from the browser while RCON still works. Fixes the EOS \"wrong adapter on re-register\" bug.",
  }],
  // V Rising env-driven settings. CLI flags (which our entrypoint
  // assembles from these env vars) override ServerHostSettings.json,
  // so this is the canonical place to set server identity + slots +
  // password. Each apply runs the recreate dance (~30s) — world data
  // (Saves/) is preserved.
  "v-rising": [{
    prop: "SERVER_NAME",
    label: "Server name",
    type: "string",
    default: "panel V Rising",
    hint: "What players see in the in-game server browser.",
  }, {
    prop: "SERVER_DESCRIPTION",
    label: "Description",
    type: "string",
    default: "A V Rising server hosted by panel.",
    hint: "Short tagline shown in the server details pane.",
  }, {
    prop: "SERVER_PASSWORD",
    label: "Join password",
    type: "password",
    default: "",
    hint: "Leave blank for an open server. Players are prompted on join.",
  }, {
    prop: "MAX_PLAYERS",
    label: "Max players",
    type: "string",
    default: "40",
    hint: "Concurrent connection cap. Stunlock's hard ceiling is 128.",
  }, {
    prop: "MAX_ADMINS",
    label: "Reserved admin slots",
    type: "string",
    default: "4",
    hint: "Slots held open for admins when the server hits Max players.",
  }, {
    prop: "SAVE_NAME",
    label: "Save name",
    type: "string",
    default: "panel",
    hint: "Folder name under Saves/. Changing this starts a fresh world (the old one stays on disk under its old name).",
  }, {
    prop: "LIST_ON_STEAM",
    label: "List on Steam",
    type: "select",
    default: "true",
    options: [["true","Yes — show in Steam browser"],["false","No"]],
    hint: "Steam server browser registration.",
  }, {
    prop: "LIST_ON_EOS",
    label: "List on EOS (cross-play)",
    type: "select",
    default: "true",
    options: [["true","Yes — show in in-game cross-play browser"],["false","No"]],
    hint: "Epic Online Services registration. Both Steam + EOS clients use this.",
  }, {
    prop: "GAME_SETTINGS_PRESET",
    label: "Game settings preset",
    type: "select",
    default: "",
    options: [
      ["",                       "(none — use ServerGameSettings.json)"],
      ["StandardPvP",            "Standard PvP"],
      ["StandardPvE",            "Standard PvE"],
      ["Duo_PvP",                "Duo PvP"],
      ["Solo_PvP",               "Solo PvP"],
      ["HardcorePvP",            "Hardcore PvP"],
      ["Hardcore_PvP_NoDuration","Hardcore PvP (no duration)"],
    ],
    hint: "Stunlock's bundled rule presets. Setting any preset OVERRIDES individual edits in ServerGameSettings.json — leave blank if you've customised game rules manually.",
  }, {
    prop: "RCON_ENABLED",
    label: "RCON enabled",
    type: "select",
    default: "false",
    options: [["false","Off"],["true","On"]],
    hint: "Source-RCON over TCP. The panel's console uses RCON when available.",
  }, {
    prop: "RCON_PASSWORD",
    label: "RCON password",
    type: "password",
    default: "",
    hint: "Required when RCON is enabled. Used by the panel's console + any external RCON tools you connect.",
  }],

  // Conan Exiles env-driven launch settings. The container's entrypoint
  // assembles `-ServerName=…`, `-MaxPlayers=…`, `-AdminPassword=…` from
  // these env vars, and these BEAT whatever's in ServerSettings.ini for
  // the Funcom-validated keys (server name, password, slots). Map switches
  // wipe the world unless you tick the wipe-skip checkbox in the dialog.
  "conan-exiles": [{
    prop: "SERVER_NAME",
    label: "Server name",
    type: "string",
    default: "panel Conan Exiles",
    hint: "Shown in the in-game server browser. Set via -ServerName launch arg, overrides INI on every boot.",
  }, {
    prop: "SERVER_MAP",
    label: "Map",
    type: "select",
    default: "/Game/Maps/ConanSandbox/ConanSandbox",
    hint: "Switching maps does NOT wipe game.db — but the SQLite world is map-specific, so switching maps then back may corrupt actor references. Treat as fresh-world by default; uncheck the wipe to keep the old DB around.",
    options: [
      ["/Game/Maps/ConanSandbox/ConanSandbox",            "Exiled Lands"],
      ["/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah", "Isle of Siptah (DLC)"],
    ],
    wipe_paths: ["ConanSandbox/Saved/game.db", "ConanSandbox/Saved/game.db-shm", "ConanSandbox/Saved/game.db-wal"],
    wipe_label: "Wipe world DB (game.db)",
    wipe_hint: "Conan stores all world state in a single SQLite file. Default on for map switches — keeping the old DB across maps tends to leave orphaned thralls/buildings referencing the wrong map's actors.",
  }, {
    prop: "MAX_PLAYERS",
    label: "Max players",
    type: "string",
    default: "40",
    hint: "Concurrent player cap. Funcom's official cap is 40; Conan can technically host more but performance degrades.",
  }, {
    prop: "RCON_PASSWORD",
    label: "RCON password",
    type: "password",
    default: "panel_rcon",
    hint: "Source-RCON password the panel's console uses. Different from the in-game AdminPassword (which is in the Config tab).",
  }, {
    prop: "STEAM_APP_ID",
    label: "Steam App ID override",
    type: "string",
    default: "440900",
    hint: "Conan dedicated server reports itself as the regular Conan app (440900) for the master-server listing — leave this alone unless you know exactly what you're changing.",
    advanced: true,
  }, {
    prop: "EDITION",
    label: "Edition",
    type: "select",
    default: "enhanced",
    hint: "Switching from UE5→UE4 may not load Enhanced-era saves; backup first. UE4→UE5 is safe (Funcom's migration handles it). Click Update after changing.",
    options: [
      ["enhanced", "Enhanced (UE5, default)"],
      ["legacy",   "Legacy (UE4 — Steam beta branch)"],
    ],
  }],

  // Rust env-driven launch settings. RustDedicated takes most settings
  // via -nounityaudio +server.X "Y" command-line flags; the entrypoint
  // assembles those from these env vars. Procgen seed/size are baked
  // into the world data on first boot — changing seed or world size
  // requires a fresh world.
  "rust": [{
    prop: "SERVER_NAME",
    label: "Server name (hostname)",
    type: "string",
    default: "panel Rust",
    hint: "Shown in the in-game server browser. Maps to +server.hostname on the launch line.",
  }, {
    prop: "SERVER_DESCRIPTION",
    label: "Description",
    type: "string",
    default: "Powered by panel",
    hint: "Long-form description shown in the server details pane.",
  }, {
    prop: "SERVER_URL",
    label: "Website URL",
    type: "string",
    default: "",
    hint: "Optional. Click-through link in the server browser.",
  }, {
    prop: "SERVER_HEADER_IMAGE",
    label: "Header image URL",
    type: "string",
    default: "",
    hint: "512×256 PNG/JPG hosted publicly over HTTPS. Shown above the description in the browser.",
  }, {
    prop: "SERVER_IDENTITY",
    label: "Server identity",
    type: "string",
    default: "panel-rust",
    hint: "Folder name under server/ where saves live. Changing this points the server at a different save dir — the old one stays on disk. Avoid spaces.",
  }, {
    prop: "SERVER_SEED",
    label: "World seed",
    type: "string",
    default: "12345",
    hint: "Procgen seed. Changing this generates a new world on next boot UNLESS the existing identity already has a generated map.",
  }, {
    prop: "SERVER_WORLD_SIZE",
    label: "World size",
    type: "string",
    default: "3000",
    hint: "1000–6000. Larger = longer procgen + more RAM. Same caveat as seed: existing world wins if it's already been generated.",
  }, {
    prop: "SERVER_LEVEL",
    label: "Map preset",
    type: "select",
    default: "Procedural Map",
    options: [
      ["Procedural Map", "Procedural Map (default)"],
      ["Barren",          "Barren"],
      ["HapisIsland",     "Hapis Island"],
      ["SavasIsland",     "Savas Island"],
      ["CraggyIsland",    "Craggy Island"],
    ],
    hint: "Most servers stay on Procedural Map. The fixed maps (Hapis, Savas, Craggy) ignore seed + world size.",
  }, {
    prop: "MAX_PLAYERS",
    label: "Max players",
    type: "string",
    default: "50",
    hint: "Concurrent player cap. Rust scales well — official servers run 200+; community private servers usually 50–150.",
  }, {
    prop: "RCON_PASSWORD",
    label: "RCON password",
    type: "password",
    default: "panel_rcon",
    hint: "WebSocket-RCON password. Used by the panel's console + RustAdmin / Battlemetrics if you wire them up.",
  }],
  // Soulmask — EVERYTHING goes through env-config recreate (gamehost calls
  // this DurableConfig=false). Two kinds of key ride the same POST:
  //  - real container env (SERVER_NAME/GAME_MODE/… ) the entrypoint turns
  //    into launch args (-SteamServerName= / -pve / -PSW= / -adminpsw= …);
  //  - gx_* config_values consumed by templates/GameXishu.json.tmpl — the
  //    recreate re-renders the boot template, and the entrypoint copies it
  //    into WS/Saved/GameplaySettings/GameXishu.json on every start.
  // config-render alone would apply only the gx_* half and silently drop
  // the env half, hence one recreate path for both (2026-07-08 changelog).
  // Fields carry `group` — the launch pane renders per-group headings.
  "soulmask": [{
    prop: "SERVER_NAME", group: "Server", label: "Server name", type: "string",
    default: "panel Soulmask",
    hint: "Steam browser name (passed as -SteamServerName=).",
  }, {
    prop: "GAME_MODE", group: "Server", label: "Game mode", type: "select",
    default: "pve", options: [["pve","PvE"],["pvp","PvP"]],
    hint: "Launch flag -pve / -pvp. PvP damage between players is additionally gated by the PvP damage knob below.",
  }, {
    prop: "SERVER_PASSWORD", group: "Server", label: "Join password", type: "password",
    default: "", hint: "Leave blank for an open server (-PSW=).",
  }, {
    prop: "ADMIN_PASSWORD", group: "Server", label: "Admin password", type: "password",
    default: "changeme", hint: "In-game GM/admin panel password (-adminpsw= — NOT the join password).",
  }, {
    prop: "MAX_PLAYERS", group: "Server", label: "Max players", type: "string",
    default: "30", hint: "Concurrent player cap (-MaxPlayers=). Numeric only.",
  }, {
    prop: "SAVING", group: "Server", label: "World save interval (s)", type: "string",
    default: "600", hint: "-saving= — how often the world autosaves.",
  }, {
    prop: "BACKUP", group: "Server", label: "Backup interval (s)", type: "string",
    default: "900", hint: "-backup= — rolling save-folder backups.",
  },
  // --- GameXishu gameplay knobs (templates/GameXishu.json.tmpl, stamped
  // into all 3 difficulty-preset sections). Ratios: 1 = vanilla.
  {
    prop: "gx_exp_ratio", group: "XP rates", label: "XP multiplier", type: "string",
    default: "1", hint: "Overall character XP (ExpRatio). 1 = vanilla.",
  }, {
    prop: "gx_gather_exp_ratio", group: "XP rates", label: "Gathering XP", type: "string",
    default: "1", hint: "XP from gathering/harvesting (CaiJiExpRatio).",
  }, {
    prop: "gx_kill_exp_ratio", group: "XP rates", label: "Kill XP", type: "string",
    default: "1", hint: "XP from kills (ShaGuaiExpRatio).",
  }, {
    prop: "gx_harvest_ratio", group: "Harvest & loot", label: "Harvest yield", type: "string",
    default: "1", hint: "Hand-gathering yield (CaiJiDiaoLuoRatio).",
  }, {
    prop: "gx_wood_ratio", group: "Harvest & loot", label: "Logging yield", type: "string",
    default: "1", hint: "Wood from trees (FaMuDiaoLuoRatio).",
  }, {
    prop: "gx_mining_ratio", group: "Harvest & loot", label: "Mining yield", type: "string",
    default: "1", hint: "Ore/stone from mining (CaiKuangDiaoLuoRatio).",
  }, {
    prop: "gx_animal_loot_ratio", group: "Harvest & loot", label: "Animal loot", type: "string",
    default: "1", hint: "Drops from animal corpses (DongWuShiTiDiaoLuoRatio).",
  }, {
    prop: "gx_crop_growth_ratio", group: "Growth & survival", label: "Crop growth speed", type: "string",
    default: "1", hint: "ZuoWuShengZhangRatio — higher = faster crops.",
  }, {
    prop: "gx_animal_growth_ratio", group: "Growth & survival", label: "Animal growth speed", type: "string",
    default: "1", hint: "Tamed-animal maturing speed (DongWuShengZhangRatio).",
  }, {
    prop: "gx_food_consume_ratio", group: "Growth & survival", label: "Hunger rate", type: "string",
    default: "1", hint: "Food drain (ShiWuXiaoHaoRatio). Lower = slower hunger.",
  }, {
    prop: "gx_water_consume_ratio", group: "Growth & survival", label: "Thirst rate", type: "string",
    default: "1", hint: "Water drain (ShuiXiaoHaoRatio).",
  }, {
    prop: "gx_item_decay_ratio", group: "Decay & death", label: "Item spoil rate", type: "string",
    default: "1", hint: "Food/item spoilage speed (WuPinFuHuaiRatio).",
  }, {
    prop: "gx_building_decay", group: "Decay & death", label: "Building decay", type: "select",
    default: "1", options: [["1","On (vanilla)"],["0","Off"]],
    hint: "JianZhuFuLanKaiGuan — structure decay over time.",
  }, {
    prop: "gx_drop_on_death", group: "Decay & death", label: "Drop items on death", type: "select",
    default: "0", options: [["0","Keep inventory"],["1","Drop on death"]],
    hint: "FuHuoMoveSiWangBaoKaiGuan — death penalty for inventory.",
  }, {
    prop: "gx_pvp_damage", group: "World & PvP", label: "PvP damage", type: "select",
    default: "1", options: [["1","On"],["0","Off"]],
    hint: "HuXIangShangHaiKaiGuan — player-vs-player damage switch (independent of the pve/pvp mode flag).",
  }, {
    prop: "gx_day_length", group: "World & PvP", label: "Day length (in-game hours/day)", type: "string",
    default: "24", hint: "GameWorldTimePower. 24 = vanilla pacing; lower = faster days.",
  }, {
    prop: "gx_daytime_portion", group: "World & PvP", label: "Daylight portion (0–1)", type: "string",
    default: "0.8", hint: "GameWorldDayTimePortion — fraction of the cycle that is daytime. Vanilla ≈ 0.8.",
  }],
  // Minecraft Bedrock env-driven settings. The itzg image stamps each of
  // these into /data/server.properties on EVERY container start, so the
  // env value is the source of truth — editing the same keys in the file
  // editor would silently revert on the next boot (the file schema only
  // exposes the NON-env-managed properties). Each apply = recreate dance;
  // world data (the /data volume) is preserved.
  "minecraft-bedrock": [{
    prop: "SERVER_NAME", group: "Identity", label: "Server name", type: "string",
    default: "Refuge Bedrock", hint: "→ server-name. Shown in the in-game server list.",
  }, {
    prop: "MAX_PLAYERS", group: "Identity", label: "Max players", type: "string",
    default: "10", hint: "→ max-players. Numeric only.",
  }, {
    prop: "ONLINE_MODE", group: "Identity", label: "Online mode (Xbox Live auth)", type: "select",
    default: "true", options: [["true","On (recommended)"],["false","Off"]],
    hint: "→ online-mode. Off allows unauthenticated clients.",
  }, {
    prop: "GAMEMODE", group: "Gameplay", label: "Default gamemode", type: "select",
    default: "survival", options: [["survival","Survival"],["creative","Creative"],["adventure","Adventure"]],
  }, {
    prop: "DIFFICULTY", group: "Gameplay", label: "Difficulty", type: "select",
    default: "easy", options: [["peaceful","Peaceful"],["easy","Easy"],["normal","Normal"],["hard","Hard"]],
  }, {
    prop: "ALLOW_CHEATS", group: "Gameplay", label: "Allow cheats (commands)", type: "select",
    default: "false", options: [["false","Off"],["true","On"]],
  }, {
    prop: "DEFAULT_PLAYER_PERMISSION_LEVEL", group: "Gameplay", label: "Default permission level", type: "select",
    default: "member", options: [["visitor","Visitor"],["member","Member"],["operator","Operator"]],
  }, {
    prop: "LEVEL_NAME", group: "World", label: "World folder (level name)", type: "string",
    default: "level", hint: "→ level-name. ⚠ Changing it loads/creates a DIFFERENT world folder under worlds/ — the old one stays on disk.",
  }, {
    prop: "LEVEL_TYPE", group: "World", label: "World type", type: "select",
    default: "DEFAULT", options: [["DEFAULT","Default"],["FLAT","Flat"],["LEGACY","Legacy"]],
    hint: "Only applies when the world is first generated.",
  }, {
    prop: "VIEW_DISTANCE", group: "Performance", label: "View distance (chunks)", type: "string",
    default: "10", hint: "→ view-distance. Numeric only.",
  }, {
    prop: "TICK_DISTANCE", group: "Performance", label: "Tick distance (chunks)", type: "string",
    default: "4", hint: "→ tick-distance. 4–12.",
  }, {
    prop: "VERSION", group: "Updates", label: "Bedrock server version", type: "string",
    default: "LATEST", hint: "LATEST, or a pinned version like 1.21.44.01 — itzg re-downloads the binary on change.",
  }],
  // Core Keeper env-driven settings. The entrypoint passes these straight
  // to the binary as -worldname/-worldmode/-maxplayers/-gameid launch
  // args; there is no config file at all (no RCON either — admin is the
  // in-game founders key). Each apply = recreate dance; worlds live under
  // /game-saves/DedicatedServer and are preserved.
  "core-keeper": [{
    prop: "WORLD_NAME", group: "World", label: "World name", type: "string",
    default: "panel", hint: "-worldname. ⚠ Changing it loads/creates a different world save.",
  }, {
    prop: "WORLD_MODE", group: "World", label: "World mode", type: "select",
    default: "0", options: [["0","Normal"],["1","Hard"],["2","Creative"],["4","Casual"]],
    hint: "-worldmode. Only applies when the world is first created.",
  }, {
    prop: "MAX_PLAYERS", group: "Server", label: "Max players", type: "string",
    default: "10", hint: "-maxplayers. Numeric only.",
  }, {
    prop: "GAME_ID", group: "Server", label: "Game ID (invite code)", type: "string",
    default: "", hint: "-gameid — pin a fixed 28+ char Game ID so players can rejoin by code. Empty = server generates one (see GameID.txt in /game).",
  }],
};

// renderEnvConfigCard returns the HTML for the Game launch settings
// section. Returns "" when the instance's module has no env-config
// fields declared, so the section is hidden cleanly.
//
// UX: rows track local dirty state. A footer "Apply N changes" button
// batches every diverged field into a single recreate dance — earlier
// per-row Apply buttons forced the operator to recreate the container
// once per knob, which made multi-field edits painfully slow.
function renderEnvConfigCard(inst) {
  const fields = ENV_CONFIG_SCHEMA[inst.module_id];
  if (!fields || !fields.length) return "";
  const cv = inst.config_values || {};
  const rowsHTML = fields.map(f => {
    const id = "im-env-" + f.prop;
    const cur = cv[f.prop] || f.default || "";
    let ctrl = "";
    if (f.type === "select") {
      const opts = (f.options || []).map(o => {
        const [val, lab] = Array.isArray(o) ? o : [o, o];
        const sel = (val === cur) ? " selected" : "";
        return `<option value="${esc(val)}"${sel}>${esc(lab)}</option>`;
      }).join("");
      ctrl = `<select id="${id}" data-env-prop="${esc(f.prop)}" data-env-original="${esc(cur)}" style="min-width:240px">${opts}</select>`;
    } else if (f.type === "bool") {
      const checked = (cur === "true" || cur === "1") ? " checked" : "";
      ctrl = `<input id="${id}" data-env-prop="${esc(f.prop)}" data-env-original="${esc(cur)}" type="checkbox"${checked}>`;
    } else if (f.type === "password") {
      ctrl = `<input id="${id}" data-env-prop="${esc(f.prop)}" data-env-original="${esc(cur)}" type="password" value="${esc(cur)}" autocomplete="new-password" style="min-width:240px">`;
    } else {
      ctrl = `<input id="${id}" data-env-prop="${esc(f.prop)}" data-env-original="${esc(cur)}" value="${esc(cur)}" style="min-width:240px">`;
    }
    return `<div class="im-cfg-field" data-env-row="${esc(f.prop)}" style="flex-direction:row;align-items:center;gap:14px">
      <label for="${id}" style="display:flex;flex-direction:column;align-items:flex-start;gap:2px;flex:0 0 180px">
        <span class="name-line">${esc(f.label)}</span>
        <span class="mono dim" title="${esc(f.prop)}">${esc(f.prop)}</span>
      </label>
      <div style="flex:1">
        ${ctrl}
        ${f.hint ? `<div class="form-hint" style="margin-top:5px">${f.hint}</div>` : ""}
      </div>
      <span class="env-dirty-dot dirty-dot" data-env-dot="${esc(f.prop)}" style="display:none" title="Changed — pending Apply"></span>
    </div>`;
  }).join("");
  return `<section class="im-set-card" style="margin-bottom:16px">
    <header>Game launch settings <span class="dim mono" style="font-size:11px;font-weight:400;margin-left:8px">env-var · recreates container · world data preserved</span></header>
    <div class="body" style="padding:14px">${rowsHTML}</div>
    <footer id="env-config-footer" style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);background:var(--bg-2)">
      <span id="env-config-status" class="dim" style="font-size:12.5px">No changes.</span>
      <span style="display:flex;gap:8px">
        <button id="env-config-reset" class="small" disabled>Reset</button>
        <button id="env-config-apply" class="small primary" disabled>Apply changes</button>
      </span>
    </footer>
  </section>`;
}

// wireEnvConfigCard hooks input/change handlers on every env field and
// drives the single footer Apply button. Tracks dirty state in a local
// Set; on Apply click, bundles every diverged field into one POST to
// /env-config and runs a single recreate. Wipe-checkbox per dirty field
// (when the schema declares wipe_paths) is consolidated into the same
// confirm modal — operator gets one decision per affected wipe path
// instead of one modal per field.
function wireEnvConfigCard(instanceID) {
  const inst = allInstances.find(x => x.instance_id === instanceID);
  const schema = inst ? (ENV_CONFIG_SCHEMA[inst.module_id] || []) : [];
  if (!schema.length) return;
  const fieldByProp = {};
  for (const f of schema) fieldByProp[f.prop] = f;

  const inputs = Array.from(document.querySelectorAll("[data-env-prop]"));
  const applyBtn  = document.getElementById("env-config-apply");
  const resetBtn  = document.getElementById("env-config-reset");
  const statusEl  = document.getElementById("env-config-status");
  if (!applyBtn || !resetBtn || !statusEl) return;

  const readVal = (el) => el.type === "checkbox" ? (el.checked ? "true" : "false") : el.value;
  const setVal  = (el, v) => {
    if (el.type === "checkbox") el.checked = (v === "true" || v === "1");
    else el.value = v;
  };

  const collectDirty = () => {
    const dirty = [];
    for (const el of inputs) {
      const prop = el.dataset.envProp;
      const orig = el.dataset.envOriginal || "";
      const cur = readVal(el);
      if (cur !== orig) dirty.push({ prop, orig, cur, def: fieldByProp[prop] });
    }
    return dirty;
  };

  const refreshFooter = () => {
    const dirty = collectDirty();
    const dirtyProps = new Set(dirty.map(d => d.prop));
    // Per-row dirty dot
    for (const el of inputs) {
      const prop = el.dataset.envProp;
      const dot = document.querySelector(`[data-env-dot="${CSS.escape(prop)}"]`);
      const row = document.querySelector(`[data-env-row="${CSS.escape(prop)}"]`);
      if (dot) dot.style.display = dirtyProps.has(prop) ? "" : "none";
      if (row) row.classList.toggle("row-dirty", dirtyProps.has(prop));
    }
    applyBtn.disabled = dirty.length === 0;
    resetBtn.disabled = dirty.length === 0;
    if (dirty.length === 0) {
      statusEl.textContent = "No changes.";
      applyBtn.textContent = "Apply changes";
    } else {
      statusEl.textContent = `${dirty.length} change${dirty.length === 1 ? "" : "s"} pending`;
      applyBtn.textContent = `Apply ${dirty.length} change${dirty.length === 1 ? "" : "s"}`;
    }
  };

  for (const el of inputs) {
    el.addEventListener("input", refreshFooter);
    el.addEventListener("change", refreshFooter);
  }

  resetBtn.addEventListener("click", () => {
    for (const el of inputs) {
      setVal(el, el.dataset.envOriginal || "");
    }
    refreshFooter();
  });

  applyBtn.addEventListener("click", async () => {
    const dirty = collectDirty();
    if (!dirty.length) return;
    // Collect wipe-paths from any dirty field that declared them. Each
    // distinct wipe_path set gets its own checkbox in the confirm modal
    // so the operator can decide per affected data area (e.g. ARK's
    // SavedArks wipe should be opt-in even when bundled with name+pwd
    // changes that don't need any wipe).
    const wipeOptions = [];
    const seen = new Set();
    for (const d of dirty) {
      const f = d.def;
      if (f && Array.isArray(f.wipe_paths) && f.wipe_paths.length) {
        const key = f.wipe_paths.join(",");
        if (seen.has(key)) continue;
        seen.add(key);
        wipeOptions.push({
          paths:  f.wipe_paths,
          label:  f.wipe_label || `Wipe related data (${key})`,
          hint:   f.wipe_hint  || "",
          fromProp: d.prop,
        });
      }
    }
    // Detect cross-edition transitions on Conan Exiles (or anywhere else
    // a module surfaces an EDITION key) and inject a high-visibility
    // warning into the confirm modal. This is per-direction: Enhanced→
    // Legacy is the dangerous one (UE5 saves don't always load under UE4),
    // Legacy→Enhanced is Funcom-handled and merely informational.
    const warnings = [];
    const editionChange = dirty.find(d => d.prop === "EDITION");
    if (editionChange) {
      const from = (editionChange.orig || "enhanced").toLowerCase();
      const to   = (editionChange.cur  || "enhanced").toLowerCase();
      if (from === "enhanced" && to === "legacy") {
        warnings.push({
          severity: "danger",
          title:    "⚠️ Switching from UE5 (Enhanced) to UE4 (Legacy)",
          body:     "Conan's Enhanced build writes saves with a newer ABI; loading them under UE4 may corrupt the world or refuse to start. Strongly recommend snapshotting the saves volume before applying. After this change, click Update on the Updates tab to pull the conan-exiles-legacy Steam beta branch.",
        });
      } else if (from === "legacy" && to === "enhanced") {
        warnings.push({
          severity: "info",
          title:    "↗ Switching from UE4 (Legacy) to UE5 (Enhanced)",
          body:     "Funcom's migration handles UE4→UE5 saves cleanly for standard world data; mods will need their UE5 Workshop versions. After this change, click Update on the Updates tab to pull the public Enhanced branch.",
        });
      }
    }
    const confirmed = await openEnvConfigConfirmBatch({
      changes: dirty.map(d => ({ prop: d.prop, oldVal: d.orig, newVal: d.cur, label: d.def && d.def.label || d.prop })),
      wipeOptions,
      warnings,
    });
    if (!confirmed) return;
    applyBtn.disabled = true;
    resetBtn.disabled = true;
    applyBtn.textContent = "Recreating…";
    const updates = {};
    for (const d of dirty) updates[d.prop] = d.cur;
    if (confirmed.wipePaths && confirmed.wipePaths.length) {
      updates["_PANEL_WIPE_PATHS"] = confirmed.wipePaths.join(",");
    }
    try {
      await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/env-config`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ updates }),
      });
      toast(`${dirty.length} setting${dirty.length === 1 ? "" : "s"} applied${confirmed.wipePaths && confirmed.wipePaths.length ? " (data wiped)" : ""}`, "ok");
      loadInstanceConfig();
      pollInstances();
    } catch (err) {
      toast(`Apply failed: ${err.message}`, "err");
      applyBtn.disabled = false;
      resetBtn.disabled = false;
      applyBtn.textContent = `Apply ${dirty.length} change${dirty.length === 1 ? "" : "s"}`;
    }
  });

  refreshFooter();
}

// openEnvConfigConfirm — mini-modal on top of the instance modal.
// Resolves to either {wipe: bool} (proceed) or null (cancelled).
function openEnvConfigConfirm({ prop, oldVal, newVal, wipePaths, wipeLabel, wipeHint }) {
  return new Promise(resolve => {
    let modal = $("#env-config-confirm-modal");
    if (!modal) {
      modal = document.createElement("div");
      modal.id = "env-config-confirm-modal";
      modal.className = "modal-bg";
      modal.style.display = "none";
      modal.innerHTML = `<div class="modal small">
        <div class="modal-head"><h3>Apply config change</h3><button data-close>Close</button></div>
        <div class="modal-body"><div class="pad" style="display:flex;flex-direction:column;gap:14px">
          <div id="ec-summary" style="font-size:13.5px;line-height:1.5"></div>
          <label id="ec-wipe-row" style="display:none;align-items:flex-start;gap:10px;cursor:pointer;border:1px solid var(--border);border-radius:var(--radius);padding:11px 13px;background:rgba(248,113,113,.05)">
            <input type="checkbox" id="ec-wipe-cb" checked style="margin-top:2px">
            <div>
              <div style="font-weight:600;font-size:13px;color:var(--err)" id="ec-wipe-label"></div>
              <div class="dim" style="font-size:11.5px;line-height:1.45;margin-top:3px" id="ec-wipe-hint"></div>
            </div>
          </label>
          <div class="dim" style="font-size:11.5px">
            The server will stop, recreate with the new value, and restart if it was running. ~30 seconds typical.
          </div>
          <div style="display:flex;gap:8px;justify-content:flex-end">
            <button data-close>Cancel</button>
            <button id="ec-go" class="primary">Apply</button>
          </div>
        </div></div>
      </div>`;
      document.body.appendChild(modal);
      if (typeof _wireBackdropClose === "function") _wireBackdropClose(modal);
    }
    $("#ec-summary").innerHTML =
      `Change <span class="mono" style="color:var(--accent)">${esc(prop)}</span> from `
      + `<span class="mono">"${esc(oldVal || "—")}"</span> to <span class="mono">"${esc(newVal)}"</span>?`;
    const wipeRow = $("#ec-wipe-row");
    if (wipePaths) {
      wipeRow.style.display = "flex";
      $("#ec-wipe-label").textContent = wipeLabel;
      $("#ec-wipe-hint").textContent  = wipeHint;
      $("#ec-wipe-cb").checked = true;
    } else {
      wipeRow.style.display = "none";
    }
    showModal("#env-config-confirm-modal");
    const goBtn = $("#ec-go");
    const closeAndResolve = (val) => {
      hideModal("#env-config-confirm-modal");
      // Cleanup listeners so successive opens don't double-fire.
      goBtn.onclick = null;
      modal.querySelectorAll("[data-close]").forEach(b => b.onclick = null);
      resolve(val);
    };
    goBtn.onclick = () => closeAndResolve({ wipe: !!$("#ec-wipe-cb").checked });
    modal.querySelectorAll("[data-close]").forEach(b => b.onclick = () => closeAndResolve(null));
  });
}

// openEnvConfigConfirmBatch — confirm modal for the batched env-config
// flow. Lists every diverged field on a single screen and surfaces one
// checkbox per distinct wipe-path set declared by any dirty field. The
// resolver returns { wipePaths: [...] } where wipePaths is the union of
// every wipe set the operator left checked, or null on cancel.
function openEnvConfigConfirmBatch({ changes, wipeOptions, warnings }) {
  return new Promise(resolve => {
    let modal = document.getElementById("env-config-confirm-batch-modal");
    if (!modal) {
      modal = document.createElement("div");
      modal.id = "env-config-confirm-batch-modal";
      modal.className = "modal-bg";
      modal.style.display = "none";
      modal.innerHTML = `<div class="modal small">
        <div class="modal-head"><h3>Apply config changes</h3><button data-close>Close</button></div>
        <div class="modal-body"><div class="pad" style="display:flex;flex-direction:column;gap:14px">
          <div id="ecb-warnings" style="display:flex;flex-direction:column;gap:8px"></div>
          <div id="ecb-summary" style="font-size:13px;line-height:1.55"></div>
          <div id="ecb-wipes" style="display:flex;flex-direction:column;gap:8px"></div>
          <div class="dim" style="font-size:11.5px">
            One stop → recreate → start cycle covers every change. ~30 seconds typical. World data is preserved unless a wipe option is checked above.
          </div>
          <div style="display:flex;gap:8px;justify-content:flex-end">
            <button data-close>Cancel</button>
            <button id="ecb-go" class="primary">Apply</button>
          </div>
        </div></div>
      </div>`;
      document.body.appendChild(modal);
      if (typeof _wireBackdropClose === "function") _wireBackdropClose(modal);
    }
    // Warnings render at the TOP of the modal — high-visibility banner for
    // changes that have data-loss potential beyond the standard recreate
    // (e.g. cross-edition swaps on Conan Exiles). Severity drives colour:
    // danger (red, demands attention), info (blue, just heads-up).
    const warnEl = document.getElementById("ecb-warnings");
    if (warnEl) {
      warnEl.innerHTML = (warnings || []).map(w => {
        const isDanger = w.severity === "danger";
        const accent   = isDanger ? "var(--err)" : "#93c5fd";
        const bg       = isDanger ? "rgba(248,113,113,.08)" : "rgba(96,165,250,.08)";
        const border   = isDanger ? "rgba(248,113,113,.35)" : "rgba(96,165,250,.35)";
        return `<div style="border:1px solid ${border};border-radius:var(--radius);padding:11px 13px;background:${bg}">
          <div style="font-weight:600;font-size:13px;color:${accent};margin-bottom:4px">${esc(w.title || "")}</div>
          <div class="dim" style="font-size:11.5px;line-height:1.5">${esc(w.body || "")}</div>
        </div>`;
      }).join("");
      warnEl.style.display = (warnings && warnings.length) ? "" : "none";
    }
    const sumEl   = document.getElementById("ecb-summary");
    const wipesEl = document.getElementById("ecb-wipes");
    sumEl.innerHTML = `<div style="margin-bottom:6px">${changes.length} setting${changes.length === 1 ? "" : "s"} will change:</div>`
      + `<ul style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:4px">`
      + changes.map(c =>
          `<li><span style="color:var(--text)">${esc(c.label)}</span>: `
          + `<span class="mono dim">"${esc(c.oldVal || "—")}"</span> `
          + `<span class="dim">→</span> `
          + `<span class="mono" style="color:var(--accent)">"${esc(c.newVal || "—")}"</span></li>`
        ).join("")
      + `</ul>`;
    if (wipeOptions && wipeOptions.length) {
      wipesEl.innerHTML = wipeOptions.map((w, i) => `
        <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;border:1px solid var(--border);border-radius:var(--radius);padding:11px 13px;background:rgba(248,113,113,.05)">
          <input type="checkbox" id="ecb-wipe-${i}" checked style="margin-top:2px">
          <div>
            <div style="font-weight:600;font-size:13px;color:var(--err)">${esc(w.label)}</div>
            ${w.hint ? `<div class="dim" style="font-size:11.5px;line-height:1.45;margin-top:3px">${esc(w.hint)}</div>` : ""}
          </div>
        </label>
      `).join("");
    } else {
      wipesEl.innerHTML = "";
    }
    showModal("#env-config-confirm-batch-modal");
    const goBtn = document.getElementById("ecb-go");
    const closeAndResolve = (val) => {
      hideModal("#env-config-confirm-batch-modal");
      goBtn.onclick = null;
      modal.querySelectorAll("[data-close]").forEach(b => b.onclick = null);
      resolve(val);
    };
    goBtn.onclick = () => {
      const wipePaths = [];
      (wipeOptions || []).forEach((w, i) => {
        const cb = document.getElementById(`ecb-wipe-${i}`);
        if (cb && cb.checked) wipePaths.push(...w.paths);
      });
      closeAndResolve({ wipePaths });
    };
    modal.querySelectorAll("[data-close]").forEach(b => b.onclick = () => closeAndResolve(null));
  });
}

// ---------------- CONFIG tab ----------------
// ---- UNIFIED CONFIG TAB (rail + main + sticky-footer Apply) ----
// Replaces the older 3-stacked-cards layout. One nav, one Apply,
// dirty-state tracked across env-config / ports / INI. Per-game
// schemas (ENV_CONFIG_SCHEMA + configSchemas) plug straight in;
// no module-side changes needed.
const cfgState = {
  instanceID: "",
  schema: null,
  envFields: [],
  ports: [],
  iniPath: "",
  iniParsed: null,
  iniLoaded: false,
  activePane: "",
  dirty: new Map(),       // key="scope|prop" → {scope, prop, oldVal, newVal}
  portsHaveDupes: false,
};

function _cfgKey(scope, prop) { return scope + "|" + prop; }
function _cfgNormBool(v) { return /^(true|1|yes|on)$/i.test(String(v == null ? "" : v)) ? "true" : "false"; }

function loadInstanceConfig() {
  const id = instanceModalState.id;
  const wrap = $("#im-cfg-wrap");
  if (!id || !wrap) return;
  const inst = allInstances.find(x => x.instance_id === id);
  if (!inst) return;
  const mod = (allModules.find(m => m.id === inst.module_id) || {});

  cfgState.instanceID = id;
  cfgState.schema = configSchemas[inst.module_id] || null;
  // 7DTD V3.0: a 3.0-branch instance uses the v3 schema (SandboxCode + only the
  // serverconfig properties that survived the 3.0 overhaul). 2.6 and earlier keep
  // the classic schema.
  //
  // Detection MUST NOT hinge on the literal provider_id string alone: the MOVING
  // branches `public`/`current`/`stable` ALL resolve to the 3.0 build now that TFP
  // flipped public to 3.0 (2026-06-29) — see sevenDtdBranchKey in httpapi.go. An
  // earlier gate keyed only on `latest_experimental`/`v3*`, so updating a 3.0
  // server to "stable" (still 3.0 binaries) silently dropped the whole Sandbox
  // Options tab. We now treat any 3.0-resolving branch as v3, AND — as the ground
  // truth that survives future branch-label churn — treat the presence of a
  // persisted SandboxCode as decisive (a 2.6 server never has one).
  if (inst.module_id === "7dtd") {
    const cv = inst.config_values || {};
    const pid = (cv.provider_id || "").toLowerCase();
    const hasSandboxCode = !!(cv.sandbox_code || cv.SandboxCode);
    const v3Branch = pid === "latest_experimental" || pid === "current" ||
                     pid === "stable" || pid === "v3_0" || pid.startsWith("v3");
    if (hasSandboxCode || v3Branch) {
      cfgState.schema = configSchemas["7dtd-v3"] || cfgState.schema;
    }
  }
  // Conan Exiles: rewrite the config file path based on EDITION. Enhanced
  // (UE5, native) uses Config/LinuxServer/; Legacy (UE4, Wine) uses
  // Config/WindowsServer/. We clone the static schema so we don't mutate
  // the module-level entry. filePaths still lists both for graceful
  // fallback (e.g. legacy data still on disk after an edition flip).
  if (cfgState.schema && inst.module_id === "conan-exiles") {
    const cv = inst.config_values || {};
    const edition = (cv.EDITION || "enhanced").toLowerCase();
    const subdir = edition === "legacy" ? "WindowsServer" : "LinuxServer";
    const other  = edition === "legacy" ? "LinuxServer"   : "WindowsServer";
    const primary = `ConanSandbox/Saved/Config/${subdir}/ServerSettings.ini`;
    const fallback = `ConanSandbox/Saved/Config/${other}/ServerSettings.ini`;
    cfgState.schema = Object.assign({}, cfgState.schema, {
      file: primary,
      filePaths: [primary, fallback],
    });
  }
  // Project Zomboid: the per-server ini filename tracks the SERVER_NAME
  // env (~/Zomboid/Server/<SERVER_NAME>.ini, default "panel"). Same
  // clone-and-rewrite pattern as conan-exiles' EDITION above.
  if (cfgState.schema && inst.module_id === "project-zomboid") {
    const cv = inst.config_values || {};
    const sname = cv.SERVER_NAME || "panel";
    const rel = `Zomboid/Server/${sname}.ini`;
    cfgState.schema = Object.assign({}, cfgState.schema, {
      file: rel,
      filePaths: [rel, `/game-saves/${rel}`],
    });
  }
  cfgState.envFields = ENV_CONFIG_SCHEMA[inst.module_id] || [];
  cfgState.ports = (mod.ports && Array.isArray(mod.ports) ? mod.ports : null) || guessPortsForModule(inst.module_id) || [];
  cfgState.iniPath = "";
  cfgState.iniParsed = null;
  cfgState.iniLoaded = false;
  cfgState.dirty = new Map();
  cfgState.portsHaveDupes = false;

  // Build rail nav items.
  const railItems = [];
  if (cfgState.envFields.length) {
    railItems.push({ pane: "launch", icon: "🚀", title: "Launch settings", subtitle: "env vars · recreates", count: cfgState.envFields.length, scope: "env" });
  }
  if (cfgState.ports.length) {
    railItems.push({ pane: "ports", icon: "🌐", title: "Network ports", subtitle: "host ports · recreates", count: cfgState.ports.length, scope: "port" });
  }
  if (cfgState.schema) {
    cfgState.schema.groups.forEach((g, gi) => railItems.push({
      pane: "ini-" + gi, icon: g.icon || "⚙️", title: g.title, subtitle: "",
      count: g.fields.length, scope: "ini", groupIndex: gi,
    }));
  }

  if (!railItems.length) {
    wrap.style.padding = "16px";
    wrap.style.display = "block";
    wrap.style.overflow = "auto";
    wrap.innerHTML = `<div class="cfg-empty"><div class="big">📝</div>No configuration available<span class="hint">This module hasn't declared any editable settings. Use the <b>Files</b> tab to edit raw config files.</span></div>`;
    return;
  }

  cfgState.activePane = railItems[0].pane;

  // Override the wrap's default padding/overflow so our shell fills it.
  wrap.style.padding = "0";
  wrap.style.overflow = "hidden";
  wrap.style.display = "flex";
  wrap.style.flexDirection = "column";

  const quickItems = railItems.filter(it => it.scope !== "ini");
  const iniItems   = railItems.filter(it => it.scope === "ini");

  const railHTML = `
    ${quickItems.length ? `<div class="cfg-rail-section">
      <div class="cfg-rail-section-h">Server</div>
      ${quickItems.map(it => _cfgRailLinkHTML(it)).join("")}
    </div>` : ""}
    ${iniItems.length ? `<div class="cfg-rail-section">
      <div class="cfg-rail-section-h" title="${esc(cfgState.schema.file || "")}">${esc(cfgState.schema.file || "Config")}</div>
      ${iniItems.map(it => _cfgRailLinkHTML(it)).join("")}
    </div>` : ""}
  `;

  wrap.innerHTML = `
    <div class="cfg-shell">
      <div class="cfg-body">
        <aside class="cfg-rail">${railHTML}</aside>
        <main class="cfg-main">
          <div class="cfg-main-head">
            <div class="cfg-main-title" id="cfg-main-title"></div>
            <div class="cfg-search-wrap">
              <span class="cfg-search-icon">🔍</span>
              <input class="cfg-search" id="cfg-search" placeholder="Filter on this page…" autocomplete="off">
            </div>
          </div>
          <div class="cfg-pane-stage" id="cfg-pane-stage"></div>
        </main>
      </div>
      <footer class="cfg-foot">
        <span class="cfg-foot-status" id="cfg-foot-status">No changes</span>
        <button class="cfg-foot-btn small" id="cfg-foot-reset" disabled>Reset</button>
        <button class="cfg-foot-btn primary small" id="cfg-foot-apply" disabled>Apply</button>
      </footer>
    </div>
  `;

  wrap.querySelectorAll(".cfg-rail-link").forEach(b => {
    b.addEventListener("click", () => switchCfgPane(b.dataset.cfgPane));
  });
  $("#cfg-search").addEventListener("input", e => filterCfgPane(e.target.value));
  $("#cfg-foot-reset").addEventListener("click", resetAllCfg);
  $("#cfg-foot-apply").addEventListener("click", applyAllCfg);

  renderCfgPane(cfgState.activePane);
  refreshCfgFooter();
}

function _cfgRailLinkHTML(it) {
  return `<button class="cfg-rail-link${it.pane === cfgState.activePane ? " active" : ""}" data-cfg-pane="${esc(it.pane)}">
    <span class="cfg-rail-icon">${it.icon || "⚙️"}</span>
    <span class="cfg-rail-text">
      <span class="cfg-rail-title">${esc(it.title)}</span>
      ${it.subtitle ? `<span class="cfg-rail-sub">${esc(it.subtitle)}</span>` : ""}
    </span>
    <span class="cfg-rail-count">${it.count}</span>
    <span class="cfg-rail-dirty" data-cfg-rail-dirty="${esc(it.pane)}" style="display:none"></span>
  </button>`;
}

async function switchCfgPane(pane) {
  if (!pane || cfgState.activePane === pane) return;
  cfgState.activePane = pane;
  document.querySelectorAll(".cfg-rail-link").forEach(b => b.classList.toggle("active", b.dataset.cfgPane === pane));
  const search = $("#cfg-search");
  if (search) search.value = "";
  // Outgoing pane: slide-blur-fade out to the left, then swap content,
  // then the incoming pane plays its own cfgPaneIn entrance.
  const stage = $("#cfg-pane-stage");
  if (stage) {
    stage.classList.add("cfg-pane-leaving");
    await new Promise(r => setTimeout(r, 140));
  }
  await renderCfgPane(pane);
  if (stage) stage.classList.remove("cfg-pane-leaving");
}

async function renderCfgPane(pane) {
  const stage = $("#cfg-pane-stage");
  const title = $("#cfg-main-title");
  if (!stage || !title) return;

  if (pane === "launch") {
    title.innerHTML = `🚀 Launch settings <span class="cfg-main-sub">recreates the container · world data preserved</span>`;
    stage.innerHTML = _renderLaunchPaneInner();
    _wireFieldEvents(stage);
  } else if (pane === "ports") {
    title.innerHTML = `🌐 Network ports <span class="cfg-main-sub">host:container mapping · recreates the container</span>`;
    stage.innerHTML = _renderPortsPaneInner();
    _wireFieldEvents(stage);
    _wirePortsExtras(stage);
  } else if (pane.startsWith("ini-")) {
    if (!cfgState.iniLoaded) {
      title.innerHTML = `⚙️ ${esc(cfgState.schema.file || "Config")}`;
      stage.innerHTML = `<div class="cfg-empty">Reading ${esc(cfgState.schema.file)}…</div>`;
      await _loadIniFileAsync();
    }
    if (!cfgState.iniParsed) return; // _loadIniFileAsync already rendered empty state
    const gi = parseInt(pane.split("-")[1], 10);
    const g = cfgState.schema.groups[gi];
    if (!g) return;
    if (g.kind === "sandbox") {
      title.innerHTML = `${esc(g.icon || "🎲")} ${esc(g.title)} <span class="cfg-main-sub">V3.0 SandboxCode · effective on next start</span>`;
      await renderSandboxPane(stage, gi);
      filterCfgPane($("#cfg-search").value || "");
      return;
    }
    title.innerHTML = `${esc(g.icon || "⚙️")} ${esc(g.title)} <span class="cfg-main-sub">${esc(cfgState.iniPath || cfgState.schema.file)} · effective on next start</span>`;
    stage.innerHTML = _renderIniPaneInner(gi);
    _wireFieldEvents(stage);
  }
  filterCfgPane($("#cfg-search").value || "");
}

// ===== 7DTD V3.0 SandboxCode panel =====
// Renders the 150 in-game Sandbox Options as grouped dropdowns + a paste-a-code
// field + an official-preset picker. All three converge on a single hidden
// SandboxCode input (data-cfg-prop="SandboxCode") that flows through the normal
// durable config-render/apply pipeline. Encode/decode go through the server
// codec (/api/7dtd/sandbox/*) — the panel never re-implements the encoding.
let _sandboxSchemaCache = null;
async function _getSandboxSchema() {
  if (_sandboxSchemaCache) return _sandboxSchemaCache;
  _sandboxSchemaCache = await fetchJSON("/api/7dtd/sandbox/schema");
  return _sandboxSchemaCache;
}

async function renderSandboxPane(stage, gi) {
  const g = cfgState.schema.groups[gi];
  // Live SandboxCode from the parsed serverconfig (what the server boots with).
  const live = (cfgState.iniParsed && cfgState.iniParsed.values) || {};
  const liveCode = (live.SandboxCode != null && live.SandboxCode !== "") ? String(live.SandboxCode) : "AAAJABJACJADJARFBNC";

  stage.innerHTML = `<div class="cfg-pane-desc">${g.description || ""}</div>
    <div class="cfg-empty">Loading sandbox options…</div>`;

  let schema, values;
  try {
    schema = await _getSandboxSchema();
    const dec = await fetchJSON("/api/7dtd/sandbox/decode", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ code: liveCode }),
    });
    values = dec.values || {};
  } catch (e) {
    stage.innerHTML = `<div class="cfg-empty"><div class="big">⚠</div>Couldn't load sandbox options<span class="hint">${esc((e && e.message) || e)}</span></div>`;
    return;
  }

  const presetOpts = (schema.presets || []).map(p =>
    `<option value="${esc(p.id)}"${p.code ? "" : " disabled"}>${esc(p.name)}${p.code ? "" : " (capture in-game)"}</option>`
  ).join("");

  // Build the category accordions. Each option = a labeled <select> over its
  // allowed values; non-default values get a dot. data-sbopt holds the option
  // name so the change handler can re-encode.
  const groupsHTML = (schema.categories || []).map((cat, ci) => {
    const rows = (cat.options || []).map(o => {
      const cur = (values[o.name] != null) ? String(values[o.name]) : o.default;
      const opts = o.values.map((v, vi) => {
        const lab = (o.labels && o.labels[vi]) ? `${_sbDisplay(o, v, vi)}` : _sbDisplay(o, v, vi);
        return `<option value="${esc(v)}"${v === cur ? " selected" : ""}>${esc(lab)}</option>`;
      }).join("");
      const changed = cur !== o.default;
      return `<div class="cfg-card sbopt-row${changed ? " sb-changed" : ""}" data-sbopt-name="${esc(o.name)}" data-sbopt-default="${esc(o.default)}">
        <label class="cfg-card-label">${esc(o.display)}<span class="sb-dot" title="changed from default"></span></label>
        <select class="cfg-input sbopt" data-sbopt="${esc(o.name)}">${opts}</select>
      </div>`;
    }).join("");
    return `<details class="sb-cat"${ci === 0 ? " open" : ""}>
      <summary>${esc(cat.icon || "⚙️")} ${esc(cat.title)} <span class="sb-cat-count">${(cat.options||[]).length}</span></summary>
      <div class="cfg-grid sb-grid">${rows}</div>
    </details>`;
  }).join("");

  stage.innerHTML = `
    <div class="cfg-pane-desc">${g.description || ""}</div>
    <input type="hidden" data-cfg-scope="ini" data-cfg-prop="SandboxCode" data-cfg-orig="${esc(liveCode)}" id="sb-code-input" value="${esc(liveCode)}">
    <div class="sb-toolbar">
      <div class="sb-tool">
        <label>Official preset</label>
        <select id="sb-preset"><option value="">— pick a preset —</option>${presetOpts}</select>
      </div>
      <div class="sb-tool sb-tool-grow">
        <label>Or paste a SandboxCode (from the in-game Sandbox Options → Copy Code)</label>
        <div class="sb-paste-row">
          <input type="text" id="sb-paste" class="cfg-input" placeholder="e.g. AAAJABJACJADJARFBNC" value="">
          <button class="btn btn-sm" id="sb-paste-apply" type="button">Apply code</button>
        </div>
      </div>
    </div>
    <div class="sb-codeline">Current code: <code id="sb-code-display">${esc(liveCode)}</code>
      <button class="btn btn-xs" id="sb-copy" type="button">Copy</button>
      <button class="btn btn-xs" id="sb-reset-def" type="button" title="Reset all 150 options to the game default">Reset to default</button>
    </div>
    ${groupsHTML}
  `;

  _wireFieldEvents(stage); // wires the hidden SandboxCode input into dirty tracking
  _wireSandboxControls(stage);
}

// _sbDisplay turns a raw value + its localization-key label into something
// readable. Many labels are xui/go-keys; for those we fall back to the raw
// value (a number/bool), which is meaningful enough. Pure values get a tidy
// percent/× hint for float multiplier sets.
function _sbDisplay(o, v, vi) {
  const lab = o.labels && o.labels[vi];
  if (lab && !/^(xui|go|lbl|none)/i.test(lab)) return lab; // human label
  if (o.type === "bool") return v === "true" ? "Yes" : "No";
  if (o.type === "float") {
    const f = parseFloat(v);
    if (!isNaN(f)) return (f === 1 ? "100% (default)" : Math.round(f * 100) + "%");
  }
  return v;
}

async function _recomputeSandboxCode(stage) {
  // Gather every option's current value, send to the server encoder.
  const vals = {};
  stage.querySelectorAll("select.sbopt").forEach(sel => { vals[sel.dataset.sbopt] = sel.value; });
  let code;
  try {
    const r = await fetchJSON("/api/7dtd/sandbox/encode", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ values: vals }),
    });
    code = r.code;
  } catch (e) { toast((e && e.message) || "Encode failed", "error"); return; }
  const hidden = stage.querySelector("#sb-code-input");
  const disp = stage.querySelector("#sb-code-display");
  if (disp) disp.textContent = code;
  if (hidden) { _writeCfgInputValue(hidden, code); hidden.dispatchEvent(new Event("change", {bubbles:true})); }
  // Refresh per-row changed dots.
  stage.querySelectorAll(".sbopt-row").forEach(row => {
    const sel = row.querySelector("select.sbopt");
    row.classList.toggle("sb-changed", sel && sel.value !== row.dataset.sboptDefault);
  });
}

function _wireSandboxControls(stage) {
  stage.querySelectorAll("select.sbopt").forEach(sel => {
    sel.addEventListener("change", () => _recomputeSandboxCode(stage));
  });
  // Paste a code → decode → set every dropdown → re-encode (round-trips through
  // the server so an invalid code is rejected cleanly).
  const pasteApply = stage.querySelector("#sb-paste-apply");
  if (pasteApply) pasteApply.addEventListener("click", async () => {
    const code = (stage.querySelector("#sb-paste").value || "").trim();
    if (!code) return;
    await _applySandboxCode(stage, code);
  });
  // Preset picker → use its code.
  const presetSel = stage.querySelector("#sb-preset");
  if (presetSel) presetSel.addEventListener("change", async () => {
    const id = presetSel.value; if (!id) return;
    const p = (_sandboxSchemaCache.presets || []).find(x => x.id === id);
    if (!p || !p.code) return;
    await _applySandboxCode(stage, p.code);
  });
  const resetDef = stage.querySelector("#sb-reset-def");
  if (resetDef) resetDef.addEventListener("click", async () => { await _applySandboxCode(stage, "AAAJABJACJADJARFBNC"); });
  const copy = stage.querySelector("#sb-copy");
  if (copy) copy.addEventListener("click", () => {
    const c = stage.querySelector("#sb-code-display").textContent || "";
    navigator.clipboard?.writeText(c); toast("Code copied", "ok");
  });
}

async function _applySandboxCode(stage, code) {
  let values;
  try {
    const dec = await fetchJSON("/api/7dtd/sandbox/decode", {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ code }),
    });
    values = dec.values || {};
  } catch (e) { toast((e && e.message) || "Invalid code", "error"); return; }
  stage.querySelectorAll("select.sbopt").forEach(sel => {
    const v = values[sel.dataset.sbopt];
    if (v != null && [...sel.options].some(o => o.value === String(v))) sel.value = String(v);
  });
  await _recomputeSandboxCode(stage);
  toast("Sandbox options updated from code", "ok");
}

function _renderLaunchPaneInner() {
  const inst = allInstances.find(x => x.instance_id === cfgState.instanceID);
  if (!inst) return `<div class="cfg-empty">Instance not found</div>`;
  const cv = inst.config_values || {};
  const fields = cfgState.envFields;
  // Fields may declare a `group` — consecutive runs of the same group get a
  // heading + their own grid (house rule: >10 fields need visual grouping;
  // soulmask ships 24). Ungrouped schemas render exactly as before.
  const runs = [];
  for (const f of fields) {
    const g = f.group || "";
    if (!runs.length || runs[runs.length - 1].group !== g) runs.push({ group: g, fields: [] });
    runs[runs.length - 1].fields.push(f);
  }
  const renderField = (f) => _renderUnifiedField({
        scope: "env",
        prop: f.prop,
        label: f.label,
        type: f.type,
        options: f.options,
        hint: f.hint,
        placeholder: f.placeholder,
        rows: f.rows,
        wide: f.wide || f.type === "textarea",
        wipe_paths: f.wipe_paths,
        // Show defaults as ghost placeholders, NOT pre-filled values —
        // operators see "what good values look like" without us writing
        // anything to config_values they didn't ask for.
        default: f.default,
        rawValue: cv[f.prop] != null && cv[f.prop] !== "" ? cv[f.prop] : "",
      });
  return `
    <div class="cfg-pane-desc">
      Container environment variables. Saving these <b>recreates</b> the container — saves and configs are preserved (volumes are kept). Players currently connected will be briefly disconnected.
    </div>
    ${runs.map(r => `
      ${r.group ? `<div class="cfg-rail-section-h" style="margin:14px 2px 8px">${esc(r.group)}</div>` : ""}
      <div class="cfg-grid">${r.fields.map(renderField).join("")}</div>
    `).join("")}
  `;
}

function _renderPortsPaneInner() {
  const inst = allInstances.find(x => x.instance_id === cfgState.instanceID);
  const portsCurrent = (inst && inst.ports) || {};
  if (!cfgState.ports.length) return `<div class="cfg-empty">No ports declared by this module.</div>`;
  const friendly = (p) => {
    const n = (p.name || "").toLowerCase();
    if (n === "game") return "Game traffic";
    if (n === "raw")  return "Raw / query";
    if (n === "rcon") return "RCON";
    if (n === "telnet") return "Telnet RCON";
    if (n === "battleye") return "BattlEye RCON";
    if (n === "reserved") return "Reserved";
    if (n === "query") return "Server query";
    if (n === "webadmin" || n === "web") return "Web admin";
    return p.name.charAt(0).toUpperCase() + p.name.slice(1);
  };
  const cardsHTML = cfgState.ports.map(p => {
    const cur = portsCurrent[p.name] != null && portsCurrent[p.name] !== "" ? portsCurrent[p.name] : (p.default != null ? p.default : "");
    const proto = (p.proto || "").toUpperCase();
    const protoColor = proto === "UDP" ? "var(--proto-udp,#a78bfa)" : "var(--proto-tcp,#60a5fa)";
    const id = `cfg-port-${p.name}`;
    const flags = [
      p.internal ? `<span class="port-chip port-chip-int" title="Bound to 127.0.0.1, not exposed publicly">🔒 internal</span>` : "",
      p.required ? `<span class="port-chip" title="Required by the game">required</span>` : "",
    ].filter(Boolean).join(" ");
    return `<div class="cfg-card" data-cfg-scope="port" data-cfg-prop="${esc(p.name)}">
      <div class="cfg-card-head">
        <label for="${id}" class="cfg-card-label" data-search-text="${esc(friendly(p) + " " + p.name + " " + (p.proto || ""))}">
          <span class="cfg-card-name">${esc(friendly(p))} <span class="port-proto" style="--proto-c:${protoColor};margin-left:4px">${esc(proto || "—")}</span></span>
          <span class="cfg-card-key" title="${esc(p.name)}">${esc(p.name)} · container :${esc(p.default != null ? p.default : "—")}</span>
        </label>
        <span class="cfg-card-dot"></span>
      </div>
      <div class="cfg-card-input">
        <input id="${id}" type="number" min="1" max="65535"
               value="${esc(cur)}"
               data-cfg-scope="port"
               data-cfg-prop="${esc(p.name)}"
               data-cfg-orig="${esc(cur)}"
               data-port="${esc(p.name)}"
               data-proto="${esc(p.proto || "")}"
               ${p.internal ? `disabled title="Bound to 127.0.0.1 only — fixed by manifest"` : ""}>
      </div>
      ${flags ? `<div class="cfg-card-hint">${flags}</div>` : ""}
    </div>`;
  }).join("");
  return `
    <div class="cfg-pane-desc">
      Host-side port forwarded to the container. Editing any value triggers <b>stop → recreate → start</b>. Port collisions are caught client-side as you type.
    </div>
    <div class="cfg-grid">${cardsHTML}</div>
    <div id="cfg-ports-warn" style="display:none;margin-top:12px" class="ports-warn">⚠ Two ports share the same host port — pick distinct values.</div>
  `;
}

function _wirePortsExtras(stage) {
  const refreshDup = () => {
    const inputs = Array.from(stage.querySelectorAll("[data-cfg-scope='port']"));
    const seen = new Map();
    inputs.forEach(inp => {
      const v = (inp.value || "").trim();
      if (!v) return;
      const key = `${v}|${(inp.dataset.proto || "").toLowerCase()}`;
      if (!seen.has(key)) seen.set(key, []);
      seen.get(key).push(inp);
    });
    let anyDup = false;
    inputs.forEach(inp => inp.classList.remove("dup"));
    for (const [, arr] of seen) {
      if (arr.length > 1) { anyDup = true; arr.forEach(inp => inp.classList.add("dup")); }
    }
    const warn = stage.querySelector("#cfg-ports-warn");
    if (warn) warn.style.display = anyDup ? "" : "none";
    cfgState.portsHaveDupes = anyDup;
    refreshCfgFooter();
  };
  stage.querySelectorAll("[data-cfg-scope='port']").forEach(inp => inp.addEventListener("input", refreshDup));
  refreshDup();
}

async function _loadIniFileAsync() {
  const schema = cfgState.schema;
  if (!schema) { cfgState.iniLoaded = true; return; }
  let content = null, usedPath = null, lastErr = null;
  for (const p of schema.filePaths) {
    try {
      const q = new URLSearchParams({ path: p });
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/files/read?${q}`);
      content = r.content; usedPath = p; break;
    } catch (e) { lastErr = e; }
  }
  cfgState.iniLoaded = true;
  if (content == null) {
    const msg = (lastErr && lastErr.message) || "";
    const isMissing = /no such file|not found|not a directory|cannot access|could not find|does not exist/i.test(msg);
    const inst = allInstances.find(x => x.instance_id === cfgState.instanceID);
    const isRunning = inst && inst.status === "running";
    const stage = $("#cfg-pane-stage");
    if (stage) {
      stage.innerHTML = isMissing
        ? `<div class="cfg-empty">
            <div class="big">📝</div>
            Configuration hasn't been generated yet.
            <span class="hint">Most game servers write their config files on first boot. ${isRunning ? "It's running now — give it a minute and reload." : "Click ▶ Start, let it initialize, then reload."}</span>
            <div class="dim" style="font-size:11.5px;margin-top:14px">Expected at: <code>${schema.filePaths.map(esc).join("</code> or <code>")}</code></div>
          </div>`
        : `<div class="cfg-empty"><div class="big">⚠</div>Couldn't load config file<span class="hint">${esc(msg || "unknown error")}</span></div>`;
    }
    cfgState.iniParsed = null;
    return;
  }
  let parsed;
  if (schema.format === "xml7dtd") parsed = parseXML7DTD(content);
  else if (schema.format === "ini_ark") parsed = parseINI(content);
  else if (schema.format === "ue_ini") parsed = parseUEIni(content);
  else if (schema.format === "ini_palworld") parsed = parsePalworldINI(content);
  else if (schema.format === "yaml_empyrion") parsed = parseEmpyrionYaml(content);
  else if (schema.format === "json") parsed = parseJSON(content);
  else if (schema.format === "cfg_rust") parsed = parseCfgRust(content);
  else if (schema.format === "cfg_dayz") parsed = parseCfgDayz(content);
  else if (schema.format === "xml_attrs") parsed = parseXmlAttrs(content, schema);
  else parsed = parseProperties(content);
  if (schema.format === "ue_ini") parsed._schemaSections = ueSchemaSections(schema);
  cfgState.iniParsed = parsed;
  cfgState.iniPath = usedPath;
}

function _renderIniPaneInner(gi) {
  const g = cfgState.schema.groups[gi];
  const v = cfgState.iniParsed.values || {};
  const fieldsHTML = g.fields.map(f => _renderUnifiedField({
    scope: "ini",
    prop: f.prop,
    label: f.label,
    type: f.type,
    options: f.options,
    hint: f.hint,
    placeholder: f.placeholder,
    rows: f.rows,
    min: f.min, max: f.max, step: f.step,
    wide: f.wide || f.type === "textarea" || f.type === "modlist",
    default: f.default,
    locked: f.locked,
    rawValue: v[f.prop] != null ? v[f.prop] : "",
  })).join("");
  const durableNote = cfgState.schema.durable
    ? `<div class="cfg-pane-desc" style="border-left:3px solid #34d399;background:rgba(52,211,153,.06)">💾 <b>Durable</b> — saving writes the panel's boot template too (no recreate, no disconnect). Changes apply on the next restart.</div>`
    : "";
  return `
    ${durableNote}
    ${g.description ? `<div class="cfg-pane-desc">${g.description}</div>` : ""}
    <div class="cfg-grid">${fieldsHTML}</div>
  `;
}

function _renderUnifiedField(f) {
  const id = `cfg-${f.scope}-${f.prop}`;
  const cur = f.rawValue == null ? "" : String(f.rawValue);
  // Bool: store normalized "true"/"false" so dirty-check works regardless
  // of the source-file casing (ARK uses True/False, properties use true/false).
  const origForData = (f.type === "bool") ? _cfgNormBool(cur) : cur;
  // locked: panel-owned field (port allocator / RCON password) — rendered
  // for visibility but disabled, so it can never go dirty.
  const dataAttrs = `data-cfg-scope="${esc(f.scope)}" data-cfg-prop="${esc(f.prop)}" data-cfg-orig="${esc(origForData)}"${f.locked ? " disabled" : ""}`;
  const wide = f.wide ? " wide" : "";

  // Ghost placeholder: when the field is empty, show the schema default
  // (or an explicit placeholder) in light italics so operators see
  // "what good values look like" without us pre-filling them.
  const placeholderText = (() => {
    if (f.placeholder) return f.placeholder;
    if (cur === "" && f.default != null && String(f.default).length) return String(f.default);
    return "";
  })();
  const phAttr = placeholderText ? ` placeholder="${esc(placeholderText)}"` : "";

  let ctrl = "";
  if (f.type === "bool") {
    const isOn = origForData === "true";
    ctrl = `<label class="toggle">
      <input id="${id}" type="checkbox" ${isOn ? "checked" : ""} ${dataAttrs}>
      <span class="track"></span>
      <span class="label-txt ${isOn ? 'on' : ''}" data-toggle-label>${isOn ? "Enabled" : "Disabled"}</span>
    </label>`;
  } else if (f.type === "select" || f.type === "enum") {
    const opts = (f.options || []).map(o => {
      const [val, label] = Array.isArray(o) ? o : [o, o];
      return `<option value="${esc(val)}"${val === cur ? " selected" : ""}>${esc(label)}</option>`;
    }).join("");
    ctrl = `<select id="${id}" ${dataAttrs}>${opts}</select>`;
  } else if (f.type === "number") {
    const attrs = [
      f.min != null ? `min="${esc(f.min)}"` : "",
      f.max != null ? `max="${esc(f.max)}"` : "",
      f.step != null ? `step="${esc(f.step)}"` : "",
    ].filter(Boolean).join(" ");
    ctrl = `<input id="${id}" type="number" value="${esc(cur)}"${phAttr} ${attrs} ${dataAttrs}>`;
  } else if (f.type === "password") {
    ctrl = `<input id="${id}" type="password" value="${esc(cur)}"${phAttr} autocomplete="new-password" ${dataAttrs}>`;
  } else if (f.type === "textarea") {
    const rows = f.rows || 4;
    ctrl = `<textarea id="${id}" rows="${rows}" spellcheck="false"${phAttr} ${dataAttrs}>${esc(cur)}</textarea>`;
  } else if (f.type === "modlist") {
    const ids = cur.split(",").map(s => s.trim()).filter(Boolean);
    const chips = ids.length ? `<div class="cfg-chips" data-cfg-chips="${esc(f.prop)}">${ids.map(x => `<span class="cfg-chip">${esc(x)}</span>`).join("")}</div>` : "";
    ctrl = `<textarea id="${id}" rows="3" spellcheck="false" placeholder="e.g. 928102, 1609138, 899987403" ${dataAttrs}>${esc(cur)}</textarea>${chips}`;
  } else {
    ctrl = `<input id="${id}" type="text" value="${esc(cur)}"${phAttr} ${dataAttrs}>`;
  }

  // Wipe-data warn renders as its own alert pill below the hint — never
  // inline with the hint text (which made the chip look like a misaligned
  // floating circle next to the label).
  const wipeWarn = (f.scope === "env" && Array.isArray(f.wipe_paths) && f.wipe_paths.length)
    ? `<div class="cfg-card-warn"><span class="warn-icon">⚠</span><span class="warn-text">Saving this may wipe related data — you'll get a checkbox in the Apply dialog</span></div>`
    : "";
  const searchText = (f.label || "") + " " + (f.prop || "") + " " + (f.hint || "");
  return `<div class="cfg-card${wide}" data-search-text="${esc(searchText)}">
    <div class="cfg-card-head">
      <label for="${id}" class="cfg-card-label">
        <span class="cfg-card-name">${esc(f.label || f.prop)}</span>
        <span class="cfg-card-key" title="${esc(f.prop)}">${esc(f.prop)}</span>
      </label>
      <span class="cfg-card-dot"></span>
    </div>
    <div class="cfg-card-input">${ctrl}</div>
    ${f.hint ? `<div class="cfg-card-hint">${f.hint}</div>` : ""}
    ${wipeWarn}
  </div>`;
}

function _wireFieldEvents(stage) {
  // Stagger card entrance — each card gets `--i` matching its DOM order,
  // capped so very long panes don't end up with multi-second tails.
  stage.querySelectorAll(".cfg-grid").forEach(grid => {
    const cards = grid.querySelectorAll(":scope > .cfg-card");
    cards.forEach((c, i) => c.style.setProperty("--i", Math.min(i, 24)));
  });
  stage.querySelectorAll("[data-cfg-scope][data-cfg-prop]").forEach(el => {
    if (!el.matches("input, select, textarea")) return;
    const handler = () => onCfgFieldChange(el);
    el.addEventListener("input", handler);
    el.addEventListener("change", handler);
  });
}

function _readCfgInputValue(el) {
  if (el.type === "checkbox") return el.checked ? "true" : "false";
  return el.value;
}

function _writeCfgInputValue(el, v) {
  if (el.type === "checkbox") {
    el.checked = (v === "true" || v === "1" || /^(true|1|yes|on)$/i.test(v || ""));
  } else {
    el.value = v;
  }
  // iOS-style toggle label flip
  const tog = el.closest(".toggle");
  if (tog) {
    const lab = tog.querySelector("[data-toggle-label]");
    if (lab) {
      const on = el.checked;
      lab.textContent = on ? "Enabled" : "Disabled";
      lab.classList.toggle("on", on);
    }
  }
}

function onCfgFieldChange(el) {
  const scope = el.dataset.cfgScope;
  const prop  = el.dataset.cfgProp;
  if (!scope || !prop) return;
  const orig = el.dataset.cfgOrig || "";
  const cur = _readCfgInputValue(el);
  const card = el.closest(".cfg-card");
  const k = _cfgKey(scope, prop);
  if (cur !== orig) {
    cfgState.dirty.set(k, { scope, prop, oldVal: orig, newVal: cur });
    if (card) card.setAttribute("data-dirty", "1");
  } else {
    cfgState.dirty.delete(k);
    if (card) card.removeAttribute("data-dirty");
  }
  // toggle label flip on checkbox change
  if (el.type === "checkbox") {
    const tog = el.closest(".toggle");
    if (tog) {
      const lab = tog.querySelector("[data-toggle-label]");
      if (lab) {
        const on = el.checked;
        lab.textContent = on ? "Enabled" : "Disabled";
        lab.classList.toggle("on", on);
      }
    }
  }
  // modlist: re-render chips below the textarea on change
  if (el.tagName === "TEXTAREA") {
    const chips = el.parentElement && el.parentElement.querySelector(`[data-cfg-chips="${CSS.escape(prop)}"]`);
    if (chips) {
      const ids = (el.value || "").split(",").map(s => s.trim()).filter(Boolean);
      chips.innerHTML = ids.map(x => `<span class="cfg-chip">${esc(x)}</span>`).join("");
    }
  }
  refreshCfgFooter();
}

function refreshCfgFooter() {
  const status = $("#cfg-foot-status");
  const reset  = $("#cfg-foot-reset");
  const apply  = $("#cfg-foot-apply");
  if (!status || !reset || !apply) return;
  const n = cfgState.dirty.size;

  // Per-rail dirty dots — set on the link whose pane owns each dirty field.
  const dirtyPanes = new Set();
  for (const d of cfgState.dirty.values()) {
    if (d.scope === "env")  dirtyPanes.add("launch");
    else if (d.scope === "port") dirtyPanes.add("ports");
    else if (d.scope === "ini" && cfgState.schema) {
      const gi = cfgState.schema.groups.findIndex(g => g.fields.some(f => f.prop === d.prop));
      if (gi >= 0) dirtyPanes.add("ini-" + gi);
    }
  }
  document.querySelectorAll("[data-cfg-rail-dirty]").forEach(el => {
    el.style.display = dirtyPanes.has(el.dataset.cfgRailDirty) ? "" : "none";
  });

  if (n === 0) {
    status.classList.remove("dirty");
    status.innerHTML = `No changes`;
    reset.disabled = true;
    apply.disabled = true;
    apply.textContent = "Apply";
    return;
  }
  status.classList.add("dirty");
  const byScope = { env: 0, port: 0, ini: 0 };
  for (const d of cfgState.dirty.values()) byScope[d.scope] = (byScope[d.scope] || 0) + 1;
  const bits = [];
  if (byScope.env)  bits.push(`${byScope.env} launch`);
  if (byScope.port) bits.push(`${byScope.port} port${byScope.port === 1 ? "" : "s"}`);
  if (byScope.ini)  bits.push(`${byScope.ini} setting${byScope.ini === 1 ? "" : "s"}`);
  status.innerHTML = `<span class="cfg-foot-pip"></span>${n} change${n === 1 ? "" : "s"} pending <span class="dim" style="margin-left:6px">(${bits.join(", ")})</span>`;
  reset.disabled = false;
  apply.disabled = !!cfgState.portsHaveDupes;
  apply.textContent = `Apply ${n} change${n === 1 ? "" : "s"}`;
}

function resetAllCfg() {
  document.querySelectorAll("[data-cfg-scope][data-cfg-prop]").forEach(el => {
    if (!el.matches("input, select, textarea")) return;
    const orig = el.dataset.cfgOrig || "";
    _writeCfgInputValue(el, orig);
    onCfgFieldChange(el);
  });
}

function filterCfgPane(query) {
  const stage = $("#cfg-pane-stage");
  if (!stage) return;
  const q = (query || "").trim().toLowerCase();
  stage.querySelectorAll(".cfg-card").forEach(card => {
    if (!q) { card.removeAttribute("data-filter-hide"); return; }
    const text = ((card.dataset.searchText || "") + " " + (card.textContent || "")).toLowerCase();
    if (!text.includes(q)) card.setAttribute("data-filter-hide", "1");
    else card.removeAttribute("data-filter-hide");
  });
}

async function applyAllCfg() {
  const dirty = Array.from(cfgState.dirty.values());
  if (!dirty.length) return;
  if (cfgState.portsHaveDupes) { toast("Port collision — fix duplicates first", "err"); return; }

  const apply  = $("#cfg-foot-apply");
  const reset  = $("#cfg-foot-reset");
  const status = $("#cfg-foot-status");
  const setProgress = (msg) => { if (status) status.innerHTML = `<span class="cfg-foot-pip"></span>${esc(msg)}`; };

  const envChanges  = dirty.filter(d => d.scope === "env");
  const portChanges = dirty.filter(d => d.scope === "port");
  const iniChanges  = dirty.filter(d => d.scope === "ini");

  // Confirm env-config changes (with wipe-path opt-ins) up front, before touching anything.
  let confirmEnv = null;
  if (envChanges.length) {
    const wipeOptions = [];
    const seen = new Set();
    for (const d of envChanges) {
      const f = cfgState.envFields.find(x => x.prop === d.prop);
      if (f && Array.isArray(f.wipe_paths) && f.wipe_paths.length) {
        const key = f.wipe_paths.join(",");
        if (seen.has(key)) continue; seen.add(key);
        wipeOptions.push({
          paths: f.wipe_paths,
          label: f.wipe_label || `Wipe related data (${key})`,
          hint:  f.wipe_hint  || "",
          fromProp: d.prop,
        });
      }
    }
    confirmEnv = await openEnvConfigConfirmBatch({
      changes: envChanges.map(d => {
        const f = cfgState.envFields.find(x => x.prop === d.prop);
        return { prop: d.prop, oldVal: d.oldVal, newVal: d.newVal, label: (f && f.label) || d.prop };
      }),
      wipeOptions,
    });
    if (!confirmEnv) return;
  }

  apply.disabled = true;
  reset.disabled = true;

  try {
    // 1. INI write — fast, no recreate.
    if (iniChanges.length) {
      const sch = cfgState.schema;

      // 1a. Durable modules (7dtd): the entrypoint re-stamps the live file
      // from the panel-rendered boot template on EVERY boot, so a live-file
      // write alone silently reverts on the next restart. Push the change
      // through /config-render FIRST — it merges into config_values and
      // re-renders the boot template on the agent (NO recreate; applies on
      // the next restart). We pin EVERY template-mapped field at its
      // current live-file value (what the editor shows), overlaid with the
      // operator's edits: that reconciles any drift between config_values
      // and the live file, so a re-render can never revert settings a
      // long-lived server (Season 10!) was already running with.
      if (sch && sch.durable) {
        setProgress("Saving to boot template…");
        const tmplUpdates = {};
        const live = (cfgState.iniParsed && cfgState.iniParsed.values) || {};
        for (const g of sch.groups) for (const f of g.fields) {
          if (!f.tmplKey || f.locked) continue;
          let val = live[f.prop];
          if (val == null || val === "") continue;
          if (f.type === "bool") val = _cfgNormBool(val);
          tmplUpdates[f.tmplKey] = String(val);
        }
        for (const d of iniChanges) {
          let f = null;
          for (const g of sch.groups) { f = g.fields.find(x => x.prop === d.prop) || f; }
          if (f && f.tmplKey) tmplUpdates[f.tmplKey] = d.newVal; // "" clears → template default
        }
        if (Object.keys(tmplUpdates).length) {
          await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/config-render`, {
            method: "POST", headers: {"Content-Type":"application/json"},
            body: JSON.stringify({ updates: tmplUpdates }),
          });
        }
      }

      setProgress(`Writing ${cfgState.iniPath || cfgState.schema.file}…`);
      const updates = {};
      const isArkIni = cfgState.schema && (cfgState.schema.format === "ini_ark" || cfgState.schema.format === "ue_ini");
      for (const d of iniChanges) {
        let v = d.newVal;
        if (isArkIni && (v === "true" || v === "false")) v = v === "true" ? "True" : "False";
        updates[d.prop] = v;
      }
      let newContent;
      if (sch.format === "xml7dtd") newContent = serializeXML7DTD(cfgState.iniParsed, updates);
      else if (sch.format === "ini_ark") newContent = serializeINI(cfgState.iniParsed, updates);
      else if (sch.format === "ue_ini") newContent = serializeUEIni(cfgState.iniParsed, updates);
      else if (sch.format === "ini_palworld") newContent = serializePalworldINI(cfgState.iniParsed, updates);
      else if (sch.format === "yaml_empyrion") newContent = serializeEmpyrionYaml(cfgState.iniParsed, updates);
      else if (sch.format === "json") newContent = serializeJSON(cfgState.iniParsed, updates);
      else if (sch.format === "cfg_rust") newContent = serializeCfgRust(cfgState.iniParsed, updates);
      else if (sch.format === "cfg_dayz") newContent = serializeCfgDayz(cfgState.iniParsed, updates);
      else if (sch.format === "xml_attrs") newContent = serializeXmlAttrs(cfgState.iniParsed, updates);
      else newContent = serializeProperties(cfgState.iniParsed, updates);
      await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/files`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ path: cfgState.iniPath, content: newContent }),
      });
    }

    // 2. Ports — recreates container.
    if (portChanges.length) {
      setProgress(`Recreating with new ports…`);
      const entries = [];
      for (const p of cfgState.ports) {
        const el = document.querySelector(`[data-port="${CSS.escape(p.name)}"]`);
        if (!el) continue;
        const v = parseInt(el.value, 10);
        if (!Number.isFinite(v) || v < 1 || v > 65535) throw new Error(`Invalid port for ${p.name}`);
        entries.push({
          name: p.name,
          proto: p.proto || "tcp",
          container_port: Number(p.default) || v,
          host_port: v,
          internal: !!p.internal,
        });
      }
      await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/ports`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ ports: entries }),
      });
    }

    // 3. Env-config — recreates container (after ports so the new ports are
    // already in the DB row; backend env-config recreate uses row.AssignedPorts).
    if (envChanges.length) {
      setProgress(`Recreating with new launch settings…`);
      const updates = {};
      for (const d of envChanges) updates[d.prop] = d.newVal;
      if (confirmEnv && confirmEnv.wipePaths && confirmEnv.wipePaths.length) {
        updates["_PANEL_WIPE_PATHS"] = confirmEnv.wipePaths.join(",");
      }
      await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/env-config`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ updates }),
      });
    }

    if (iniChanges.length && cfgState.schema && cfgState.schema.durable && !envChanges.length && !portChanges.length) {
      toast(`${dirty.length} setting${dirty.length === 1 ? "" : "s"} saved — applies on next restart, no recreate needed`, "ok");
    } else {
      toast(`${dirty.length} change${dirty.length === 1 ? "" : "s"} applied`, "ok");
    }
    setProgress("Reloading…");
    cfgState.dirty.clear();
    pollInstances();
    loadInstanceConfig();
  } catch (err) {
    toast(`Apply failed: ${err.message}`, "err");
    apply.disabled = false;
    reset.disabled = false;
    refreshCfgFooter();
  }
}

$("#im-cfg-reload").addEventListener("click", loadInstanceConfig);

// ---------------- PLAYERS tab ----------------
function loadInstancePlayers() {
  const id = instanceModalState.id; if (!id) return;
  refreshInstancePlayers();
  if (instanceModalState.playersTimer) clearInterval(instanceModalState.playersTimer);
  instanceModalState.playersTimer = setInterval(refreshInstancePlayers, 5000);
}
async function refreshInstancePlayers() {
  const id = instanceModalState.id; const wrap = $("#im-p-wrap"); if (!id || !wrap) return;
  const i = allInstances.find(x => x.instance_id === id);
  const appSt = latestAppState[id] || {};
  const running = i && i.status === "running";

  // Headline card: count + cap.
  const countText = appSt.players_online !== undefined
    ? `<span style="font-size:28px;font-weight:700">${appSt.players_online}</span><span class="dim"> / ${appSt.players_max}</span>`
    : running ? `<span class="dim">no data yet — RCON may still be connecting</span>` : `<span class="dim">server is stopped</span>`;

  if (!running) {
    wrap.innerHTML = `<div class="empty"><div class="big">${EMPTY_ICONS.users}</div>Server isn't running<span class="hint">Start the server to see connected players</span></div>`;
    return;
  }

  // First paint: show a loading state immediately. The roster RCON call
  // below can take seconds (or hang) — without this the tab sits BLANK
  // until the await resolves, which reads as a broken feature.
  if (!wrap.firstElementChild) wrap.innerHTML = loadingState("Fetching roster…");

  // Empyrion has no in-game RCON player-list command — its player roster
  // comes from the panel-empyrion-bridge sidecar instead, with full
  // metadata + per-row admin actions (give item, set role, +1k credits,
  // teleport, etc). Render that here so the regular Players tab is the
  // single source of truth — no need to hop into the Empyrion-tools tab
  // just to see who's online or kick someone.
  if (i && i.module_id === "empyrion") {
    return renderEAHPlayers(wrap);
  }

  // Pull the live list via RCON "lp" (7DTD) / "list" (Minecraft) etc.
  const listCmd = playerListCommandFor(i.module_id);
  let listErr = "", listText = "";
  if (listCmd) {
    try {
      const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ command: listCmd }),
        // A wedged RCON tracker must not hang the tab forever.
        signal: AbortSignal.timeout(8000),
      });
      const data = await r.json();
      if (r.ok && !data.error) listText = data.output || "";
      else listErr = data.error || data.message || `HTTP ${r.status}`;
    } catch (e) { listErr = e.name === "TimeoutError" ? "RCON timed out after 8s — the server may still be starting, or its telnet/RCON link is wedged." : e.message; }
  } else {
    listErr = "No list-players command wired up for this module yet.";
  }
  const parsed = parsePlayerList(i.module_id, listText);

  // App-state count when the tracker has one; otherwise the parsed roster
  // itself is the source of truth (7dtd's tracker count can lag or be
  // absent right after a controller restart).
  const online = appSt.players_online !== undefined ? appSt.players_online : (parsed.length || undefined);
  const cap = appSt.players_max;
  const pct = (online !== undefined && cap) ? Math.min(100, Math.round((online / cap) * 100)) : null;
  // Deterministic accent per player name so avatars are visually distinct.
  const hueFor = (s) => { let h = 0; for (const c of (s||"")) h = (h*31 + c.charCodeAt(0)) >>> 0; return h % 360; };
  const initial = (s) => (s||"?").trim().charAt(0).toUpperCase() || "?";
  const canKick = i && i.module_id === "7dtd"; // RCON kick wired for 7DTD today

  wrap.innerHTML = `
    <div class="pl-hero">
      <div class="pl-hero-count">
        <span class="pl-hero-num">${online !== undefined ? online : "—"}</span>
        <span class="pl-hero-cap">${cap ? "/ " + cap : ""}</span>
        <span class="pl-hero-lbl">players online</span>
      </div>
      ${pct !== null ? `<div class="pl-hero-bar"><div class="pl-hero-fill" style="width:${pct}%"></div></div><div class="pl-hero-pct">${pct}% full</div>` : `<div class="dim" style="margin-left:auto">${online === undefined ? "no data yet — RCON may still be connecting" : ""}</div>`}
    </div>
    <div class="pl-roster" id="im-p-roster">
      ${listErr && !parsed.length
        ? `<div class="empty"><div class="big">${EMPTY_ICONS.antenna}</div>Couldn't read the roster<span class="hint">${esc(listErr)}</span></div>`
        : parsed.length
          ? parsed.map(p => `
            <div class="pl-card">
              <span class="pl-avatar" style="--ph:${hueFor(p.name)}">${esc(initial(p.name))}</span>
              <div class="pl-card-main">
                <div class="pl-card-name">${esc(p.name || "—")}</div>
                <div class="pl-card-meta">${p.id ? `<span class="mono">${esc(p.id)}</span>` : ""}${p.extra ? `<span class="pl-card-extra">${esc(p.extra)}</span>` : ""}</div>
              </div>
              ${canKick && p.name ? `<button class="pl-kick" data-kick-player="${esc(p.name)}" title="Kick ${esc(p.name)}">Kick</button>` : ""}
            </div>`).join("")
          : `<div class="empty"><div class="big">${EMPTY_ICONS.moon}</div>No players connected<span class="hint">The roster updates every 5s while this tab is open.</span></div>`}
    </div>
    ${listText && !parsed.length ? `<details style="margin-top:14px"><summary class="dim" style="cursor:pointer">RCON raw output</summary>
      <pre class="mono" style="padding:10px;background:var(--surface-2);border-radius:var(--radius-sm);font-size:11.5px;max-height:240px;overflow:auto">${esc(listText)}</pre></details>` : ""}
  `;
}
$("#im-p-refresh").addEventListener("click", refreshInstancePlayers);

// Kick a player from the roster (7DTD today). Confirms, fires the RCON
// kick, then refreshes the list. Delegated since rows are re-rendered.
$("#im-p-wrap").addEventListener("click", async (e) => {
  const btn = e.target.closest("[data-kick-player]");
  if (!btn) return;
  const name = btn.dataset.kickPlayer;
  const id = instanceModalState.id; if (!id) return;
  const ok = await (typeof themedConfirm === "function"
    ? themedConfirm({ title: "Kick player", body: `Kick "${name}" from the server?`, confirmLabel: "Kick", danger: true })
    : Promise.resolve(confirm(`Kick "${name}"?`)));
  if (!ok) return;
  btn.disabled = true; btn.textContent = "Kicking…";
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ command: `kick "${name}" "Kicked by an admin"` }),
    });
    const data = await r.json().catch(() => ({}));
    if (!r.ok || data.error) throw new Error(data.error || data.message || `HTTP ${r.status}`);
    toast(`Kicked ${name}`, "ok");
    setTimeout(refreshInstancePlayers, 600);
  } catch (err) {
    toast("Kick failed: " + err.message, "err");
    btn.disabled = false; btn.textContent = "Kick";
  }
});

function playerListCommandFor(moduleID) {
  return {
    "7dtd": "lp",
    "minecraft-java": "list",
    "valheim": "",   // no in-game RCON list
    "rust": "players",
    "ark": "listplayers",
    "ark-sa": "listplayers",
    "terraria": "playing",
  }[moduleID] || "";
}
// Best-effort parser — returns array of {name,id,extra}. Works for the
// common games; falls through to an empty array (UI shows raw text).
function parsePlayerList(moduleID, text) {
  if (!text) return [];
  const out = [];
  if (moduleID === "7dtd") {
    // Old format: "1. id=171, name=Pete, pos=(...), ..., steamid=7656..."
    // V2/V3 format: "0. id=401303, Strongheart, pos=(...), ..., level=31,
    //                pltfmid=Steam_7656..., ..., ping=47" — bare name, no
    // name= prefix, steamid moved into pltfmid=Steam_.
    const lineRe = /^\s*\d+\.\s+id=(\d+),\s+(?:name=)?([^,]+),/;
    for (const line of text.split(/\r?\n/)) {
      const m = line.match(lineRe);
      if (!m) continue;
      const steam = (line.match(/(?:steamid=|pltfmid=Steam_)(\d+)/) || [])[1];
      const level = (line.match(/\blevel=(\d+)/) || [])[1];
      const ping  = (line.match(/\bping=(\d+)/) || [])[1];
      const extra = [level ? `lvl ${level}` : "", ping ? `${ping}ms` : ""].filter(Boolean).join(" · ");
      out.push({ name: m[2].trim(), id: steam || m[1], extra });
    }
  } else if (moduleID === "minecraft-java") {
    // "There are 2 of a max of 20 players online: Alice, Bob"
    const m = text.match(/players online:\s*(.+)$/mi);
    if (m) { for (const n of m[1].split(",").map(s => s.trim()).filter(Boolean)) out.push({ name: n, id: "", extra: "" }); }
  } else if (moduleID === "ark" || moduleID === "ark-sa") {
    // "0. Pete, 76561198...."
    const re = /^\s*\d+\.\s+(.+?),\s*(\d+)\s*$/gm;
    let m; while ((m = re.exec(text)) != null) out.push({ name: m[1].trim(), id: m[2], extra: "" });
  } else if (moduleID === "rust") {
    // rust "players" has a header + "Pete : 192.168... : 0s : healthy"
    for (const line of text.split(/\r?\n/)) {
      const mm = line.match(/^(\S.+?)\s*:\s*([\d.:a-f]+)/);
      if (mm) out.push({ name: mm[1].trim(), id: mm[2], extra: "" });
    }
  } else if (moduleID === "palworld") {
    // CSV with header: "name,playeruid,steamid" then one row per player.
    for (const line of text.split(/\r?\n/).slice(1)) {
      const cols = line.split(",");
      if (cols.length >= 3 && cols[0].trim()) {
        out.push({ name: cols[0].trim(), id: cols[2].trim(), extra: "uid:" + cols[1].trim() });
      }
    }
  } else if (moduleID === "rust") {
    // Rust's `playerlist` RCON returns a JSON array. Empty server → "[]".
    // Populated → [{"SteamID":"76561...","OwnerSteamID":"76561...",
    //              "DisplayName":"Player","Ping":42,"Address":"ip:port",
    //              "ConnectedSeconds":123, ...}, ...]
    try {
      const arr = JSON.parse(text);
      if (Array.isArray(arr)) {
        for (const p of arr) {
          if (!p || typeof p !== "object") continue;
          out.push({
            name: p.DisplayName || p.UserName || p.Name || "unknown",
            id:   p.SteamID || p.UserID || "",
            extra: p.Ping != null ? (p.Ping + "ms") : "",
          });
        }
      }
    } catch (_) { /* fallthrough — raw text shown */ }
  } else if (moduleID === "conan-exiles") {
    // Conan's `listplayers` prints a pipe-separated table with columns like
    //   Idx | Char name | Player name | User ID | Platform ID | Platform Type | Server | AFK
    // Skip lines that look like the header or dividers; keep data rows.
    for (const line of text.split(/\r?\n/)) {
      if (!line.includes("|")) continue;
      const cols = line.split("|").map(s => s.trim());
      if (cols.length < 5) continue;
      if (/^idx$/i.test(cols[0]) || /^-+$/.test(cols[0])) continue; // header / divider
      if (!/^\d+$/.test(cols[0])) continue;                         // non-numeric first col → skip
      out.push({ name: cols[1] || cols[2], id: cols[4] || cols[3], extra: cols[5] || "" });
    }
  }
  return out;
}

async function loadConfigForm(instanceID, schema, wrap) {
  wrap.innerHTML = `<div class="dim">Reading ${esc(schema.file)}…</div>`;
  // Try the declared file paths in order. Files API is relative to the
  // instance's browseable_root so one of them should hit.
  let content = null, usedPath = null, lastErr = null;
  for (const p of schema.filePaths) {
    try {
      const q = new URLSearchParams({ path: p });
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files/read?${q}`);
      content = r.content; usedPath = p; break;
    } catch (e) { lastErr = e; }
  }
  if (content == null) {
    // Most modules generate their config on first boot (ARK writes
    // GameUserSettings.ini only after the game initializes). If the
    // underlying agent error looks like "not found" we show a friendly
    // "boot me first" state rather than a scary error.
    const msg = (lastErr && lastErr.message) || "";
    // Normalise across different underlying readers:
    // - GNU `ls` says "cannot access", "No such file or directory"
    // - `docker cp` says "Could not find the file" / "path does not exist"
    // - Our FsRead fallback says "not a directory" / "not found"
    const isMissing = /no such file|not found|not a directory|cannot access|could not find|does not exist/i.test(msg);
    const i = allInstances.find(x => x.instance_id === instanceID);
    const isRunning = i && i.status === "running";
    if (isMissing) {
      wrap.innerHTML = `<div class="empty">
        <div class="big">📝</div>
        Configuration hasn't been generated yet.
        <span class="hint">Most game servers write their config files on first boot. Start the server once, let it initialize (${isRunning ? "it's running now — may take a minute" : "click ▶ Start"}), then come back here.</span>
        <div class="dim" style="font-size:11.5px;margin-top:14px">Expected at: <code>${schema.filePaths.map(esc).join("</code> or <code>")}</code></div>
      </div>`;
    } else {
      wrap.innerHTML = `<div class="empty">Couldn't load config file.<span class="hint">${esc(msg || "unknown error")}. Tried: <code>${schema.filePaths.map(esc).join("</code>, <code>")}</code></span></div>`;
    }
    return;
  }
  // Parse the file into a flat {key: value} map. Parsers preserve the raw
  // text so Save writes back only the fields we touched.
  let parsed;
  if (schema.format === "xml7dtd") parsed = parseXML7DTD(content);
  else if (schema.format === "ini_ark") parsed = parseINI(content);
  else if (schema.format === "ue_ini") parsed = parseUEIni(content);
  else if (schema.format === "ini_palworld") parsed = parsePalworldINI(content);
  else if (schema.format === "yaml_empyrion") parsed = parseEmpyrionYaml(content);
  else if (schema.format === "json") parsed = parseJSON(content);
  else if (schema.format === "cfg_rust") parsed = parseCfgRust(content);
  else if (schema.format === "cfg_dayz") parsed = parseCfgDayz(content);
  else if (schema.format === "xml_attrs") parsed = parseXmlAttrs(content, schema);
  else parsed = parseProperties(content);
  if (schema.format === "ue_ini") parsed._schemaSections = ueSchemaSections(schema);
  renderConfigForm(instanceID, schema, usedPath, parsed, wrap);
}

function renderConfigForm(instanceID, schema, filePath, parsed, wrap) {
  // Unknown-field list — anything in the file not in our schema.
  const known = new Set();
  for (const g of schema.groups) for (const f of g.fields) known.add(f.prop);
  const extras = Object.keys(parsed.values).filter(k => !known.has(k)).sort();

  // Each schema group becomes a sub-tab at the top of the Config tab.
  // Fits 10+ grouped sections without a mile of scrolling.
  const navHTML = schema.groups.map((g, gi) =>
    `<button class="cfg-tab${gi === 0 ? ' active' : ''}" data-cfg-group="${gi}">${g.icon ? esc(g.icon) + " " : ""}${esc(g.title)}</button>`
  ).join("");

  const panesHTML = schema.groups.map((g, gi) => `
    <div class="im-cfg-pane${gi === 0 ? ' active' : ''}" data-cfg-pane="${gi}">
      ${g.description ? `<div class="im-cfg-pane-desc">${g.description}</div>` : ""}
      <div class="im-cfg-fields">
        ${g.fields.map(f => renderConfigField(f, parsed.values[f.prop])).join("")}
      </div>
    </div>`).join("");

  wrap.innerHTML = `
    <div class="dim" style="font-size:12px;margin-bottom:12px">Editing <code>${esc(filePath)}</code>. Unknown keys are preserved verbatim.</div>
    <nav class="im-cfg-nav" id="im-cfg-nav">${navHTML}</nav>
    <div id="im-cfg-panes">${panesHTML}</div>
    ${extras.length ? `<details style="margin-top:18px"><summary class="dim" style="cursor:pointer;font-size:12px">Show ${extras.length} other keys in the file (not exposed as form fields — saved verbatim)</summary>
      <pre class="mono" style="padding:12px;background:var(--bg);border-radius:var(--radius-pill);font-size:11.5px;max-height:240px;overflow:auto;margin-top:8px">${esc(extras.map(k => k + " = " + parsed.values[k]).join("\n"))}</pre>
    </details>` : ""}
    <div style="margin-top:18px;display:flex;gap:10px;align-items:center;padding:14px 0;border-top:1px solid var(--border-soft)">
      <button class="primary" id="im-cfg-save">💾 Save changes</button>
      <span class="dim" style="font-size:12px">Changes take effect on next server start.</span>
    </div>`;

  // Sub-tab switching.
  $("#im-cfg-nav").addEventListener("click", e => {
    const t = e.target.closest(".cfg-tab"); if (!t) return;
    const gi = t.dataset.cfgGroup;
    $$("#im-cfg-nav .cfg-tab").forEach(x => x.classList.toggle("active", x === t));
    $$("#im-cfg-panes .im-cfg-pane").forEach(p => p.classList.toggle("active", p.dataset.cfgPane === gi));
  });

  $("#im-cfg-save").addEventListener("click", async () => {
    const updates = {};
    for (const g of schema.groups) for (const f of g.fields) {
      const el = document.getElementById("cfg_" + f.prop);
      if (!el) continue;
      let v;
      if (f.type === "bool") {
        // ARK + UE INI use True/False; most other configs use true/false;
        // XML7DTD uses true/false. Capitalize based on format.
        v = (schema.format === "ini_ark" || schema.format === "ue_ini")
          ? (el.checked ? "True" : "False")
          : (el.checked ? "true" : "false");
      } else v = el.value;
      updates[f.prop] = v;
    }
    let newContent;
    if (schema.format === "xml7dtd") newContent = serializeXML7DTD(parsed, updates);
    else if (schema.format === "ini_ark") newContent = serializeINI(parsed, updates);
    else if (schema.format === "ue_ini") newContent = serializeUEIni(parsed, updates);
    else if (schema.format === "ini_palworld") newContent = serializePalworldINI(parsed, updates);
    else if (schema.format === "yaml_empyrion") newContent = serializeEmpyrionYaml(parsed, updates);
    else if (schema.format === "json") newContent = serializeJSON(parsed, updates);
    else if (schema.format === "cfg_rust") newContent = serializeCfgRust(parsed, updates);
    else if (schema.format === "cfg_dayz") newContent = serializeCfgDayz(parsed, updates);
    else if (schema.format === "xml_attrs") newContent = serializeXmlAttrs(parsed, updates);
    else newContent = serializeProperties(parsed, updates);
    try {
      await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ path: filePath, content: newContent }),
      });
      toast("Config saved — restart the server to apply", "ok");
    } catch (err) { toast("Save failed: " + err.message, "err"); }
  });
}

function renderConfigField(f, rawVal) {
  const id = "cfg_" + f.prop;
  const cur = rawVal == null ? "" : String(rawVal);
  const hint = f.hint ? `<div class="dim" style="font-size:11px;margin-top:3px">${esc(f.hint)}</div>` : "";
  let ctrl = "";
  if (f.type === "bool") {
    const isOn = /^(true|1|yes|on)$/i.test(cur);
    ctrl = `<label class="toggle">
      <input type="checkbox" id="${id}" ${isOn ? "checked" : ""}>
      <span class="track"></span>
      <span class="label-txt ${isOn ? 'on' : ''}" data-toggle-label>${isOn ? "Enabled" : "Disabled"}</span>
    </label>`;
  } else if (f.type === "number") {
    const attrs = [`min="${f.min ?? ""}"`, `max="${f.max ?? ""}"`, f.step ? `step="${f.step}"` : ""].filter(Boolean).join(" ");
    ctrl = `<input id="${id}" type="number" value="${esc(cur)}" ${attrs}>`;
  } else if (f.type === "enum") {
    const opts = f.options.map(o => {
      const [val, label] = Array.isArray(o) ? o : [o, o];
      return `<option value="${esc(val)}"${val === cur ? " selected" : ""}>${esc(label)}</option>`;
    }).join("");
    ctrl = `<select id="${id}">${opts}</select>`;
  } else if (f.type === "password") {
    ctrl = `<input id="${id}" type="password" value="${esc(cur)}" autocomplete="off">`;
  } else if (f.type === "textarea") {
    const rows = f.rows || 4;
    ctrl = `<textarea id="${id}" rows="${rows}" spellcheck="false" placeholder="${esc(f.placeholder || "")}">${esc(cur)}</textarea>`;
  } else if (f.type === "modlist") {
    // Mod IDs — comma-separated list. Render as a textarea for easy editing
    // with chips below showing the parsed entries for visual feedback.
    const ids = cur.split(",").map(s => s.trim()).filter(Boolean);
    const chips = ids.length
      ? `<div class="cfg-chips">${ids.map(id => `<span class="cfg-chip">${esc(id)}</span>`).join("")}</div>`
      : "";
    ctrl = `<textarea id="${id}" rows="3" spellcheck="false" placeholder="e.g. 928102, 1609138, 899987403">${esc(cur)}</textarea>${chips}`;
  } else {
    ctrl = `<input id="${id}" type="text" value="${esc(cur)}">`;
  }
  const wide = (f.type === "textarea" || f.type === "modlist" || f.wide) ? " wide" : "";
  // Friendly name on its own line, mono config-key on the line below.
  // CSS handles the truncation; `title=` on the key span lets a hover
  // tooltip show the full key for the rare cases where it doesn't fit
  // even the column width. Keeps "AdminPasswordsForServerSettings" from
  // shoving the input off-screen.
  return `<div class="im-cfg-field${wide}">
    <label for="${id}">
      <span class="name-line" title="${esc(f.label)}">${esc(f.label)}</span>
      <span class="mono dim" title="${esc(f.prop)}">${esc(f.prop)}</span>
    </label>
    ${ctrl}
    ${hint}
  </div>`;
}

// ---- parsers: properties (MC) ----
// ---- parsers: generic JSON with dotted-path flattening ----
// Flattens nested objects so the schema can target e.g.
// "ServerDescription_Persistent.InviteCode". Arrays and primitives land as
// leaves; unknown keys in the file are preserved on save because we
// structuredClone the original tree and only overwrite the paths in
// `updates`. Output is tab-indented to match the style Windrose itself
// writes ServerDescription.json with.
function parseJSON(text) {
  let root = {};
  try { root = JSON.parse(text); } catch { /* tolerate empty/garbage, form just shows blanks */ }
  const values = {};
  const walk = (node, prefix) => {
    if (node === null || node === undefined) { if (prefix) values[prefix] = ""; return; }
    if (Array.isArray(node) || typeof node !== "object") {
      values[prefix] = Array.isArray(node) ? JSON.stringify(node) : String(node);
      return;
    }
    for (const k of Object.keys(node)) walk(node[k], prefix ? prefix + "." + k : k);
  };
  walk(root, "");
  return { root, values };
}
function serializeJSON(parsed, updates) {
  // structuredClone so we keep the original as a reference for type coercion.
  const out = structuredClone(parsed.root ?? {});
  for (const [dotted, rawVal] of Object.entries(updates)) {
    const path = dotted.split(".");
    let node = out;
    let ref = parsed.root ?? {};
    for (let i = 0; i < path.length - 1; i++) {
      const k = path[i];
      if (typeof node[k] !== "object" || node[k] === null || Array.isArray(node[k])) node[k] = {};
      node = node[k];
      ref = (ref && typeof ref === "object") ? ref[k] : undefined;
    }
    const leaf = path[path.length - 1];
    const prior = ref && typeof ref === "object" ? ref[leaf] : undefined;
    // Preserve the original JSON type where possible — the Windrose client
    // parses MaxPlayerCount etc. as numbers and refuses string-typed ones.
    if (typeof prior === "number") {
      const n = Number(rawVal);
      node[leaf] = Number.isFinite(n) ? n : prior;
    } else if (typeof prior === "boolean") {
      node[leaf] = /^(true|1|yes|on)$/i.test(String(rawVal));
    } else if (Array.isArray(prior)) {
      // Array-typed keys (Factorio's tags) are shown as their JSON text —
      // parse the edit back into a real array; a plain comma list is
      // accepted as a fallback so operators can type `a, b` naturally.
      try {
        const arr = JSON.parse(rawVal);
        node[leaf] = Array.isArray(arr) ? arr : prior;
      } catch {
        node[leaf] = String(rawVal).split(",").map(s => s.trim()).filter(Boolean);
      }
    } else if (prior === undefined) {
      // Brand-new key (e.g. injecting gameSettings.* into enshrouded's
      // minimal seeded config): no prior type to mirror, so infer from the
      // value — game servers parse "1.5"-as-string / "true"-as-string as
      // invalid. Non-numeric strings (enums, names) pass through verbatim.
      if (/^(true|false)$/i.test(String(rawVal))) node[leaf] = /^true$/i.test(String(rawVal));
      else if (String(rawVal).trim() !== "" && Number.isFinite(Number(rawVal))) node[leaf] = Number(rawVal);
      else node[leaf] = rawVal;
    } else {
      node[leaf] = rawVal;
    }
  }
  // Tab indent + trailing newline matches Windrose's own writer.
  return JSON.stringify(out, null, "\t") + "\n";
}

function parseProperties(text) {
  const lines = text.split(/\r?\n/);
  const values = {}, order = [];
  lines.forEach((raw, idx) => {
    const line = raw.trim();
    if (!line || line.startsWith("#") || line.startsWith("!")) return;
    const eq = raw.indexOf("=");
    if (eq <= 0) return;
    const k = raw.slice(0, eq).trim();
    const v = raw.slice(eq + 1);
    values[k] = v;
    order.push(k);
  });
  return { lines, values, order };
}
function serializeProperties(parsed, updates) {
  const seen = new Set();
  const out = parsed.lines.map(raw => {
    const line = raw.trim();
    if (!line || line.startsWith("#") || line.startsWith("!")) return raw;
    const eq = raw.indexOf("=");
    if (eq <= 0) return raw;
    const k = raw.slice(0, eq).trim();
    if (updates.hasOwnProperty(k)) { seen.add(k); return `${k}=${updates[k]}`; }
    return raw;
  });
  for (const k of Object.keys(updates)) if (!seen.has(k)) out.push(`${k}=${updates[k]}`);
  return out.join("\n");
}

// ---- parsers: 7DTD serverconfig.xml ----
// 7DTD uses <property name="X" value="Y"/> — a flat list inside <ServerSettings>.
// We keep the original text so Save patches only the lines we need.
function parseXML7DTD(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  // Non-greedy match on the two attributes in either order.
  const re = /<property\s+name="([^"]+)"\s+value="([^"]*)"\s*\/?>/;
  const reRev = /<property\s+value="([^"]*)"\s+name="([^"]+)"\s*\/?>/;
  lines.forEach(raw => {
    let m = re.exec(raw);
    if (m) { values[m[1]] = m[2]; return; }
    m = reRev.exec(raw);
    if (m) { values[m[2]] = m[1]; }
  });
  return { lines, values, text };
}
function serializeXML7DTD(parsed, updates) {
  const seen = new Set();
  const out = parsed.lines.map(raw => {
    const re = /<property\s+name="([^"]+)"\s+value="([^"]*)"\s*\/?>/;
    const m = re.exec(raw);
    if (!m) return raw;
    const k = m[1];
    if (!updates.hasOwnProperty(k)) return raw;
    seen.add(k);
    const newVal = String(updates[k]).replace(/"/g, "&quot;");
    return raw.replace(re, `<property name="${k}" value="${newVal}"/>`);
  });
  // New keys that weren't in the file — insert before </ServerSettings>.
  const extras = Object.keys(updates).filter(k => !seen.has(k));
  if (extras.length) {
    for (let i = out.length - 1; i >= 0; i--) {
      if (/<\/ServerSettings>/.test(out[i])) {
        const insert = extras.map(k =>
          `  <property name="${k}" value="${String(updates[k]).replace(/"/g,"&quot;")}"/>`
        );
        out.splice(i, 0, ...insert);
        break;
      }
    }
  }
  return out.join("\n");
}

// ---- parsers: dedicated.yaml (Empyrion) ----
// Empyrion's config is YAML but with a fixed two-level shape:
//   SectionName:
//     Key: Value
//     # Key2: DefaultValue  # optional commented-out defaults
//     Key3: "Quoted Value"
// We flatten to a keyed map so the form can show bare keys (they're
// unique across ServerConfig + GameConfig). Commented defaults are
// surfaced as values too so the form renders them editable; on save we
// UNCOMMENT by replacing the # line with the new active setting. No new
// keys are introduced — Empyrion rejects unknown ones and aborts startup.
function parseEmpyrionYaml(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  const commented = {};  // key → { lineIdx, indent, defaultVal } for "# Key: X" lines
  const activeRe    = /^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/;
  const commentedRe = /^(\s+)#\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/;
  const unquote = (v) => {
    if ((v.startsWith('"') && v.endsWith('"')) ||
        (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
    return v;
  };
  lines.forEach((raw, idx) => {
    // Try active setting first — beats the commented-default regex.
    let m = activeRe.exec(raw);
    if (m) {
      let v = m[3];
      if (v === "" || v.startsWith("#")) return;            // nested block / trailing-comment-only
      values[m[2]] = unquote(v);
      return;
    }
    m = commentedRe.exec(raw);
    if (m) {
      let v = m[3];
      // Strip trailing inline explanatory comment like " # Only use for public servers".
      const hashIdx = v.indexOf(" #");
      if (hashIdx >= 0) v = v.slice(0, hashIdx).trim();
      v = unquote(v);
      commented[m[2]] = { lineIdx: idx, indent: m[1], defaultVal: v };
      // Surface the default to the form so the field renders pre-filled
      // (but DON'T shadow an active setting earlier in the file).
      if (!values.hasOwnProperty(m[2])) values[m[2]] = v;
    }
  });
  return { lines, values, commented };
}
function serializeEmpyrionYaml(parsed, updates) {
  const activeRe = /^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/;
  const seen = new Set();
  const formatVal = (newV, hint) => {
    newV = String(newV);
    const hadDouble = hint && hint.startsWith('"');
    const hadSingle = hint && hint.startsWith("'");
    const needs = newV === "" || /[\s:#]/.test(newV);
    if (hadDouble) return `"${newV.replace(/"/g, '\\"')}"`;
    if (hadSingle) return `'${newV.replace(/'/g, "''")}'`;
    if (needs)     return `"${newV.replace(/"/g, '\\"')}"`;
    return newV;
  };
  const out = parsed.lines.map(raw => {
    const m = activeRe.exec(raw);
    if (!m) return raw;
    const indent = m[1], k = m[2], oldV = m[3];
    if (!updates.hasOwnProperty(k)) return raw;
    seen.add(k);
    return `${indent}${k}: ${formatVal(updates[k], oldV)}`;
  });
  // For keys the operator set that only exist as "# Key: X" comments,
  // uncomment in place with the new value.
  for (const k of Object.keys(updates)) {
    if (seen.has(k)) continue;
    const c = parsed.commented && parsed.commented[k];
    if (!c) continue;
    out[c.lineIdx] = `${c.indent}${k}: ${formatVal(updates[k], c.defaultVal)}`;
    seen.add(k);
  }
  return out.join("\n");
}

// ---- parsers: INI (ARK GameUserSettings) ----
// Values are scanned across all sections (ARK puts most gameplay keys under
// [ServerSettings]). On write we patch in place; unknown keys in any section
// are preserved verbatim.
function parseINI(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  for (const raw of lines) {
    const line = raw.trim();
    if (!line || line.startsWith(";") || line.startsWith("#") || line.startsWith("[")) continue;
    const eq = raw.indexOf("=");
    if (eq <= 0) continue;
    const k = raw.slice(0, eq).trim();
    const v = raw.slice(eq + 1).trim();
    // Last occurrence wins (typical INI semantics).
    values[k] = v;
  }
  return { lines, values };
}
function serializeINI(parsed, updates) {
  // Track which lines belong to which section so we can:
  //   (a) detect a key that already exists in the file but in the WRONG
  //       section (e.g. ServerPVE stranded under [KsM] because a prior
  //       buggy save appended it after the last header), and
  //   (b) insert truly-new keys INSIDE [ServerSettings] instead of at
  //       end-of-file (where they'd get scoped to whatever section was
  //       last). For ARK that's load-bearing — `ServerPVE` outside
  //       `[ServerSettings]` is silently ignored by the game.
  const seen = new Set();
  let curSection = "";
  // First pass: replace in-place when the key is in [ServerSettings].
  // Drop existing matches that live in another section (we'll rewrite
  // them under [ServerSettings] in the append step) so a one-time
  // stale-section line gets healed.
  const out = [];
  for (const raw of parsed.lines) {
    const line = raw.trim();
    if (line.startsWith("[") && line.endsWith("]")) {
      curSection = line;
      out.push(raw);
      continue;
    }
    if (!line || line.startsWith(";") || line.startsWith("#")) { out.push(raw); continue; }
    const eq = raw.indexOf("=");
    if (eq <= 0) { out.push(raw); continue; }
    const k = raw.slice(0, eq).trim();
    if (!updates.hasOwnProperty(k)) { out.push(raw); continue; }
    if (curSection === "[ServerSettings]") {
      seen.add(k);
      out.push(`${k}=${updates[k]}`);
    } else {
      // Stranded copy in the wrong section — drop it; the append step
      // below will write the canonical copy under [ServerSettings].
    }
  }
  const fresh = Object.keys(updates).filter(k => !seen.has(k));
  if (fresh.length) {
    // Insert immediately AFTER the [ServerSettings] header so the new
    // keys end up scoped correctly. If the section doesn't exist, append
    // it at the bottom with the new keys.
    const hdrIdx = out.findIndex(l => /^\s*\[ServerSettings\]\s*$/.test(l));
    if (hdrIdx >= 0) {
      out.splice(hdrIdx + 1, 0, ...fresh.map(k => `${k}=${updates[k]}`));
    } else {
      out.push("", "[ServerSettings]");
      for (const k of fresh) out.push(`${k}=${updates[k]}`);
    }
  }
  return out.join("\n");
}

// --- Section-aware UE INI (format: "ue_ini") --------------------------------
// VEIN's Game.ini spans multiple [/Script/...] sections; the ini_ark
// serializer above hardcodes a single [ServerSettings] and would corrupt it.
// ue_ini keys each value by its real section. parseUEIni returns {lines,
// values} (values keyed by bare key so the form finds them) + a _sectionByKey
// map; the schema attaches _schemaSections so brand-new keys land in the right
// place.
function parseUEIni(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  const sectionByKey = {};
  let cur = "";
  for (const raw of lines) {
    const line = raw.trim();
    if (line.startsWith("[") && line.endsWith("]")) { cur = line; continue; }
    if (!line || line.startsWith(";") || line.startsWith("#")) continue;
    const eq = raw.indexOf("=");
    if (eq <= 0) continue;
    const k = raw.slice(0, eq).trim();
    const v = raw.slice(eq + 1).trim();
    values[k] = v;
    sectionByKey[k] = cur;
  }
  return { lines, values, _sectionByKey: sectionByKey };
}
function serializeUEIni(parsed, updates) {
  const sectionByKey = parsed._sectionByKey || {};
  const schemaSections = parsed._schemaSections || {};
  const target = {};
  for (const k of Object.keys(updates)) target[k] = sectionByKey[k] || schemaSections[k] || "";
  const seen = new Set();
  let curSection = "";
  const out = [];
  for (const raw of parsed.lines) {
    const line = raw.trim();
    if (line.startsWith("[") && line.endsWith("]")) { curSection = line; out.push(raw); continue; }
    if (!line || line.startsWith(";") || line.startsWith("#")) { out.push(raw); continue; }
    const eq = raw.indexOf("=");
    if (eq <= 0) { out.push(raw); continue; }
    const k = raw.slice(0, eq).trim();
    if (updates.hasOwnProperty(k) && curSection === target[k]) { out.push(`${k}=${updates[k]}`); seen.add(k); }
    else { out.push(raw); }
  }
  const fresh = Object.keys(updates).filter(k => !seen.has(k));
  const bySection = {};
  for (const k of fresh) (bySection[target[k]] ||= []).push(k);
  for (const [sec, keys] of Object.entries(bySection)) {
    if (!sec) { for (const k of keys) out.push(`${k}=${updates[k]}`); continue; }
    const hdrIdx = out.findIndex(l => l.trim() === sec);
    if (hdrIdx >= 0) {
      out.splice(hdrIdx + 1, 0, ...keys.map(k => `${k}=${updates[k]}`));
    } else {
      if (out.some(l => l.trim() !== "") && out[out.length - 1].trim() !== "") out.push("");
      out.push(sec);
      for (const k of keys) out.push(`${k}=${updates[k]}`);
    }
  }
  while (out.length && out[0].trim() === "") out.shift();
  return out.join("\n");
}
function ueSchemaSections(schema) {
  const m = {};
  for (const g of schema.groups) for (const f of g.fields) {
    if (f.section) m[f.prop] = f.section;
  }
  return m;
}

// Palworld stores its entire runtime config in PalWorldSettings.ini under
// `OptionSettings=(Difficulty=None,DayTimeSpeedRate=1.000000,…,ServerName="My Server",…)`
// — a single Unreal-engine config tuple, NOT a normal INI line list.
//
// parsePalworldINI returns the same shape as parseINI ({lines, values}) so
// the form renderer can stay format-agnostic, but `values` is keyed by the
// inner OptionSettings keys (Difficulty, ServerName, etc.) and we stash the
// outer line + tuple span on the parsed object so the serializer can patch
// only the changed keys without reflowing the file.
// Rust's server.cfg uses Facepunch convar syntax — one setting per line:
//
//   server.hostname "Refuge Gaming"
//   server.maxplayers 100
//   decay.scale 1.0
//
// Tokens are dot-namespaced. Values are either bare numbers / true / false
// or double-quoted strings. We parse to {lines, values} (matching parseINI's
// shape) so the generic form renderer doesn't care about the format.
function parseCfgRust(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  for (const raw of lines) {
    const line = raw.trim();
    if (!line || line.startsWith("#") || line.startsWith("//")) continue;
    // First whitespace splits key from value.
    const m = line.match(/^(\S+)\s+(.*)$/);
    if (!m) continue;
    let v = m[2].trim();
    // Strip surrounding double quotes if present.
    if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
    values[m[1]] = v;
  }
  return { lines, values };
}
function serializeCfgRust(parsed, updates) {
  const seen = new Set();
  const isString = v => v !== "" && (isNaN(Number(v)) && v !== "true" && v !== "false");
  const fmt = (k, v) => isString(v) ? `${k} "${String(v).replace(/"/g, '\\"')}"` : `${k} ${v}`;
  const out = parsed.lines.map(raw => {
    const line = raw.trim();
    if (!line || line.startsWith("#") || line.startsWith("//")) return raw;
    const m = line.match(/^(\S+)\s+(.*)$/);
    if (!m) return raw;
    const k = m[1];
    if (!updates.hasOwnProperty(k)) return raw;
    seen.add(k);
    return fmt(k, updates[k]);
  });
  for (const k of Object.keys(updates)) {
    if (!seen.has(k)) out.push(fmt(k, updates[k]));
  }
  return out.join("\n");
}

// DayZ's serverDZ.cfg is Bohemia's config syntax — one `key = value;` per
// line, strings double-quoted, numbers bare, `//` comments, and nested
// `class Name { … };` blocks we must leave byte-identical. We only parse
// top-level assignments (brace depth 0); everything else passes through the
// serializer untouched. Shape matches parseINI ({lines, values}) plus a
// `quoted` map so the serializer re-quotes exactly like the original.
function parseCfgDayz(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  const quoted = {};
  let depth = 0;
  for (const raw of lines) {
    const line = raw.trim();
    // Track class-block nesting BEFORE deciding to parse: an opening line
    // like `class Missions` has no '=' so it falls through harmlessly, but
    // `{` / `};` adjust depth so inner `template = "…";` lines are skipped.
    if (depth === 0 && !line.startsWith("//") && line !== "") {
      const m = line.match(/^([A-Za-z_]\w*)\s*=\s*(.*?);?\s*(\/\/.*)?$/);
      if (m && !m[1].endsWith("[")) {
        let v = m[2].trim();
        if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
          quoted[m[1]] = true;
          v = v.slice(1, -1).replace(/\\"/g, '"');
        }
        values[m[1]] = v;
      }
    }
    for (const c of raw) {
      if (c === "{") depth++;
      else if (c === "}") depth = Math.max(0, depth - 1);
    }
  }
  return { lines, values, quoted };
}
function serializeCfgDayz(parsed, updates) {
  const seen = new Set();
  const quotedOrig = parsed.quoted || {};
  // Re-quote like the original line when we saw the key; for brand-new keys
  // quote anything that isn't a bare number (DayZ accepts only quoted strings).
  const fmt = (k, v) => {
    const isNum = v !== "" && !isNaN(Number(v));
    const q = quotedOrig.hasOwnProperty(k) ? quotedOrig[k] : !isNum;
    return q ? `${k} = "${String(v).replace(/"/g, '\\"')}";` : `${k} = ${v};`;
  };
  let depth = 0;
  const out = parsed.lines.map(raw => {
    let result = raw;
    const line = raw.trim();
    if (depth === 0 && !line.startsWith("//") && line !== "") {
      const m = line.match(/^([A-Za-z_]\w*)\s*=\s*(.*?);?\s*(\/\/.*)?$/);
      if (m && !m[1].endsWith("[") && updates.hasOwnProperty(m[1])) {
        seen.add(m[1]);
        const comment = m[3] ? ` ${m[3]}` : "";
        result = fmt(m[1], updates[m[1]]) + comment;
      }
    }
    for (const c of raw) {
      if (c === "{") depth++;
      else if (c === "}") depth = Math.max(0, depth - 1);
    }
    return result;
  });
  for (const k of Object.keys(updates)) {
    if (!seen.has(k)) out.push(fmt(k, updates[k]));
  }
  return out.join("\n");
}

// --- XML root-element attributes (format: "xml_attrs") ----------------------
// Barotrauma's serversettings.xml keeps every setting as an attribute on the
// root <serversettings …> element. Attribute names are case-INSENSITIVE to
// the game (the server rewrites the file with its own canonical casing, which
// differs from our seeded casing — e.g. EnableUPnP vs enableupnp), so we take
// the schema and mirror each file attribute onto the schema's prop name for
// display, remembering the real file key (_attrAlias) for the writeback.
// _xmlRootTagSpan finds the [start, end) of the root element's opening tag,
// quote-aware (a raw '>' inside a quoted attribute value is legal XML and
// must not terminate the tag).
function _xmlRootTagSpan(text) {
  const start = text.search(/<[A-Za-z_]/);
  if (start < 0) return null;
  let inQuote = false;
  for (let i = start + 1; i < text.length; i++) {
    const c = text[i];
    if (c === '"') inQuote = !inQuote;
    else if (c === ">" && !inQuote) return { start, end: i + 1 };
  }
  return null;
}
function parseXmlAttrs(text, schema) {
  const values = {};
  const alias = {};   // schema prop (or raw key) -> exact key as in the file
  const span = _xmlRootTagSpan(text);
  const raw = {};
  if (span) {
    const re = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"]*)"/g;
    const body = text.slice(span.start, span.end).replace(/^<[A-Za-z_][\w-]*/, "");
    let a;
    while ((a = re.exec(body))) {
      raw[a[1]] = a[2].replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
    }
  }
  const lower = {};
  for (const k of Object.keys(raw)) { lower[k.toLowerCase()] = k; values[k] = raw[k]; alias[k] = k; }
  if (schema && schema.groups) {
    for (const g of schema.groups) for (const f of g.fields) {
      const fk = lower[f.prop.toLowerCase()];
      if (fk !== undefined && !(f.prop in values)) { values[f.prop] = raw[fk]; alias[f.prop] = fk; }
    }
  }
  return { text, values, _attrAlias: alias };
}
function serializeXmlAttrs(parsed, updates) {
  const out = parsed.text;
  const span = _xmlRootTagSpan(out);
  if (!span) return out;
  let tag = out.slice(span.start, span.end);
  const escAttr = v => String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  const extras = [];
  for (const [prop, v] of Object.entries(updates)) {
    const fileKey = (parsed._attrAlias && parsed._attrAlias[prop]) || prop;
    const keyRe = new RegExp("((?:^|\\s)" + fileKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + '\\s*=\\s*")[^"]*(")');
    if (keyRe.test(tag)) tag = tag.replace(keyRe, `$1${escAttr(v)}$2`);
    else extras.push(`${fileKey}="${escAttr(v)}"`);
  }
  if (extras.length) {
    // Insert new attributes just before the tag's closing "/>" or ">".
    tag = tag.replace(/\s*(\/?)>$/, (mm, slash) => "\n  " + extras.join("\n  ") + (slash ? " /" : "") + ">");
  }
  return out.slice(0, span.start) + tag + out.slice(span.end);
}

function parsePalworldINI(text) {
  const lines = text.split(/\r?\n/);
  const values = {};
  let optionLine = -1;
  let openParen = -1;
  let closeParen = -1;
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/^\s*OptionSettings\s*=\s*\(/);
    if (m) {
      optionLine = i;
      openParen = lines[i].indexOf("(");
      closeParen = lines[i].lastIndexOf(")");
      break;
    }
  }
  if (optionLine < 0 || openParen < 0 || closeParen < 0) {
    return { lines, values, palworld: { found: false } };
  }
  const inner = lines[optionLine].slice(openParen + 1, closeParen);
  // Split on top-level commas — values may contain quoted commas.
  const parts = [];
  let buf = "", inQ = false;
  for (const c of inner) {
    if (c === '"') inQ = !inQ;
    if (c === "," && !inQ) { parts.push(buf); buf = ""; continue; }
    buf += c;
  }
  if (buf) parts.push(buf);
  for (const p of parts) {
    const eq = p.indexOf("=");
    if (eq <= 0) continue;
    const k = p.slice(0, eq).trim();
    let v = p.slice(eq + 1).trim();
    // Strip outer quotes for display; serializer re-adds based on field type.
    if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
    values[k] = v;
  }
  return { lines, values, palworld: { found: true, optionLine, openParen, closeParen, originalParts: parts } };
}
function serializePalworldINI(parsed, updates) {
  if (!parsed.palworld || !parsed.palworld.found) {
    // No tuple in the file (rare — file should have been seeded from
    // DefaultPalWorldSettings.ini). Fall back to appending one.
    const out = parsed.lines.slice();
    const kvs = Object.entries(updates).map(([k, v]) =>
      // Quote string-y values; leave numerics + booleans bare.
      `${k}=${/^(True|False|None|-?\d+(\.\d+)?)$/.test(v) ? v : `"${v}"`}`
    ).join(",");
    out.push("[/Script/Pal.PalGameWorldSettings]");
    out.push(`OptionSettings=(${kvs})`);
    return out.join("\n");
  }
  const { optionLine, originalParts } = parsed.palworld;
  // Walk the original parts in order so unchanged keys preserve their
  // original spelling/whitespace; replace the value when an update is set.
  const newParts = originalParts.map(p => {
    const eq = p.indexOf("=");
    if (eq <= 0) return p;
    const k = p.slice(0, eq).trim();
    if (!updates.hasOwnProperty(k)) return p;
    const v = updates[k];
    // Heuristic for re-quoting: if the original was quoted, keep it quoted;
    // otherwise leave bare. Pure numbers/booleans/None stay bare regardless.
    const orig = p.slice(eq + 1).trim();
    const wasQuoted = orig.length >= 2 && orig[0] === '"' && orig[orig.length - 1] === '"';
    const looksScalar = /^(True|False|None|-?\d+(\.\d+)?)$/.test(v);
    const formatted = (wasQuoted && !looksScalar) ? `"${v}"` : (looksScalar ? v : `"${v}"`);
    return `${k}=${formatted}`;
  });
  // Append any brand-new keys at the end of the tuple (rare; schema-only fields).
  const seen = new Set(originalParts.map(p => p.slice(0, p.indexOf("=")).trim()));
  for (const [k, v] of Object.entries(updates)) {
    if (seen.has(k)) continue;
    const looksScalar = /^(True|False|None|-?\d+(\.\d+)?)$/.test(v);
    newParts.push(`${k}=${looksScalar ? v : `"${v}"`}`);
  }
  const out = parsed.lines.slice();
  out[optionLine] = `OptionSettings=(${newParts.join(",")})`;
  return out.join("\n");
}

// browseable-roots map (module_id → [{name, path, hint}]). Mirrors what
// each module.yaml declares. The agent validates paths against its own
// copy of this list so even if the UI sends a bogus path, the agent
// refuses it. Modules with a single root don't need an entry here — the
// Files tab falls back to relative-path mode against the default root.
const moduleBrowseableRoots = {
  "7dtd": [
    { name: "Saves & Configs", path: "/game-saves", hint: "World data, serverconfig.xml, admin files" },
    { name: "Game Files",       path: "/game",       hint: "Binaries, Mods/, Data/ — drop mod folders here" },
  ],
  "empyrion": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Saves/, dedicated.yaml, admin files" },
    { name: "Game Files",       path: "/game",       hint: "Binaries, Content/, Mods/ — drop mod folders here" },
  ],
  "valheim": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Worlds + adminlist.txt + banlist.txt" },
    { name: "Game Files",       path: "/game",       hint: "Binaries, plugins — drop BepInEx/Jotunn here" },
  ],
  "palworld": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Pal/Saved world data + PalWorldSettings.ini" },
    { name: "Game Files",       path: "/game",       hint: "PalServer install, binaries" },
  ],
  "windrose": [
    { name: "Saves & Configs", path: "/game-saves", hint: "ServerDescription.json + R5/Saved world data" },
    { name: "Game Files",       path: "/game",       hint: "WindroseServer.exe, R5/ engine files" },
  ],
  "conan-exiles": [
    { name: "Saves & Configs", path: "/game-saves", hint: "ConanSandbox/Saved/ world data + Config/LinuxServer/ (Enhanced) or Config/WindowsServer/ (Legacy) INIs" },
    { name: "Game Files",       path: "/game",       hint: "ConanSandbox/Binaries/Linux/ (Enhanced, UE5 native) or Binaries/Win64/ (Legacy, UE4 Wine) + ConanSandbox/Mods/ (Workshop drops)" },
  ],
  "rust": [
    { name: "Saves & Configs", path: "/game-saves", hint: "server/<identity>/ world + cfg + oxide/" },
    { name: "Game Files",       path: "/game",       hint: "RustDedicated, Bundles/, shipped cfgs" },
  ],
  "dragonwilds": [
    { name: "Saves & Configs", path: "/game-saves", hint: "RSDragonwilds/Saved/ — DedicatedServer.ini + world saves" },
    { name: "Game Files",       path: "/game",       hint: "RSDragonwildsServer.sh, RSDragonwilds/Binaries/Linux/" },
  ],
  "dayz": [
    { name: "Saves & Configs", path: "/game-saves", hint: "serverDZ.cfg + profiles/ (logs, bans, RPTs)" },
    { name: "Game Files",       path: "/game",       hint: "DayZServer binary + battleye/, mpmissions/, keys/" },
  ],
  "factorio": [
    { name: "Saves & Configs", path: "/game-saves", hint: "saves/, mods/, config/" },
    { name: "Game Files",       path: "/game",       hint: "Factorio binary tree" },
  ],
  "satisfactory": [
    { name: "Saves & Configs", path: "/game-saves", hint: "SaveGames/ + Engine configs" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries" },
  ],
  "barotrauma": [
    { name: "Saves & Configs", path: "/game-saves", hint: "serversettings.xml + saves/" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries, Mods/" },
  ],
  "enshrouded": [
    { name: "Saves & Configs", path: "/game-saves", hint: "enshrouded_server.json + savegame/" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries" },
  ],
  "terraria": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Worlds/, serverconfig.txt" },
    { name: "Game Files",       path: "/game",       hint: "Terraria binary" },
  ],
  "project-zomboid": [
    { name: "Saves & Configs", path: "/game-saves", hint: "~/Zomboid tree" },
    { name: "Game Files",       path: "/game",       hint: "PZ install" },
  ],
  "core-keeper": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Worlds + server.json" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries" },
  ],
  "soulmask": [
    { name: "Saves & Configs", path: "/game-saves", hint: "WS/Saved/ + GameUserSettings.ini" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries, WS/Content/" },
  ],
  "v-rising": [
    { name: "Saves & Configs", path: "/game-saves", hint: "ServerHostSettings.json + Saves/" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries" },
  ],
  "sons-of-the-forest": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Config/ + Saved/" },
    { name: "Game Files",       path: "/game",       hint: "Server binaries" },
  ],
  "vein": [
    { name: "Saves & Configs", path: "/game-saves", hint: "Vein/Saved/ savegame + Config/LinuxServer/Game.ini" },
    { name: "Game Files",       path: "/game",       hint: "Vein/Binaries/Linux/ server binary + steamclient" },
  ],
};
function rootsFor(moduleID) {
  // WI-07: manifest browseable_roots (from /api/modules — the same
  // module.yaml list the agent validates paths against) wins; the inline
  // map is only a fallback for modules missing the field.
  const meta = _modMeta(moduleID);
  if (meta && Array.isArray(meta.browseable_roots) && meta.browseable_roots.length) {
    return meta.browseable_roots;
  }
  return moduleBrowseableRoots[moduleID] || null;
}

// Fallback if the controller doesn't expose manifest.ports yet.
// primaryPortFor returns the host-bound port of the instance's MAIN
// public connection endpoint (the one players type into their game
// client), formatted as ":NNNN". Prefers any per-instance override
// in i.ports over the module default from guessPortsForModule.
// Returns "" if no port can be resolved.
function primaryPortFor(i) {
  if (!i) return "";
  const list = guessPortsForModule(i.module_id) || [];
  // Heuristic: the first public (non-internal) required port named
  // "game" wins. Falls back to the first required public port, then to
  // the first in the list.
  const pick = list.find(p => p.name === "game" && !p.internal && p.required)
            || list.find(p => !p.internal && p.required)
            || list.find(p => !p.internal)
            || list[0];
  if (!pick) return "";
  // i.ports is now `{name: hostPortNumber}` per the new allocator DTO.
  // Older code passed `{name: {host_port: N}}`; keep tolerant of both.
  const overrides = i.ports && typeof i.ports === "object" ? i.ports : {};
  const ov = overrides[pick.name];
  let port;
  if (typeof ov === "number") port = ov;
  else if (ov && typeof ov === "object") port = ov.host_port || ov.hostPort;
  if (!port) port = pick.default;
  return port ? ":" + port : "";
}

function guessPortsForModule(id) {
  // WI-07: manifest ports (from /api/modules, incl. env names) win; the
  // inline map below is only a fallback for modules missing the field or
  // before the first modules poll lands.
  const meta = _modMeta(id);
  if (meta && Array.isArray(meta.ports) && meta.ports.length) return meta.ports;
  const m = {
    "7dtd":          [{name:"game",proto:"udp",default:26900,required:true},{name:"gameA",proto:"udp",default:26901,required:true},{name:"gameB",proto:"udp",default:26902,required:true},{name:"telnet",proto:"tcp",default:8081,internal:true}],
    "minecraft-java":[{name:"game",proto:"tcp",default:25565,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}],
    "valheim":       [{name:"game",proto:"udp",default:2456,required:true},{name:"query",proto:"udp",default:2457,required:true}],
    "ark":           [{name:"game",proto:"udp",default:7777,required:true},{name:"raw",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:27020,internal:true}],
    "ark-sa":        [{name:"game",proto:"udp",default:7777,required:true},{name:"raw",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:27020,internal:true}],
    "empyrion":      [{name:"game",proto:"udp",default:30000,required:true},{name:"query",proto:"udp",default:30001,required:true},{name:"client",proto:"udp",default:30002,required:true},{name:"eac",proto:"udp",default:30003,required:true},{name:"csw",proto:"tcp",default:30004,internal:true}],
    "valheim":       [{name:"game",proto:"udp",default:2456,required:true},{name:"gameA",proto:"udp",default:2457,required:true},{name:"query",proto:"udp",default:2458,required:true}],
    "palworld":      [{name:"game",proto:"udp",default:8211,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}],
    "windrose":      [{name:"game",proto:"udp",default:7777,required:true}],
    "conan-exiles":  [{name:"game",proto:"tcp",default:7777,required:true},{name:"game-udp",proto:"udp",default:7777,required:true},{name:"pinger",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}],
    "rust":          [{name:"game",proto:"udp",default:28015,required:true},{name:"rcon",proto:"tcp",default:28016,internal:true},{name:"rustplus",proto:"tcp",default:28082,required:false}],
    "dragonwilds":   [{name:"game",proto:"udp",default:7777,required:true}],
    "dayz":          [{name:"game",proto:"udp",default:2302,required:true},{name:"reserved",proto:"udp",default:2303,required:false},{name:"battleye",proto:"udp",default:2304,required:false},{name:"rcon",proto:"udp",default:2305,internal:true}],
    "factorio":      [{name:"game",proto:"udp",default:34197,required:true},{name:"rcon",proto:"tcp",default:27015,internal:true}],
    "satisfactory":  [{name:"game",proto:"udp",default:7777,required:true},{name:"beacon",proto:"udp",default:15000,required:true},{name:"messaging",proto:"tcp",default:7777,required:true}],
    "barotrauma":    [{name:"game",proto:"udp",default:27015,required:true},{name:"query",proto:"udp",default:27016,required:true}],
    "enshrouded":    [{name:"game",proto:"udp",default:15636,required:true},{name:"query",proto:"udp",default:15637,required:true}],
    "terraria":      [{name:"game",proto:"tcp",default:7777,required:true}],
    "project-zomboid":[{name:"game",proto:"udp",default:16261,required:true},{name:"gameB",proto:"udp",default:16262,required:true},{name:"rcon",proto:"tcp",default:27015,internal:true}],
    "core-keeper":   [{name:"game",proto:"udp",default:27015,required:true}],
    "soulmask":      [{name:"game",proto:"udp",default:8777,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"admin",proto:"tcp",default:8888,internal:true}],
    "v-rising":      [{name:"game",proto:"udp",default:9876,required:true},{name:"query",proto:"udp",default:9877,required:true}],
    "sons-of-the-forest":[{name:"game",proto:"udp",default:8766,required:true},{name:"query",proto:"udp",default:27016,required:true},{name:"join",proto:"udp",default:9700,required:true}],
    "vein":          [{name:"game",proto:"udp",default:7777,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"udp",default:7878,internal:true}],
  };
  return m[id] || null;
}

// ================ events SSE ================
// rAF coalescer for SSE stat bursts. An agent with N instances emits N
// instance_stats events back-to-back every sample tick; painting each one
// synchronously meant N card diffs + N hero recomputes per burst. Instead we
// mark ids dirty and flush once per animation frame — each card repaints at
// most once, hero stats recompute once. While the tab is hidden rAF doesn't
// fire; the data model (latestStats/latestAppState) is already updated, and
// the visible-tab refetch repaints everything.
const _cardDirty = new Set();
let _statsFlushArmed = false;
let _heroDirty = false;
function _queueCardRefresh(id, hero) {
  _cardDirty.add(id);
  if (hero) _heroDirty = true;
  if (_statsFlushArmed) return;
  _statsFlushArmed = true;
  requestAnimationFrame(() => {
    _statsFlushArmed = false;
    const ids = Array.from(_cardDirty);
    _cardDirty.clear();
    for (const cid of ids) updateCardInPlace(cid);
    if (_heroDirty) { _heroDirty = false; refreshHeroStats(); }
  });
}

// SSE-health-adaptive polling (WI-11): while the global SSE stream is
// healthy (server snapshot received, no error since), instance polling is
// just a slow safety net — SSE pushes state/stats/app events live, and the
// connect snapshot replaces the initial poll. On SSE error we drop straight
// back to the user-configured cadence until the stream recovers.
let _sseHealthy = false;
let _snapshotSeen = false;
const SSE_FALLBACK_POLL_MS = 30000;
function _instancePollMs() {
  return _sseHealthy ? SSE_FALLBACK_POLL_MS : (userSettings.refreshMs || 2000);
}
function _restartInstancePoll() {
  if (_pollInstancesHandle) clearInterval(_pollInstancesHandle);
  _pollInstancesHandle = setInterval(pollInstances, _instancePollMs());
}
function _setSSEHealthy(ok) {
  if (ok === _sseHealthy) return;
  _sseHealthy = ok;
  _restartInstancePoll();
  // Losing SSE means we may have missed transitions — resync immediately
  // (pollInstances itself still respects the hidden-tab gate).
  if (!ok) { pollInstances(); pollAgents(); }
}

function connectEvents() {
  // exclude=log: the dashboard has no DOM target for log lines, but they're
  // ~99% of event volume during a server boot — shipping them anyway is what
  // overflowed the SSE buffer (dropped state events → stale cards) and kept
  // the browser main thread parsing thousands of lines. Consoles + the
  // readiness scanner use per-instance streams, which keep their logs.
  const src = new EventSource("/api/events?exclude=log");
  src.onopen = () => setStatus("connected", "ok");
  src.onerror = () => { setStatus("reconnecting…", "err"); _setSSEHealthy(false); src.close(); setTimeout(connectEvents, 2000); };
  src.onmessage = e => { try { appendEvent(JSON.parse(e.data)); } catch {} };
  // Connect-time state snapshot — the controller pushes the same DTO
  // payloads the /api/instances + /api/agents polls return, so a (re)connect
  // needs no immediate poll. Named event → older clients ignore it for free.
  src.addEventListener("snapshot", async e => {
    try {
      const snap = JSON.parse(e.data);
      if (Array.isArray(snap.agents)) applyAgents(snap.agents);
      if (Array.isArray(snap.instances)) await applyInstances(snap.instances);
      _snapshotSeen = true;
      _setSSEHealthy(true);
    } catch {}
  });
}
function appendEvent(ev) {
  // Global readiness scanner — for READY_LOG_ONLY modules, the agent's
  // STATUS_RUNNING is too early (it fires when docker says the container
  // is up, NOT when the game is joinable). The console IS the source
  // of truth: only after we see the joinable log line in the live stream
  // does the frontend treat the server as actually "running." Tracked
  // session-only via instanceConfirmedReady; for instances that have
  // already been up for >10 min when the page loads, isConfirmedReady()
  // assumes ready (the boot lines are long gone from the live stream).
  if (ev.log && ev.log.instance_id && ev.log.line) {
    const inst = allInstances.find(x => x.instance_id === ev.log.instance_id);
    if (inst && READY_LOG_ONLY.has(inst.module_id)) {
      const pat = readyPatternFor(inst.module_id);
      if (pat && pat.test(ev.log.line) && !instanceConfirmedReady.has(ev.log.instance_id)) {
        // Reject lines older than the current run. The SSE stream replays
        // the agent's tail buffer on first connect, and the docker log
        // file carries lines across container restarts — without this
        // guard a previous run's "Server has completed startup" line
        // marks the just-restarted instance ready immediately.
        let stale = false;
        if (ev.log.at && inst.updated_at) {
          const lineAt = Date.parse(ev.log.at);
          const startedAt = Date.parse(inst.updated_at) - 30 * 1000; // 30s clock-skew grace
          if (!isNaN(lineAt) && !isNaN(startedAt) && lineAt < startedAt) stale = true;
        }
        if (!stale) {
          instanceConfirmedReady.add(ev.log.instance_id);
          // Repaint cards + modal so the pill flips from "starting" to
          // "running" the instant the joinable line is seen.
          paintStartingUI();
          if (typeof renderServerCards === "function") renderServerCards();
        }
      }
    }
  }
  const at = new Date(ev.at).toLocaleTimeString([], { hour12: false });
  let kind, body, cls;
  if (ev.instance_state) {
    cls = "state"; kind = "STATE";
    const s = ev.instance_state;
    body = `${s.instance_id} → ${String(s.status).replace("INSTANCE_STATUS_","").toLowerCase()}  ${s.detail || ""}`;
    // Drop the confirmed-ready flag whenever the server stops, crashes,
    // or is deleted. The next start has to re-prove joinability via the
    // log line (otherwise a quick stop+start would inherit stale "ready"
    // and skip the proper "starting" pulse).
    const sStatus = String(s.status).replace("INSTANCE_STATUS_","").toLowerCase();
    if (sStatus === "stopped" || sStatus === "crashed" || s.detail === "deleted") {
      resetReadyOnStop(s.instance_id);
      // Drop the last stats sample so the card doesn't keep painting the
      // pre-stop CPU/RAM until the next start. Without this clear the bars
      // freeze at "0.4 CPU · 8.2 GB" indefinitely after Stop, and the hero
      // totals double-count phantom load from servers that aren't running.
      delete latestStats[s.instance_id];
      delete latestAppState[s.instance_id];
      _queueCardRefresh(s.instance_id, true);
    }
  }
  else if (ev.log) { cls = "log"; kind = "LOG  "; body = `${ev.log.instance_id}  ${ev.log.line}`; }
  else if (ev.player) { cls = "player"; kind = String(ev.player.kind).replace("KIND_","").padEnd(5); body = `${ev.player.instance_id}  ${ev.player.player_name || ""}  ${ev.player.detail || ""}`; }
  else if (ev.app_state) {
    const a = ev.app_state;
    latestAppState[a.instance_id] = { players_online: a.players_online, players_max: a.players_max, uptime: a.uptime_seconds };
    _queueCardRefresh(a.instance_id, false);
    return;
  }
  else if (ev.instance_stats) {
    const s = ev.instance_stats;
    latestStats[s.instance_id] = {
      cpu: Number(s.cpu_percent) || 0,
      hostCpus: Number(s.host_cpus) || 0,
      memMB: Number(s.mem_used_bytes) / 1024 / 1024,
      memLimitMB: Number(s.mem_limit_bytes) / 1024 / 1024,
      rx: Number(s.net_rx_bytes) || 0, tx: Number(s.net_tx_bytes) || 0, pids: Number(s.pids) || 0,
    };
    _queueCardRefresh(s.instance_id, true);
    return;
  } else { return; }
  // The global #events feed was removed — we keep the SSE connection alive
  // for the connection-status indicator and to update cards/state in place,
  // but there's no DOM target for log/player/state events on the main page.
  // They still surface in the per-instance modal's Console tab via a
  // separate SSE connection.
}

// ================ init ================
// Schedules + live events used to be global panels on the main dashboard;
// they moved to per-instance modal tabs so the dashboard stays focused on
// "which servers do I have and what's their state". We still keep the
// stand-alone connection indicator in the nav via an SSE keepalive so the
// user sees at a glance whether the controller is reachable.
loadMe();
// SSE first: the connect snapshot carries the same instances+agents payloads
// the initial polls used to fetch, so a healthy stream needs no immediate
// poll. If the snapshot hasn't landed shortly after load (SSE blocked or a
// buffering proxy), fall back to the classic initial polls.
connectEvents();
pollModules();
seedSparkHistory();   // prime overview sparklines from controller history
refreshUpdateCheckMap(); // WI-14: paint "Update available" badges from stored checks
setTimeout(() => { if (!_snapshotSeen) { pollAgents(); pollInstances(); } }, 1500);
setInterval(pollAgents, 5000);
let _pollInstancesHandle = setInterval(pollInstances, 2000);
setInterval(pollModules, 15000);
// All three polls skip while the tab is hidden; refetch immediately on
// return so the dashboard doesn't sit stale for up to a full interval.
document.addEventListener("visibilitychange", () => {
  if (document.hidden) return;
  pollAgents(); pollInstances(); pollModules();
});
// NOTE: page-load readiness catch-up for READY_LOG_ONLY modules is handled
// by scanReadyForUnconfirmedRunning(), which every pollInstances tick runs
// (throttled per instance) — no separate seeding pass needed even though
// the global SSE no longer carries log lines.

// =============== USER SETTINGS (localStorage-backed) ===============
const SETTINGS_KEY = "rgsp.settings.v1";
const _defaultSettings = { density: "comfortable", showBars: true, refreshMs: 2000, notifyOnCrash: false };
function loadSettings() {
  try {
    const raw = localStorage.getItem(SETTINGS_KEY);
    if (raw) return Object.assign({}, _defaultSettings, JSON.parse(raw));
  } catch {}
  return Object.assign({}, _defaultSettings);
}
function saveSettings(s) {
  try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch {}
  applySettings(s);
}
function applySettings(s) {
  const root = document.documentElement;
  // Density via CSS var that .svr-grid reads.
  // Single source of truth with the CSS fallback in .svr-grid/.svr-group-grid:
  // comfortable = 300px (matches var(--svr-grid-min, 300px)), compact/cozy scaled.
  const dens = s.density === "compact" ? "240px" : s.density === "cozy" ? "340px" : "300px";
  root.style.setProperty("--svr-grid-min", dens);
  // CPU/RAM bar visibility
  root.classList.toggle("hide-svr-bars", !s.showBars);
  // Auto-refresh interval — restart the polling loop with the new period.
  // While SSE is healthy the poll is only a fallback, so the stretched
  // cadence wins over the user setting (SSE pushes the real-time updates).
  if (_pollInstancesHandle) clearInterval(_pollInstancesHandle);
  _pollInstancesHandle = setInterval(pollInstances, _sseHealthy ? SSE_FALLBACK_POLL_MS : (s.refreshMs || 2000));
}
const userSettings = loadSettings();
applySettings(userSettings);

// Crash-notification tracker — fires a browser Notification when an
// instance flips into a crashed-like status. Only listens when the
// setting is on AND the user has granted browser permission.
const _lastStatusByID = {};
function _checkCrashTransitions() {
  if (!userSettings.notifyOnCrash) return;
  if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
  for (const i of allInstances) {
    const prev = _lastStatusByID[i.instance_id];
    const cur = i.status;
    const isCrashLike = cur && /^(crashed|error|delete_failed|install_failed)$/i.test(cur);
    const wasCrashLike = prev && /^(crashed|error|delete_failed|install_failed)$/i.test(prev);
    if (isCrashLike && !wasCrashLike && prev !== undefined) {
      try {
        const mod = (allModules.find(m => m.id === i.module_id) || {}).name || i.module_id;
        new Notification(`💥 ${i.instance_id} crashed`, {
          body: `${mod} on agent ${i.agent_id}`,
          tag: `rgsp-crash-${i.instance_id}`,
        });
      } catch {}
    }
    _lastStatusByID[i.instance_id] = cur;
  }
}
// Re-check after each render so we cover both polling and SSE-driven updates.
{
  const _origRender = renderServerCards;
  window.renderServerCards = function() {
    _origRender.apply(this, arguments);
    _checkCrashTransitions();
  };
}

function openSettingsModal() {
  $("#set-density").value = userSettings.density;
  $("#set-show-bars").checked = userSettings.showBars;
  $("#set-refresh").value = String(userSettings.refreshMs);
  $("#set-notify").checked = userSettings.notifyOnCrash;
  // Sync iOS-toggle label state
  document.querySelectorAll("#settings-modal .toggle").forEach(t => {
    const inp = t.querySelector("input"); const lab = t.querySelector("[data-toggle-label]");
    if (lab && inp) {
      const on = inp.checked;
      lab.textContent = on ? "Enabled" : "Disabled";
      lab.classList.toggle("on", on);
    }
  });
  // Populate About — module count + a build hash (best-effort).
  const aboutMod = $("#about-modules"); if (aboutMod) aboutMod.textContent = String(allModules.length);
  const aboutBuild = $("#about-build"); if (aboutBuild) aboutBuild.textContent = (window.__rgspBuild || "dev");
  // opnfwd integration card — load current state. Failures are benign:
  // controller is too old to have the endpoint, or DB hasn't been migrated.
  loadOpnfwdSettings();
  // Panel UI switch — back to the classic front-end. Sets the panel_ui cookie
  // the controller's root route reads, then reloads "/".
  const switchBtn = $("#set-switch-classic");
  if (switchBtn && !switchBtn._wired) {
    switchBtn._wired = true;
    switchBtn.addEventListener("click", () => switchPanelUI("classic"));
  }
  showModal("#settings-modal");
}

// switchPanelUI sets the panel_ui cookie (1 year, lax) and reloads to "/".
// "new"     -> controller serves static/new.html (this UI)
// "classic" -> controller serves static/index.html
function switchPanelUI(which) {
  const oneYear = 60 * 60 * 24 * 365;
  document.cookie = `panel_ui=${which}; path=/; max-age=${oneYear}; samesite=lax`;
  location.href = "/";
}

// ================ opnfwd integration settings ================
//
// Card on the Settings modal that surfaces the panel ↔ opnfwd toggle
// (and config: URL, token, insecure-TLS), plus a Test button that
// round-trips opnfwd's /api/me, plus a Reconcile button that diffs
// what panel believes it owns against what opnfwd actually has on the
// router. Token field is masked — we send the new value only when the
// operator types something; blank = leave existing token alone.

async function loadOpnfwdSettings() {
  // Reset display state.
  const out = $("#set-opnfwd-reconcile-out"); if (out) out.style.display = "none";
  $("#set-opnfwd-status-pill").textContent = "—";
  $("#set-opnfwd-status-pill").className = "dim mono";
  try {
    const s = await fetchJSON("/api/admin/opnfwd");
    $("#set-opnfwd-enabled").checked = !!s.enabled;
    $("#set-opnfwd-url").value = s.url || "";
    $("#set-opnfwd-insecure").checked = !!s.insecure_tls;
    // Token field: show placeholder indicating "set/unset" without leaking
    // the value. Operator types a new value to overwrite.
    const tokInput = $("#set-opnfwd-token");
    tokInput.value = "";
    tokInput.placeholder = s.token_set ? "(token set — type to change, blank to clear)" : "(no token configured)";
  } catch (err) {
    // Endpoint may not exist on older controllers; leave fields blank.
    console.warn("opnfwd settings load:", err.message);
  }
  // Always-load tracked count.
  try {
    const r = await fetchJSON("/api/admin/opnfwd/forwards");
    $("#set-opnfwd-tracked").textContent = String((r.forwards || []).length) + " tracked";
  } catch (err) {
    $("#set-opnfwd-tracked").textContent = "—";
  }
  // Sync the opnfwd toggle label since loadOpnfwd ran AFTER the label-sync above.
  document.querySelectorAll("#set-opnfwd-enabled, #set-opnfwd-insecure").forEach(inp => {
    const t = inp.closest(".toggle"); if (!t) return;
    const lab = t.querySelector("[data-toggle-label]"); if (!lab) return;
    const on = inp.checked;
    lab.textContent = on ? (inp.id === "set-opnfwd-insecure" ? "On" : "Enabled") : (inp.id === "set-opnfwd-insecure" ? "Off" : "Disabled");
    lab.classList.toggle("on", on);
  });
}

// Save just the opnfwd fields. Called on change / blur. Token is sent only
// if the operator typed something new — blank means "leave it alone".
async function saveOpnfwdSettings(extra) {
  const body = Object.assign({
    enabled: $("#set-opnfwd-enabled").checked,
    url: $("#set-opnfwd-url").value.trim(),
    insecure_tls: $("#set-opnfwd-insecure").checked,
  }, extra || {});
  // Token: only include the field if the user typed something OR explicitly
  // wants to clear (input value is empty AND the user clicked clear). The
  // reset behavior is: blank = no change. If you want to clear, type a
  // single space and save (server-trims to empty).
  const tokVal = $("#set-opnfwd-token").value;
  if (tokVal !== "") {
    body.token = tokVal.trim();
    // Once consumed, blank out the input so reload doesn't re-send.
    $("#set-opnfwd-token").value = "";
  }
  try {
    await fetchJSON("/api/admin/opnfwd", {
      method: "PUT", headers: {"Content-Type":"application/json"},
      body: JSON.stringify(body),
    });
  } catch (err) {
    toast("opnfwd settings save failed: " + err.message, "err");
    return false;
  }
  return true;
}

// Live-save on toggle/url/insecure change. Token saves on blur (not change)
// so partial typing doesn't hammer PUT.
$("#set-opnfwd-enabled")?.addEventListener("change", async (e) => {
  if (await saveOpnfwdSettings()) {
    toast(e.target.checked ? "opnfwd auto-forward: ON" : "opnfwd auto-forward: OFF", "ok");
    const lab = e.target.closest(".toggle")?.querySelector("[data-toggle-label]");
    if (lab) { lab.textContent = e.target.checked ? "Enabled" : "Disabled"; lab.classList.toggle("on", e.target.checked); }
  }
});
$("#set-opnfwd-url")?.addEventListener("change", saveOpnfwdSettings);
$("#set-opnfwd-insecure")?.addEventListener("change", async (e) => {
  if (await saveOpnfwdSettings()) {
    const lab = e.target.closest(".toggle")?.querySelector("[data-toggle-label]");
    if (lab) { lab.textContent = e.target.checked ? "On" : "Off"; lab.classList.toggle("on", e.target.checked); }
  }
});
$("#set-opnfwd-token")?.addEventListener("blur", async () => {
  if ($("#set-opnfwd-token").value !== "") await saveOpnfwdSettings();
});

$("#set-opnfwd-token-show")?.addEventListener("click", () => {
  const inp = $("#set-opnfwd-token");
  inp.type = inp.type === "password" ? "text" : "password";
});

$("#set-opnfwd-test")?.addEventListener("click", async () => {
  const pill = $("#set-opnfwd-status-pill");
  pill.textContent = "testing…"; pill.className = "dim mono";
  try {
    const r = await fetchJSON("/api/admin/opnfwd/test", { method: "POST" });
    if (r.ok) {
      pill.textContent = `✓ OK (${r.elapsed_ms} ms)`;
      pill.className = "mono";
      pill.style.color = "#4fcf63";
    } else {
      pill.textContent = `✗ ${r.err || "failed"}`;
      pill.className = "mono";
      pill.style.color = "var(--err)";
    }
  } catch (err) {
    pill.textContent = "✗ " + err.message;
    pill.style.color = "var(--err)";
  }
});

$("#set-opnfwd-reconcile")?.addEventListener("click", async () => {
  const out = $("#set-opnfwd-reconcile-out");
  out.style.display = "block"; out.textContent = "Reconciling…";
  try {
    const r = await fetchJSON("/api/admin/opnfwd/reconcile", { method: "POST" });
    const lines = [];
    lines.push(`matched:       ${r.matched}`);
    lines.push(`local_total:   ${r.local_total} (panel-tracked rows)`);
    lines.push(`remote_total:  ${r.remote_total} (every forward on opnfwd)`);
    lines.push("");
    lines.push(`panel_only (panel has it, opnfwd doesn't): ${(r.panel_only || []).length}`);
    for (const x of (r.panel_only || [])) {
      lines.push(`  ${x.instance_id}/${x.port_name}  → ${x.target_ip}:${x.host_port}  nat=${(x.nat_uuid||"").slice(0,8)}`);
    }
    lines.push("");
    lines.push(`opnfwd_only (panel- prefix, no panel row): ${(r.opnfwd_only || []).length}`);
    for (const x of (r.opnfwd_only || [])) {
      lines.push(`  ${x.name}  → ${x.target}:${x.port} (${x.proto})  nat=${(x.nat_uuid||"").slice(0,8)}`);
    }
    out.textContent = lines.join("\n");
    // Refresh the tracked count after reconcile.
    try {
      const f = await fetchJSON("/api/admin/opnfwd/forwards");
      $("#set-opnfwd-tracked").textContent = String((f.forwards || []).length) + " tracked";
    } catch (e) {}
  } catch (err) {
    out.textContent = "Reconcile failed: " + err.message;
  }
});

// =============== MOBILE NAV DRAWER ===============
// Below 900px the sidebar lives off-canvas. The hamburger toggles a body
// class which slides it in. Backdrop click / link click / Esc closes it.
// The backdrop uses pointer-down + pointer-up tracking so an accidental
// drag-out from inside the drawer (e.g. text selection) doesn't dismiss.
function _openMobileDrawer() { document.body.classList.add("sidebar-open"); }
function _closeMobileDrawer() { document.body.classList.remove("sidebar-open"); }
const _hamburger = $("#topbar-menu");
if (_hamburger) _hamburger.addEventListener("click", () => {
  if (document.body.classList.contains("sidebar-open")) _closeMobileDrawer();
  else _openMobileDrawer();
});
const _sidebarBackdrop = $("#sidebar-backdrop");
if (_sidebarBackdrop) {
  let _bdDownOnBackdrop = false;
  _sidebarBackdrop.addEventListener("pointerdown", e => {
    _bdDownOnBackdrop = (e.target === _sidebarBackdrop);
  });
  _sidebarBackdrop.addEventListener("pointerup", e => {
    if (_bdDownOnBackdrop && e.target === _sidebarBackdrop) _closeMobileDrawer();
    _bdDownOnBackdrop = false;
  });
}
// Close the drawer when any sidebar link is clicked (so navigation +
// scroll-to-anchor all collapse the menu after action).
document.querySelectorAll(".sidebar a, .sidebar button").forEach(el => {
  el.addEventListener("click", () => {
    if (window.matchMedia("(max-width: 900px)").matches) _closeMobileDrawer();
  });
});
// Esc closes the drawer if it's open AND no modal is on top.
document.addEventListener("keydown", e => {
  if (e.key === "Escape" && document.body.classList.contains("sidebar-open")) {
    const anyModal = [...document.querySelectorAll(".modal-bg")].some(m => m.style.display === "flex");
    if (!anyModal) _closeMobileDrawer();
  }
});

// =============== CARD "MORE" MENU ===============
// The overhauled card shows all primary/secondary actions inline (no more
// click-to-flip strip). Rare/dangerous actions (Backup / Force-kill /
// Delete) live in a small popover toggled by the ⋯ button — opened in the
// card-actions delegate above. Here we just handle dismissal: any click
// outside an open menu, or Esc, closes it.
function _closeCardMenus() {
  document.querySelectorAll(".svr-act-menu:not([hidden])").forEach(m => {
    m.setAttribute("hidden", "");
    m.classList.remove("drop-up");
    const wrap = m.closest(".svr-act-more-wrap");
    const btn = wrap && wrap.querySelector("[data-card-more]");
    if (btn) btn.setAttribute("aria-expanded", "false");
  });
}
document.addEventListener("click", (e) => {
  // The ⋯ toggle + the menu items are handled by the card-actions
  // delegate (which calls _closeCardMenus where appropriate). Here we only
  // need to catch clicks that land OUTSIDE any more-wrap and dismiss.
  if (e.target.closest(".svr-act-more-wrap")) return;
  _closeCardMenus();
});
document.addEventListener("keydown", (e) => {
  if (e.key !== "Escape") return;
  if (document.querySelector(".svr-act-menu:not([hidden])")) { _closeCardMenus(); e.stopPropagation(); }
});

// =============== CLICK-OUTSIDE TO DISMISS (selection-safe) ===============
// For each modal-bg, close the modal when the user clicks the dim area
// AROUND the inner panel — but ONLY when the pointer-down AND pointer-up
// both happened on the bg, not on the panel inside. This protects accidental
// drag-out-of-modal during text selection from dismissing the modal.
//
// Modals can opt out with `data-no-backdrop-close` on the .modal-bg.
function _wireBackdropClose(bg) {
  let downedOnBg = false;
  bg.addEventListener("pointerdown", (e) => {
    downedOnBg = (e.target === bg);
  });
  bg.addEventListener("pointerup", (e) => {
    const wasOnBg = downedOnBg && (e.target === bg);
    downedOnBg = false;
    if (!wasOnBg) return;
    if (bg.dataset.noBackdropClose !== undefined) return;
    if (bg.id) hideModal("#" + bg.id);
  });
}
document.querySelectorAll(".modal-bg").forEach(_wireBackdropClose);
// Some modals are created lazily (e.g. env-config confirm dialog). The
// helper is exposed so any new modal can call _wireBackdropClose(bg) once
// inserted into the DOM.
window._wireBackdropClose = _wireBackdropClose;

// =============== PULL-TO-REFRESH (mobile / PWA) ===============
// PWAs (Add to Home Screen on iOS Safari, install on Android Chrome)
// have no browser chrome, so there's no reload button. A drag-down
// gesture from the top of the page triggers a soft refresh — we re-run
// pollAgents + pollInstances + pollModules, which is what a manual
// reload would otherwise force. Animated indicator slides down with the
// pull, snaps when past the threshold, spins during the refresh.
(() => {
  if (!("ontouchstart" in window) && !("onpointerdown" in window)) return;
  const indicator = document.getElementById("ptr-indicator");
  if (!indicator) return;

  const PULL_THRESHOLD = 72;   // pixels of *visible* pull required to trigger
  const RESISTANCE     = 0.55; // pulled distance is this fraction of finger travel
  const MAX_VISIBLE    = 110;  // cap on indicator translation so it doesn't fly off
  const scroller = document.scrollingElement || document.documentElement;

  let startY = 0;
  let pulling = false;
  let lastVisible = 0;
  let armed = false;

  const isModalOpen = () =>
    [...document.querySelectorAll(".modal-bg")].some(m => m.style.display === "flex");
  const isDrawerOpen = () => document.body.classList.contains("sidebar-open");

  const setIndicator = (visiblePx) => {
    const v = Math.min(MAX_VISIBLE, Math.max(0, visiblePx));
    lastVisible = v;
    indicator.style.transform = `translateY(${v - 70}px) translateX(-50%)`;
    indicator.style.opacity = String(Math.min(1, v / 32));
    indicator.classList.toggle("ready", v >= PULL_THRESHOLD);
  };

  const reset = (animate) => {
    if (animate) {
      indicator.style.transition = "transform .28s var(--ease-out), opacity .25s";
      requestAnimationFrame(() => {
        indicator.style.transform = "translateY(-70px) translateX(-50%)";
        indicator.style.opacity = "0";
        indicator.classList.remove("ready");
      });
      setTimeout(() => { indicator.style.transition = ""; }, 320);
    } else {
      indicator.style.transition = "";
      indicator.style.transform = "translateY(-70px) translateX(-50%)";
      indicator.style.opacity = "0";
      indicator.classList.remove("ready");
    }
  };

  const doRefresh = async () => {
    indicator.classList.add("loading");
    indicator.style.transform = "translateY(16px) translateX(-50%)";
    indicator.style.opacity = "1";
    try {
      const calls = [];
      if (typeof pollAgents    === "function") calls.push(pollAgents());
      if (typeof pollInstances === "function") calls.push(pollInstances());
      if (typeof pollModules   === "function") calls.push(pollModules());
      await Promise.all(calls);
      if (typeof toast === "function") toast("Refreshed", "ok");
    } catch (err) {
      if (typeof toast === "function") toast("Refresh failed: " + (err && err.message || "unknown"), "err");
    } finally {
      indicator.classList.remove("loading");
      reset(true);
    }
  };

  // Pull is only allowed when:
  // - Body is at scrollTop 0
  // - No modal / drawer open
  // - The touch target isn't inside a scrollable child that has its own scrollTop > 0
  // - On a viewport that benefits from pull-to-refresh (mobile / standalone PWA)
  const inStandalone = window.matchMedia("(display-mode: standalone)").matches
                    || window.navigator.standalone === true;
  const wantPTR = () => inStandalone || window.matchMedia("(max-width: 900px)").matches;
  const touchInScrolledChild = (target) => {
    let el = target;
    while (el && el !== document.body) {
      if (el.scrollTop && el.scrollTop > 0) {
        const cs = getComputedStyle(el);
        if (/(auto|scroll)/.test(cs.overflowY)) return true;
      }
      el = el.parentElement;
    }
    return false;
  };

  document.addEventListener("touchstart", (e) => {
    if (!wantPTR()) return;
    if (e.touches.length !== 1) return;
    if (scroller.scrollTop > 0) return;
    if (isModalOpen() || isDrawerOpen()) return;
    if (touchInScrolledChild(e.target)) return;
    startY = e.touches[0].clientY;
    pulling = true;
    armed = false;
    lastVisible = 0;
  }, { passive: true });

  document.addEventListener("touchmove", (e) => {
    if (!pulling) return;
    const dy = e.touches[0].clientY - startY;
    if (dy <= 0) {
      // User changed direction — abort.
      if (armed) reset(true);
      pulling = false; armed = false;
      return;
    }
    armed = true;
    setIndicator(dy * RESISTANCE);
  }, { passive: true });

  document.addEventListener("touchend", () => {
    if (!pulling) return;
    pulling = false;
    if (lastVisible >= PULL_THRESHOLD) doRefresh();
    else if (armed) reset(true);
    armed = false;
  }, { passive: true });

  // Cancellation (e.g. multi-touch resumes / system gesture) — fold gracefully.
  document.addEventListener("touchcancel", () => {
    if (pulling && armed) reset(true);
    pulling = false; armed = false;
  }, { passive: true });
})();

// =============== CONAN EXILES MODS TAB ===============
// Steam Workshop-backed mod browser for Conan Exiles. Same async-install
// pattern as the DayZ Mods tab — debounced hero search, ID/URL direct
// install, live job-progress cards, installed-mod list with one-click
// uninstall. Backend lives in controller/cmd/controller/conanmods.go and
// reuses the dayzModJobManager (job state is generic per InstanceID, no
// cross-instance bleed). Reuses .dzm-* CSS classes for visual cohesion
// with the DayZ tab — operators get the same shape.
const conanModsState = {
  searchTerm: "",
  searchDebounce: null,
  searchController: null,
  searchResults: [],
  searchPreview: null,
  mods: [],
  jobs: [],
  jobPollTimer: null,
  sinceOpenAt: 0,
  // edition: "enhanced" | "legacy" — set on tab open from the instance's
  // config_values["EDITION"]. Drives the workshop search tag filter so a
  // UE5 server only sees Enhanced-tagged mods (UE4 mods load silently
  // but don't run; filtering them out at search time keeps operators
  // from chasing dead installs). Defaults to "enhanced" when the field
  // hasn't been set yet (matches the module.yaml default).
  edition: "enhanced",
};

// conanWorkshopTagForEdition translates "enhanced"/"legacy" → the actual
// Steam Workshop tag names ("Enhanced"/"Legacy") used in requiredtags[]
// URL params on the steamcommunity browse page. Centralised so we can
// add other editions if Funcom ever ships one (Hyborian, who knows).
function conanWorkshopTagForEdition(ed) {
  switch ((ed || "").toLowerCase()) {
    case "enhanced": return "Enhanced";
    case "legacy":   return "Legacy";
    default:         return "Enhanced"; // safest default for an unknown value
  }
}

async function loadConanModsTab() {
  const id = instanceModalState.id; const wrap = $("#im-cnm-wrap"); if (!id) return;
  conanModsState.sinceOpenAt = Date.now() - 2000;
  // Pull the instance's edition from the live cache so the workshop
  // search filter matches what the server can actually load. Falls back
  // to "enhanced" when config_values hasn't been touched (matches the
  // module's default — Funcom's current build).
  const inst = (allInstances || []).find(i => i.instance_id === id);
  const cv = (inst && inst.config_values) || {};
  conanModsState.edition = (cv.EDITION || "enhanced").toLowerCase();
  wrap.innerHTML = loadingState("Loading mod list…");
  try {
    const [mods, jobs] = await Promise.all([
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`),
      fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`).catch(() => ({jobs:[]})),
    ]);
    conanModsState.mods = mods.mods || [];
    conanModsState.jobs = jobs.jobs || [];
  } catch (err) {
    wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`);
    return;
  }
  renderConanModsTab();
  startConanModJobPolling();
}

function visibleConanJobs() {
  const cutoff = conanModsState.sinceOpenAt || 0;
  return (conanModsState.jobs || []).filter(j => {
    if (!j.terminal) return true;
    if (!j.ended_at) return true;
    return new Date(j.ended_at).getTime() >= cutoff;
  });
}

function startConanModJobPolling() {
  if (conanModsState.jobPollTimer) return;
  // Same backoff shape as the DayZ job poll: 800ms hot, 2s after 10
  // consecutive identical payloads, snap back to 800ms on any change.
  let idleTicks = 0, lastJobsJSON = "", curMs = 800;
  const rearm = (ms) => {
    if (curMs === ms || !conanModsState.jobPollTimer) return;
    curMs = ms;
    clearInterval(conanModsState.jobPollTimer);
    conanModsState.jobPollTimer = setInterval(tick, ms);
  };
  const tick = async () => {
    const id = instanceModalState.id;
    if (!id || instanceModalState.tab !== "conanmods") { stopConanModJobPolling(); return; }
    try {
      const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`);
      const prev = conanModsState.jobs;
      conanModsState.jobs = r.jobs || [];
      const jobsJSON = JSON.stringify(conanModsState.jobs);
      if (jobsJSON === lastJobsJSON) {
        if (++idleTicks >= 10) rearm(2000);
      } else {
        lastJobsJSON = jobsJSON; idleTicks = 0; rearm(800);
      }
      const wasActive = jid => prev.find(p => p.id === jid && !p.terminal);
      for (const j of conanModsState.jobs) {
        if (j.terminal && j.phase === "done" && wasActive(j.id)) {
          try {
            const m = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`);
            conanModsState.mods = m.mods || [];
          } catch {}
          break;
        }
        if (j.terminal && j.phase === "login_needed" && wasActive(j.id)) {
          // Re-pop the Steam login modal so the operator can re-auth without
          // hunting for the Update button. Same UX the DayZ flow uses.
          const ok = await openSteamLoginModal(j.error || "Steam session expired.");
          if (ok) runConanModInstallRaw(j.workshop_id);
        }
      }
      renderConanModJobs();
      if (!conanModsState.jobs.some(j => !j.terminal)) {
        setTimeout(() => {
          if (!conanModsState.jobs.some(j => !j.terminal)) stopConanModJobPolling();
        }, 3000);
      }
    } catch (err) {
      console.warn("conan job poll failed", err);
    }
  };
  tick();
  conanModsState.jobPollTimer = setInterval(tick, 800);
}

function stopConanModJobPolling() {
  if (conanModsState.jobPollTimer) {
    clearInterval(conanModsState.jobPollTimer);
    conanModsState.jobPollTimer = null;
  }
}

function renderConanModsTab() {
  const wrap = $("#im-cnm-wrap");
  const mods = conanModsState.mods;

  // Group by workshop_id so a multi-pak mod renders as one card with
  // sub-pak badges. Operators install at the workshop-id level — they
  // shouldn't have to think about which paks ship together.
  const groups = {};
  const order = [];
  for (const m of mods) {
    if (!groups[m.workshop_id]) { groups[m.workshop_id] = []; order.push(m.workshop_id); }
    groups[m.workshop_id].push(m);
  }
  const installedRow = wsID => {
    const items = groups[wsID];
    const head = items[0];
    const paks = items.map(it => `<span class="mono" style="font-size:10.5px;background:#0007;padding:1px 6px;border-radius:3px">${esc(it.filename)}</span>`).join(" ");
    const thumb = head.preview_url
      ? `<img src="${esc(head.preview_url)}" alt="" loading="lazy" style="width:42px;height:42px;border-radius:var(--radius);object-fit:cover;flex-shrink:0">`
      : `<div style="width:42px;height:42px;border-radius:var(--radius);background:#222;display:flex;align-items:center;justify-content:center;flex-shrink:0">🧩</div>`;
    return `
    <div class="dzm-mod-row">
      <div class="dzm-mod-row-main" style="display:flex;gap:10px;align-items:center;min-width:0">
        ${thumb}
        <div style="min-width:0;flex:1">
          <div class="dzm-mod-row-title">${esc(head.title || `Workshop #${wsID}`)}</div>
          <div class="dzm-mod-row-sub" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
            <button class="small" data-conan-info="${esc(wsID)}" style="font-size:10.5px;padding:1px 7px" title="Mod info">${esc(wsID)}</button>
            ${paks}
            ${head.size ? `<span class="dim" style="font-size:10.5px">${humanBytes(head.size)}</span>` : ''}
          </div>
        </div>
      </div>
      <button class="small danger" data-conan-uninstall="${esc(wsID)}" title="Uninstall (keeps download cached for re-install)">✕</button>
    </div>`;
  };

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">

    <!-- Hero search. Mirrors the DayZ tab — paste a Workshop URL/ID for
         a direct preview, or type to live-search. The edition badge on
         the right reflects this server's UE4/UE5 setting and gates the
         search results to compatible mods. -->
    <div class="panel" style="padding:14px">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px">
        <div style="flex:1;min-width:240px">
          <div style="font-weight:600;font-size:14px">Steam Workshop · Conan Exiles</div>
          <div class="dim" style="font-size:12px;margin-top:2px">Search by name, or paste a Workshop ID / URL to install directly. Downloads are shared across every Conan instance on this agent.</div>
        </div>
        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;font-size:11px">
          <span style="padding:3px 10px;border-radius:var(--radius);background:${conanModsState.edition === 'legacy' ? 'rgba(234,179,8,0.18)' : 'rgba(34,197,94,0.18)'};color:${conanModsState.edition === 'legacy' ? 'var(--warn)' : 'var(--ok)'};font-weight:600;border:1px solid ${conanModsState.edition === 'legacy' ? 'rgba(234,179,8,0.35)' : 'rgba(34,197,94,0.35)'}" title="This server is configured to run the ${conanModsState.edition === 'legacy' ? 'UE4 (Legacy)' : 'UE5 (Enhanced)'} build. Workshop search hides incompatible mods. Change in Config tab → Edition.">
            ${conanModsState.edition === 'legacy' ? '🏛️ UE4 · Legacy' : '✨ UE5 · Enhanced'}
          </span>
          <span class="dim" style="font-size:10.5px">filtering search by tag</span>
        </div>
      </div>
      <div class="dzm-search-wrap">
        <span class="dzm-search-icon">🔎</span>
        <input id="im-cnm-search-input" class="dzm-search-input" placeholder="Search for a mod — try Pippi, Savage Wilds, Age of Calamitous, Funcom Exiles…"
               value="${esc(conanModsState.searchTerm)}" spellcheck="false" autocomplete="off" />
        <div class="dzm-search-spin" id="im-cnm-search-spin" aria-hidden="true"></div>
        <button class="dzm-search-clear" id="im-cnm-search-clear" title="Clear" style="display:${conanModsState.searchTerm ? 'block':'none'}">✕</button>
      </div>
      <div id="im-cnm-search-results" style="margin-top:12px"></div>
    </div>

    <!-- Active + recent downloads. -->
    <div id="im-cnm-jobs"></div>

    <!-- Installed mods. -->
    <div class="panel" style="padding:14px">
      <div class="dzm-section">
        <h4>🧩 Installed mods <span class="dim">(${order.length})</span></h4>
        <span class="dim" style="font-size:11px">listed in modlist.txt load order</span>
      </div>
      ${order.length
        ? `<div class="dzm-list">${order.map(installedRow).join('')}</div>`
        : `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.puzzle}</div>No mods installed yet. Search above to add one.</div>`}
      <div class="dim" style="font-size:11px;margin-top:10px">
        Tip: clients connecting to this server are prompted to subscribe to the same workshop items in the in-game launcher; Conan auto-downloads them on join.
      </div>
    </div>
  </div>`;

  const input = $("#im-cnm-search-input");
  input.addEventListener("input", onConanSearchInput);
  input.addEventListener("keydown", e => {
    if (e.key === "Enter") {
      const v = input.value.trim();
      const m = v.match(/(\d{6,20})/);
      if (m) { runConanModInstallRaw(m[1]); input.value = ""; renderConanSearchResults(); }
    } else if (e.key === "Escape") {
      input.value = ""; onConanSearchInput({ target: input });
    }
  });
  $("#im-cnm-search-clear").addEventListener("click", () => {
    input.value = ""; input.focus(); onConanSearchInput({ target: input });
  });
  $$("#im-cnm-wrap [data-conan-uninstall]").forEach(b => b.addEventListener("click", () => runConanModUninstall(b.dataset.conanUninstall)));
  $$("#im-cnm-wrap [data-conan-info]").forEach(b => b.addEventListener("click", () => openConanModInfoModal(b.dataset.conanInfo)));

  renderConanSearchResults();
  renderConanModJobs();
  if (!conanModsState.searchTerm) setTimeout(() => input && input.focus(), 50);
}

function onConanSearchInput(e) {
  const v = (e.target.value || "").trim();
  conanModsState.searchTerm = v;
  const clear = $("#im-cnm-search-clear");
  if (clear) clear.style.display = v ? "block" : "none";
  clearTimeout(conanModsState.searchDebounce);
  if (conanModsState.searchController) { try { conanModsState.searchController.abort(); } catch {} }
  if (!v) {
    conanModsState.searchResults = [];
    conanModsState.searchPreview = null;
    toggleConanSearchSpin(false);
    renderConanSearchResults();
    return;
  }
  toggleConanSearchSpin(true);
  conanModsState.searchDebounce = setTimeout(() => runConanLiveSearch(v), 300);
}

function toggleConanSearchSpin(on) {
  const s = $("#im-cnm-search-spin");
  if (s) s.classList.toggle("on", !!on);
}

async function runConanLiveSearch(q) {
  const id = instanceModalState.id; if (!id) return;
  const ctrl = new AbortController();
  conanModsState.searchController = ctrl;
  try {
    // ID-only or full URL with ?id= → fetch detail directly.
    const idMatch = q.match(/^\s*(?:https?:\/\/\S*\?id=)?(\d{6,20})\s*$/);
    if (idMatch) {
      const wsID = idMatch[1];
      const d = await fetch(`/api/workshop/detail?id=${encodeURIComponent(wsID)}`, { signal: ctrl.signal })
        .then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      // Normalise: detail endpoint returns a workshopDetail object directly.
      d.workshop_id = wsID;
      conanModsState.searchPreview = d;
      conanModsState.searchResults = [];
    } else {
      // The reused /api/workshop/search now takes ?app=440900 (Conan) +
      // ?tag=Enhanced|Legacy to filter results to mods compatible with
      // this instance's edition. Without the filter operators on a UE5
      // Enhanced server see piles of [Legacy] UE4 mods that load
      // silently but don't actually run.
      const tag = conanWorkshopTagForEdition(conanModsState.edition);
      const d = await fetch(`/api/workshop/search?app=440900&tag=${encodeURIComponent(tag)}&q=${encodeURIComponent(q)}`, { signal: ctrl.signal })
        .then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      conanModsState.searchResults = d.items || [];
      conanModsState.searchPreview = null;
    }
  } catch (err) {
    if (err.name === "AbortError") return;
    console.warn("conan live search failed", err);
  } finally {
    if (conanModsState.searchController === ctrl) {
      conanModsState.searchController = null;
      toggleConanSearchSpin(false);
    }
  }
  renderConanSearchResults();
}

function renderConanSearchResults() {
  const host = $("#im-cnm-search-results"); if (!host) return;
  const q = conanModsState.searchTerm || "";
  if (!q) { host.innerHTML = ""; return; }
  if (conanModsState.searchPreview) {
    const d = conanModsState.searchPreview;
    const installed = conanModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id || d.id));
    const wsID = d.workshop_id || d.id;
    host.innerHTML = `
      <div class="dzm-preview" style="display:flex;gap:14px;align-items:flex-start;background:#0006;padding:14px;border-radius:var(--radius)">
        ${d.preview_url ? `<img src="${esc(d.preview_url)}" alt="" style="width:160px;height:160px;border-radius:var(--radius);object-fit:cover;flex-shrink:0">` : ''}
        <div style="flex:1;min-width:0">
          <div style="font-weight:600;font-size:15px">${esc(d.title || `Workshop #${wsID}`)}</div>
          <div class="dim" style="font-size:11px;margin-top:2px">id ${esc(wsID)} · ${d.size ? humanBytes(d.size) : 'size unknown'}${d.subscriptions ? ` · ${d.subscriptions.toLocaleString()} subscribers` : ''}</div>
          <div style="margin-top:8px;font-size:12px;max-height:80px;overflow:hidden;line-height:1.4;color:#cbd5e1">${esc((d.description || '').slice(0, 320))}${(d.description||'').length > 320 ? '…' : ''}</div>
          <div style="margin-top:10px;display:flex;gap:8px">
            <button class="small" id="im-cnm-preview-info">More info</button>
            ${installed
              ? `<button class="small" disabled>Already installed</button>`
              : `<button class="primary" id="im-cnm-preview-install">Install</button>`}
          </div>
        </div>
      </div>`;
    const ib = $("#im-cnm-preview-install");
    if (ib) ib.addEventListener("click", () => runConanModInstallRaw(wsID));
    const info = $("#im-cnm-preview-info");
    if (info) info.addEventListener("click", () => openConanModInfoModal(wsID));
    return;
  }
  const items = conanModsState.searchResults || [];
  if (!items.length) {
    host.innerHTML = `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.search}</div>No results for "${esc(q)}". Try a shorter term, or paste a Workshop URL.</div>`;
    return;
  }
  const installed = new Set(conanModsState.mods.map(m => String(m.workshop_id)).filter(Boolean));
  const card = it => {
    const wsID = String(it.id);
    const isInst = installed.has(wsID);
    return `
    <div class="dzm-card" data-conan-card="${esc(wsID)}" style="cursor:pointer">
      ${it.preview_url ? `<img src="${esc(it.preview_url)}" alt="" loading="lazy">` : '<div class="dzm-card-thumb-placeholder">🧩</div>'}
      <div class="dzm-card-body">
        <div class="dzm-card-title">${esc(it.title || `Workshop #${wsID}`)}</div>
        <div class="dim" style="font-size:11px">${it.size ? humanBytes(it.size) : ''}${it.subscriptions ? ` · ${it.subscriptions.toLocaleString()}↓` : ''}</div>
        <div style="margin-top:8px">
          ${isInst
            ? `<button class="small" disabled>Installed</button>`
            : `<button class="small primary" data-conan-install-search="${esc(wsID)}">Install</button>`}
        </div>
      </div>
    </div>`;
  };
  host.innerHTML = `<div class="dzm-grid">${items.slice(0, 30).map(card).join("")}</div>`;
  $$("#im-cnm-search-results [data-conan-card]").forEach(c =>
    c.addEventListener("click", e => {
      if (e.target.closest("[data-conan-install-search]")) return;
      openConanModInfoModal(c.dataset.conanCard);
    }));
  $$("#im-cnm-search-results [data-conan-install-search]").forEach(b =>
    b.addEventListener("click", e => { e.stopPropagation(); runConanModInstallRaw(b.dataset.conanInstallSearch); }));
}

function renderConanModJobs() {
  const host = $("#im-cnm-jobs"); if (!host) return;
  const jobs = visibleConanJobs();
  if (!jobs.length) { host.innerHTML = ""; return; }
  const card = j => {
    const phaseClass = j.terminal
      ? (j.phase === "done" ? "ok" : j.phase === "login_needed" ? "warn" : "err")
      : "active";
    const pct = Math.max(0, Math.min(100, j.percent || 0));
    const bar = pct > 0
      ? `<div class="dzm-job-bar"><div class="dzm-job-bar-fill" style="width:${pct.toFixed(1)}%"></div></div>`
      : (!j.terminal ? `<div class="dzm-job-bar shimmer"></div>` : '');
    const lines = (j.lines || []).slice(-5).map(l => `<div>${esc(l)}</div>`).join("");
    return `
    <div class="dzm-job-card ${phaseClass}">
      <div style="display:flex;justify-content:space-between;gap:10px;align-items:center">
        <div style="min-width:0;flex:1">
          <div style="font-weight:600">${esc(j.title || `Workshop #${j.workshop_id}`)}</div>
          <div class="dim" style="font-size:11px">id ${esc(j.workshop_id)} · ${esc(j.phase)}${j.attempt && j.attempt > 1 ? ` · retry #${j.attempt}` : ''}</div>
        </div>
        <div style="display:flex;gap:6px">
          ${j.can_retry ? `<button class="small" data-conan-job-retry="${esc(j.id)}">Retry</button>` : ''}
          ${j.terminal ? `<button class="small" data-conan-job-dismiss="${esc(j.id)}">✕</button>` : ''}
        </div>
      </div>
      ${bar}
      ${j.error ? `<div class="dzm-job-err">${esc(j.error)}</div>` : ''}
      ${lines ? `<details style="margin-top:6px"><summary class="dim" style="font-size:11px;cursor:pointer">log tail</summary><div class="mono dzm-job-log">${lines}</div></details>` : ''}
    </div>`;
  };
  host.innerHTML = `<div class="dzm-jobs">${jobs.map(card).join("")}</div>`;
  $$("#im-cnm-jobs [data-conan-job-dismiss]").forEach(b =>
    b.addEventListener("click", async () => {
      const id = instanceModalState.id;
      try {
        await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs/${encodeURIComponent(b.dataset.conanJobDismiss)}`, { method: "DELETE" });
        conanModsState.jobs = conanModsState.jobs.filter(j => j.id !== b.dataset.conanJobDismiss);
        renderConanModJobs();
      } catch {}
    }));
  $$("#im-cnm-jobs [data-conan-job-retry]").forEach(b =>
    b.addEventListener("click", () => {
      const job = conanModsState.jobs.find(j => j.id === b.dataset.conanJobRetry);
      if (job) runConanModInstallRaw(job.workshop_id);
    }));
}

async function runConanModInstallRaw(workshopID) {
  const id = instanceModalState.id; if (!id || !workshopID) return;
  try {
    let r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`,
      { method: "POST", body: JSON.stringify({ workshop_id: String(workshopID) }) });
    if (r && r.steam_login_required) {
      const ok = await openSteamLoginModal(r.reason || "Sign in to Steam to download workshop content.");
      if (!ok) return;
      // Retry once after successful login.
      r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`,
        { method: "POST", body: JSON.stringify({ workshop_id: String(workshopID) }) });
    }
    toast(r && r.duplicate ? `Already installing ${r.title || workshopID}` : `Queued ${r && r.title ? r.title : workshopID}`, "ok");
    startConanModJobPolling();
    // Refresh jobs immediately so the operator sees their queued card.
    try {
      const jr = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`);
      conanModsState.jobs = jr.jobs || [];
      renderConanModJobs();
    } catch {}
  } catch (err) {
    toast(`Install failed: ${err.message}`, "err");
  }
}

async function runConanModUninstall(workshopID) {
  const id = instanceModalState.id; if (!id || !workshopID) return;
  if (!confirm(`Uninstall workshop item ${workshopID}? The downloaded files stay cached on the agent so re-install is instant.`)) return;
  try {
    await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods/${encodeURIComponent(workshopID)}`, { method: "DELETE" });
    conanModsState.mods = conanModsState.mods.filter(m => m.workshop_id !== workshopID);
    renderConanModsTab();
    toast(`Uninstalled ${workshopID}`, "ok");
  } catch (err) {
    toast(`Uninstall failed: ${err.message}`, "err");
  }
}

// Rich mod info modal for Conan — same shape + visual density as the
// DayZ tab's openModInfoModal: thumbnail + stats sidebar on the left,
// BBCode-rendered description + tags on the right, footer with the
// install action. Reuses the existing #mod-info-modal dialog (it's the
// same DOM the DayZ tab uses; we just paint different button wiring).
async function openConanModInfoModal(workshopID) {
  if (!workshopID) return;
  const body = $("#mi-body");
  if (!body) {
    // Modal not present (older HTML?). Fall back to console error.
    console.warn("openConanModInfoModal: #mi-body missing");
    return;
  }
  $("#mi-title").textContent = "Mod info";
  body.innerHTML = loadingState("Loading Steam Workshop details…");
  showModal("#mod-info-modal");
  let d;
  try {
    d = await fetchJSON(`/api/workshop/detail?id=${encodeURIComponent(workshopID)}`);
  } catch (err) {
    body.innerHTML = errorState(`Couldn't load details: ${err.message}`);
    return;
  }
  $("#mi-title").textContent = d.title || "Mod info";
  const installed = conanModsState.mods.some(m => String(m.workshop_id) === String(workshopID));
  const fmtSize = (b) => {
    if (!b) return "?";
    const mb = b / 1048576;
    if (mb < 1) return `${Math.round(b/1024)} KB`;
    if (mb < 1024) return `${mb.toFixed(1)} MB`;
    return `${(mb/1024).toFixed(2)} GB`;
  };
  const fmtDate = (unix) => {
    if (!unix) return "—";
    const dt = new Date(unix * 1000);
    return dt.toLocaleDateString() + " " + dt.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
  };
  const fmtNum = (n) => {
    n = Number(n) || 0;
    if (n < 1000) return String(n);
    if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k";
    return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M";
  };
  // Steam Workshop descriptions are BBCode. Reuse the same conservative
  // transform as the DayZ tab: only [b][i][u][h*][url=https://…] are
  // honored, all other tags stripped, newlines → <br>. [img] is dropped
  // on purpose — too easy to abuse with on-modal-load network exfil.
  const renderDesc = (s) => {
    if (!s) return "<i>No description.</i>";
    let t = esc(s);
    t = t.replace(/\[url=(https?:\/\/[^\s\]]+)\]([^\[]*)\[\/url\]/gi,
      (_, u, inner) => `<a href="${u}" target="_blank" rel="noopener">${inner || u}</a>`);
    t = t.replace(/\[h[1-6]\](.*?)\[\/h[1-6]\]/gi, "<b>$1</b>");
    t = t.replace(/\[b\](.*?)\[\/b\]/gi, "<b>$1</b>");
    t = t.replace(/\[i\](.*?)\[\/i\]/gi, "<i>$1</i>");
    t = t.replace(/\[u\](.*?)\[\/u\]/gi, "<u>$1</u>");
    t = t.replace(/\[\/?[a-z][^\]]*\]/gi, "");
    t = t.replace(/\n/g, "<br>");
    return t;
  };

  body.innerHTML = `
    <div style="display:grid;grid-template-columns:240px 1fr;gap:16px;align-items:start">
      <div>
        ${d.preview_url ? `<img src="${esc(d.preview_url)}" alt="" style="width:100%;border-radius:var(--radius);background:#111;display:block">` : ''}
        <div style="margin-top:10px;display:flex;flex-direction:column;gap:6px;font-size:12px">
          <div style="display:flex;justify-content:space-between"><span class="dim">Size</span><span class="mono">${fmtSize(d.size)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Updated</span><span>${fmtDate(d.time_updated)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Created</span><span>${fmtDate(d.time_created)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Subscribers</span><span>${fmtNum(d.subscriptions)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Favorites</span><span>${fmtNum(d.favorited)}</span></div>
          <div style="display:flex;justify-content:space-between"><span class="dim">Views</span><span>${fmtNum(d.views)}</span></div>
        </div>
        <div style="margin-top:10px;font-size:12px">
          <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${esc(workshopID)}" target="_blank" rel="noopener">↗ Open on Steam</a>
        </div>
      </div>
      <div style="min-width:0">
        ${(d.tags || []).length ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:6px">
          ${d.tags.map(tagChip).join("")}
        </div>` : ''}
        <div style="font-size:12.5px;line-height:1.55;max-height:340px;overflow:auto;padding-right:6px;white-space:normal">
          ${renderDesc(d.description)}
        </div>
      </div>
    </div>
    <div style="margin-top:16px;display:flex;gap:10px;justify-content:flex-end;align-items:center">
      <button data-close>Close</button>
      ${installed
        ? installedBadge()
        : `<button class="primary" id="mi-cnm-install">⬇ Install</button>`}
    </div>`;
  const btn = $("#mi-cnm-install");
  if (btn) btn.addEventListener("click", async () => {
    btn.disabled = true; btn.textContent = "⏳ Queuing…";
    try {
      await runConanModInstallRaw(workshopID);
      hideModal("#mod-info-modal");
    } catch (err) {
      btn.disabled = false; btn.textContent = "⬇ Install";
      toast(`Install failed: ${err.message}`, "err");
    }
  });
}

// Tiny byte-formatter — shared with the DayZ tab via window scope but
// declared here defensively so the Conan tab still works if loaded
// before the DayZ helpers.
if (typeof window.humanBytes !== "function") {
  window.humanBytes = function(n) {
    if (!n || n < 1024) return `${n||0} B`;
    if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
    if (n < 1024*1024*1024) return `${(n/(1024*1024)).toFixed(1)} MB`;
    return `${(n/(1024*1024*1024)).toFixed(2)} GB`;
  };
}

// =============== 7DTD MODS TAB ===============
// CurseForge-backed mod browser for 7 Days to Die. Same visual UI as
// the ARK Mods tab — search bar, results grid, click for details.
// Differs in install: 7DTD has no built-in CurseForge downloader, so
// the operator downloads the zip from CurseForge in a new tab and
// drops it into the Files tab → Game Files → Mods/. Auto-install
// (download + extract via agent sidecar) is a Phase 2 follow-up.
const sdtdModsState = {
  searchTerm: "",
  searchResults: [],
  searchPreview: null,
  searchDebounce: null,
  searchController: null,
  installed: [],   // [{name}] — folders under /game/Mods/
  nexusKey: { configured: false, premium: false, name: "" },
  installing: false,
};

async function _sdtdCheckNexusKey() {
  try {
    const r = await fetchJSON("/api/7dtd/nexus/check");
    sdtdModsState.nexusKey = {
      configured: !!r.configured, premium: !!r.premium, name: r.name || "",
      error: r.error || ""
    };
  } catch {
    sdtdModsState.nexusKey = { configured: false, premium: false };
  }
}

async function loadSdtdModsTab() {
  const id = instanceModalState.id; const wrap = $("#im-sdtdm-wrap"); if (!id) return;
  wrap.innerHTML = loadingState("Loading installed mods…");
  await _sdtdCheckNexusKey();
  // Try to read /game/Mods/ on the agent. If the directory doesn't exist
  // yet (game not installed, or never had mods), surface that as an
  // empty list rather than an error.
  try {
    const q = new URLSearchParams({ path: "/game/Mods" });
    const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`).catch(() => ({entries: []}));
    sdtdModsState.installed = (r.entries || []).filter(e => e.is_dir).map(e => ({ name: e.name }));
  } catch (err) {
    sdtdModsState.installed = [];
  }
  renderSdtdModsTab();
}

function renderSdtdModsTab() {
  const wrap = $("#im-sdtdm-wrap");
  const installed = sdtdModsState.installed || [];
  const installedRow = (m) => `
    <div class="dzm-mod-row client">
      <div style="width:40px;height:40px;flex-shrink:0;border-radius:var(--radius);background:#111;display:flex;align-items:center;justify-content:center;font-size:18px">🧩</div>
      <div class="dzm-mod-row-main">
        <div class="dzm-mod-row-title">${esc(m.name)}</div>
        <div class="dzm-mod-row-sub">
          <span class="dim" style="font-size:11px">/game/Mods/${esc(m.name)}</span>
        </div>
      </div>
      <a class="small" href="#" data-sdtdm-open-files="${esc(m.name)}" title="Open in the Files tab">📁 Files</a>
    </div>`;

  wrap.innerHTML = `
  <div style="display:flex;flex-direction:column;gap:14px">
    <!-- Drop zone — primary install path. Drag a zip onto this, or click to browse. -->
    <div class="sdtd-upload-zone" id="im-sdtdm-drop">
      <div class="upload-arrow">⬇</div>
      <div class="upload-headline">Drop mod zip files here to install</div>
      <div class="upload-sub">
        Multiple zips at once · nested zips supported · auto-detects <span class="mono">ModInfo.xml</span> · already-installed mods are skipped automatically
      </div>
      <input type="file" id="im-sdtdm-file" multiple accept=".zip,application/zip,application/x-zip-compressed">
    </div>
    <div id="im-sdtdm-upload-status"></div>

    <div class="panel" style="padding:14px">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:10px">
        <div style="flex:1;min-width:240px">
          <div style="font-weight:600;font-size:14px">🔎 Browse Nexus Mods (7 Days to Die)</div>
          <div class="dim" style="font-size:12px;margin-top:2px">Find a mod on Nexus, download the zip, then drop it into the zone above. Auto-install via API was investigated but Nexus locks direct downloads behind Premium — stick with the drop zone.</div>
        </div>
      </div>
      <div class="dzm-search-wrap">
        <span class="dzm-search-icon">🔎</span>
        <input id="im-sdtdm-search-input" class="dzm-search-input" placeholder="Search Nexus — try Darkness Falls, Undead Legacy, War of the Walkers…"
               value="${esc(sdtdModsState.searchTerm)}" spellcheck="false" autocomplete="off" />
        <div class="dzm-search-spin" id="im-sdtdm-search-spin" aria-hidden="true"></div>
        <button class="dzm-search-clear" id="im-sdtdm-search-clear" title="Clear" style="display:${sdtdModsState.searchTerm ? 'block':'none'}">✕</button>
      </div>
      <div id="im-sdtdm-search-results" style="margin-top:12px"></div>
    </div>

    <div class="panel" style="padding:14px">
      <div class="dzm-section">
        <h4>📦 Installed mods <span class="dim">(${installed.length})</span></h4>
        <span class="dim" style="font-size:11px">scanned from <span class="mono">/game/Mods/</span></span>
      </div>
      ${installed.length
        ? `<div class="dzm-list">${installed.map(installedRow).join('')}</div>`
        : `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.box}</div>No mods detected in <code>/game/Mods/</code> yet.<span class="hint" style="margin-top:6px;display:block">Use the Files tab to drop a mod folder in, or grab one from the search above.</span></div>`}
    </div>

    ${_sdtdHelpBannerHTML()}
  </div>`;

  // Drop zone wiring — drag/drop, click to browse, multi-zip upload.
  _sdtdWireDropZone();

  const input = $("#im-sdtdm-search-input");
  input.addEventListener("input", onSdtdModSearchInput);
  input.addEventListener("keydown", e => {
    if (e.key === "Enter") {
      const v = input.value.trim();
      const m = v.match(/(\d{4,12})/);
      if (m) {
        // pasted a CurseForge URL or numeric id — fetch detail
        runSdtdLiveSearch(v);
      }
    } else if (e.key === "Escape") {
      input.value = ""; onSdtdModSearchInput({ target: input });
    }
  });
  $("#im-sdtdm-search-clear").addEventListener("click", () => {
    input.value = ""; input.focus(); onSdtdModSearchInput({ target: input });
  });
  // Installed-row "Files" link → switch to the Files tab + navigate to /game/Mods.
  $$("#im-sdtdm-wrap [data-sdtdm-open-files]").forEach(b => b.addEventListener("click", e => {
    e.preventDefault();
    if (typeof switchTab === "function") {
      switchTab("files");
      // Best-effort: try to navigate the file browser to /game/Mods.
      setTimeout(() => {
        const rootBtn = document.querySelector(".fs-root-btn[data-fs-root='/game']");
        if (rootBtn) rootBtn.click();
        // navigateToPath isn't a stable API — leave the operator at the
        // chosen root, they can drill in. The Mods/ folder will be
        // visible at the top of the Game Files root.
      }, 200);
    }
  }));

  renderSdtdSearchResults();
  if (!sdtdModsState.searchTerm) setTimeout(() => input && input.focus(), 50);
}

function onSdtdModSearchInput(e) {
  const v = (e.target.value || "").trim();
  sdtdModsState.searchTerm = v;
  const clear = $("#im-sdtdm-search-clear");
  if (clear) clear.style.display = v ? "block" : "none";
  clearTimeout(sdtdModsState.searchDebounce);
  if (sdtdModsState.searchController) { try { sdtdModsState.searchController.abort(); } catch {} }
  if (!v) {
    sdtdModsState.searchResults = [];
    sdtdModsState.searchPreview = null;
    toggleSdtdSearchSpin(false);
    renderSdtdSearchResults();
    return;
  }
  toggleSdtdSearchSpin(true);
  sdtdModsState.searchDebounce = setTimeout(() => runSdtdLiveSearch(v), 300);
}

function toggleSdtdSearchSpin(on) {
  const s = $("#im-sdtdm-search-spin");
  if (s) s.classList.toggle("on", !!on);
}

async function runSdtdLiveSearch(q) {
  const ctrl = new AbortController();
  sdtdModsState.searchController = ctrl;
  try {
    const idMatch = q.match(/^\s*(?:https?:\/\/\S+?)?(?:[?&/])?(\d{4,12})\s*$/);
    if (idMatch) {
      const mid = idMatch[1];
      const d = await fetch(`/api/7dtd/mods/detail?id=${encodeURIComponent(mid)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      sdtdModsState.searchPreview = d;
      sdtdModsState.searchResults = [];
    } else {
      const d = await fetch(`/api/7dtd/mods/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)));
      if (ctrl.signal.aborted) return;
      sdtdModsState.searchResults = d.items || [];
      sdtdModsState.searchPreview = null;
    }
  } catch (err) {
    if (err.name === "AbortError") return;
    console.warn("7dtd live search failed", err);
  } finally {
    if (sdtdModsState.searchController === ctrl) {
      sdtdModsState.searchController = null;
      toggleSdtdSearchSpin(false);
    }
  }
  renderSdtdSearchResults();
}

function renderSdtdSearchResults() {
  const host = $("#im-sdtdm-search-results"); if (!host) return;
  const q = sdtdModsState.searchTerm || "";
  if (!q) { host.innerHTML = ""; return; }
  if (sdtdModsState.searchPreview) {
    host.innerHTML = renderSdtdSearchPreview(sdtdModsState.searchPreview);
    return;
  }
  const items = sdtdModsState.searchResults || [];
  if (!items.length) {
    host.innerHTML = `<div class="dzm-empty"><div class="big">${EMPTY_ICONS.search}</div>No Nexus 7DTD mods found for "${esc(q)}"<span class="hint" style="margin-top:6px;display:block">Try a shorter term, or paste a Nexus mod ID.</span></div>`;
    return;
  }
  host.innerHTML = `<div class="dzm-grid">${items.slice(0, 30).map(sdtdSearchCard).join("")}</div>`;
  // Click a card → fetch detail for the rich preview. Click an inner
  // link or button (Install / Open on Nexus) → don't intercept.
  $$("#im-sdtdm-search-results [data-sdtdm-card]").forEach(c => c.addEventListener("click", e => {
    if (e.target.closest("a, button")) return;
    runSdtdLiveSearch(c.dataset.sdtdmCard);
  }));
  // Wire install buttons (cards + preview).
  _sdtdWireInstallButtons();
}

function _sdtdWireInstallButtons() {
  $$("#im-sdtdm-wrap [data-sdtdm-install]").forEach(b => {
    if (b.dataset._wired === "1") return;
    b.dataset._wired = "1";
    b.addEventListener("click", e => {
      e.stopPropagation();
      const modID = b.dataset.sdtdmInstall;
      const name = b.dataset.sdtdmName || "";
      _sdtdInstallFromNexus(modID, name);
    });
  });
}

// _sdtdInstallFromNexus — full install flow:
//   1. Fetch /api/7dtd/nexus/files/<modId> — list main files
//   2. If multiple, show a tiny picker; if one, pick automatically
//   3. POST /api/instances/{id}/7dtd/nexus/install with {mod_id, file_id}
//   4. Toast progress, then refresh installed list
// Drop-zone wiring. Click the zone → opens the file picker. Drag files
// over → adds .drag-over class for the animated highlight. Drop →
// uploads via multipart and renders per-mod results. Multiple zips
// allowed; the server handles nested zips and dedupes on existing
// /game/Mods/ folder names.
// Window-level drag-drop guard. Without this, dropping a file ANYWHERE
// outside our zone (including drops that bubble past it) triggers the
// browser's default behavior — navigating to the dropped file's URL,
// blowing away the panel session. We install one set of listeners
// once; they swallow the default for any file drop on the window. Our
// zone's specific drop handler still fires for valid in-zone drops.
let _sdtdWindowGuardInstalled = false;
function _sdtdInstallWindowGuard() {
  if (_sdtdWindowGuardInstalled) return;
  _sdtdWindowGuardInstalled = true;
  const swallow = (e) => {
    // Only swallow when the user is dragging actual files. Internal
    // drag events (text selections, etc.) shouldn't be touched.
    if (e.dataTransfer && e.dataTransfer.types && Array.from(e.dataTransfer.types).includes("Files")) {
      e.preventDefault();
    }
  };
  window.addEventListener("dragover", swallow, false);
  window.addEventListener("drop", swallow, false);
}

function _sdtdWireDropZone() {
  _sdtdInstallWindowGuard();
  const zone = $("#im-sdtdm-drop");
  const file = $("#im-sdtdm-file");
  if (!zone || !file) return;

  // Avoid re-binding when the tab re-renders (loadSdtdModsTab fires on
  // every tab open). dataset._wired serves as our idempotency flag.
  if (zone.dataset._wired === "1") return;
  zone.dataset._wired = "1";

  // Click the zone (anywhere except the hidden file input) → opens the
  // native file picker.
  zone.addEventListener("click", (e) => {
    if (e.target.tagName === "INPUT") return;
    file.click();
  });
  file.addEventListener("change", () => {
    if (file.files && file.files.length) _sdtdUploadFiles(Array.from(file.files));
    file.value = ""; // allow re-uploading the same file
  });

  // Drag handling — counter-based so child elements don't flicker
  // dragenter/dragleave.
  let counter = 0;
  zone.addEventListener("dragenter", (e) => {
    e.preventDefault();
    e.stopPropagation();
    counter++;
    zone.classList.add("drag-over");
  });
  zone.addEventListener("dragover", (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
  });
  zone.addEventListener("dragleave", (e) => {
    e.preventDefault();
    e.stopPropagation();
    counter = Math.max(0, counter - 1);
    if (counter === 0) zone.classList.remove("drag-over");
  });
  zone.addEventListener("drop", (e) => {
    e.preventDefault();
    e.stopPropagation();
    counter = 0;
    zone.classList.remove("drag-over");
    if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) {
      toast("No files detected in the drop", "err");
      return;
    }
    const files = Array.from(e.dataTransfer.files).filter(f => /\.zip$/i.test(f.name));
    if (!files.length) {
      toast(`Need .zip files — got ${Array.from(e.dataTransfer.files).map(f => f.name).join(", ")}`, "err");
      return;
    }
    _sdtdUploadFiles(files);
  });
}

async function _sdtdUploadFiles(files) {
  const id = instanceModalState.id; if (!id) return;
  const status = $("#im-sdtdm-upload-status");
  if (!status) return;
  const totalBytes = files.reduce((s, f) => s + f.size, 0);
  const tStart = Date.now();
  // Show upload progress UI with a live elapsed timer + phase hint.
  const renderPhase = (phase) => {
    const elapsed = Math.floor((Date.now() - tStart) / 1000);
    const elTxt = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed/60)}m ${elapsed%60}s`;
    status.innerHTML = `
      <div class="sdtd-upload-progress">
        <div class="sdtd-upload-progress-head">
          <span class="sdtd-upload-progress-spin"></span>
          <span><strong>${phase}</strong> · ${files.length} zip${files.length === 1 ? "" : "s"} (${(totalBytes/1048576).toFixed(1)} MB)</span>
          <span class="dim mono" style="margin-left:auto;font-size:11px">${elTxt}</span>
        </div>
        <div class="sdtd-upload-bar"><div class="sdtd-upload-bar-fill"></div></div>
        <div class="dim" style="font-size:11px;margin-top:8px;line-height:1.4">
          Big overhauls (Darkness Falls / War of the Walkers) can take 30–90 seconds — files are written one at a time over a Docker exec channel. Progress is happening; check the Files tab to see new mod folders appearing.
        </div>
      </div>
    `;
  };
  renderPhase("Uploading + extracting");
  // Tick the elapsed counter every second.
  const tickTimer = setInterval(() => renderPhase("Uploading + extracting"), 1000);

  // 10-minute hard timeout via AbortController so a true hang surfaces
  // as a real error instead of an infinite spinner.
  const ac = new AbortController();
  const timeoutId = setTimeout(() => ac.abort("client_timeout"), 10 * 60 * 1000);

  const form = new FormData();
  for (const f of files) form.append("files", f);
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/7dtd/mods/upload`, {
      method: "POST",
      body: form,
      signal: ac.signal,
    }).then(async res => {
      const j = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(j.error || j.message || `${res.status}`);
      return j;
    });
    _sdtdRenderUploadResults(r);
    loadSdtdModsTab();
  } catch (err) {
    const reason = err && err.name === "AbortError"
      ? "Timed out after 10 minutes — the install is probably still running on the server. Check the Files tab to see what made it through, then refresh this tab."
      : "Upload failed: " + (err && err.message || err);
    status.innerHTML = `<div class="sdtd-upload-progress" style="border-color:rgba(248,113,113,.4);background:rgba(248,113,113,.06)">
      <div class="sdtd-upload-progress-head" style="color:var(--err)"><span style="font-size:18px">✕</span><span>${esc(reason)}</span></div>
    </div>`;
  } finally {
    clearTimeout(timeoutId);
    clearInterval(tickTimer);
  }
}

function _sdtdRenderUploadResults(r) {
  const status = $("#im-sdtdm-upload-status");
  if (!status) return;
  const results = r.results || [];
  if (r.warning) {
    status.innerHTML = `<div class="sdtd-upload-progress" style="border-color:rgba(251,191,36,.4);background:rgba(251,191,36,.06)">
      <div class="sdtd-upload-progress-head" style="color:var(--warn)"><span style="font-size:18px">⚠</span><span>${esc(r.warning)}</span></div>
    </div>`;
    return;
  }
  if (!results.length) {
    status.innerHTML = `<div class="sdtd-upload-progress"><div class="sdtd-upload-progress-head dim"><span>No mods detected.</span></div></div>`;
    return;
  }
  const installed = results.filter(x => x.status === "installed").length;
  const skipped   = results.filter(x => x.status === "skipped").length;
  const failed    = results.filter(x => x.status === "failed").length;
  const headline = [
    installed ? `<strong style="color:#4fcf63">${installed} installed</strong>` : "",
    skipped   ? `<strong style="color:var(--warn)">${skipped} skipped</strong>` : "",
    failed    ? `<strong style="color:var(--err)">${failed} failed</strong>` : "",
  ].filter(Boolean).join(" · ");
  const rows = results.map((m, i) => {
    const cls = m.status || "skipped";
    const icon = cls === "installed" ? "✓" : cls === "skipped" ? "↩" : "✕";
    const meta = [
      m.display_name && m.display_name !== m.name ? esc(m.display_name) : null,
      m.version ? `v${esc(m.version)}` : null,
      m.author ? `by ${esc(m.author)}` : null,
      m.file_count ? `${m.file_count} files` : null,
      m.note ? esc(m.note) : null,
      m.error ? `<span style="color:var(--err)">${esc(m.error)}</span>` : null,
    ].filter(Boolean).join(" · ");
    return `<div class="sdtd-upload-row ${cls}" style="animation-delay:${i*40}ms">
      <span class="ic">${icon}</span>
      <div class="info">
        <div class="name">${esc(m.name)}</div>
        ${meta ? `<div class="meta">${meta}</div>` : ""}
      </div>
      <span class="badge">${cls}</span>
    </div>`;
  }).join("");
  status.innerHTML = `
    <div class="sdtd-upload-progress" style="border-color:rgba(79,207,99,.32);background:rgba(79,207,99,.07)">
      <div class="sdtd-upload-progress-head" style="color:var(--text)">
        <span style="font-size:18px">📦</span>
        <span>${headline || `${results.length} mods processed`}</span>
        ${installed ? `<span class="dim" style="margin-left:auto;font-size:11.5px">restart server to load</span>` : ""}
      </div>
    </div>
    <div class="sdtd-upload-results">${rows}</div>
  `;
}

async function _sdtdInstallFromNexus(modID, modName) {
  const id = instanceModalState.id; if (!id) return;
  if (sdtdModsState.installing) { toast("Already installing — please wait", "err"); return; }
  sdtdModsState.installing = true;
  try {
    const r = await fetchJSON(`/api/7dtd/nexus/files/${encodeURIComponent(modID)}`);
    const files = (r.files || []).filter(f => f.is_primary || f.category === "MAIN" || f.category === "Main");
    let picked = files.find(f => f.is_primary) || files[0] || (r.files || [])[0];
    if (!picked) { toast("No installable files found on Nexus for that mod", "err"); return; }
    // If there are multiple main files, prompt for choice.
    if (files.length > 1) {
      const choices = files.map((f, i) => `${i + 1}. ${f.name} ${f.version ? `(v${f.version})` : ""} — ${(f.size/1048576).toFixed(1)} MB`).join("\n");
      const pick = prompt(`Multiple main files available for "${modName}":\n\n${choices}\n\nEnter a number (1–${files.length}) to install:`, "1");
      const n = parseInt(pick, 10);
      if (!n || n < 1 || n > files.length) return;
      picked = files[n - 1];
    }
    toast(`Downloading "${picked.name}" (${(picked.size/1048576).toFixed(1)} MB)…`);
    const installRes = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/nexus/install`, {
      method: "POST", headers: {"Content-Type":"application/json"},
      body: JSON.stringify({ mod_id: parseInt(modID, 10), file_id: picked.file_id, name: modName }),
    });
    toast(`Installed ${installRes.folder} — ${installRes.files_written} files (${(installRes.bytes_written/1048576).toFixed(1)} MB). Restart the server to load.`, "ok");
    await loadSdtdModsTab();
  } catch (err) {
    toast("Install failed: " + err.message, "err");
  } finally {
    sdtdModsState.installing = false;
  }
}

function _sdtdHelpBannerHTML() {
  const k = sdtdModsState.nexusKey || {};
  if (k.configured && k.premium) {
    return `<div class="panel" style="padding:11px 14px;background:rgba(63,185,80,.08);border-color:rgba(79,207,99,.28)">
      <div style="font-size:12px;color:var(--text-dim);line-height:1.55">
        ✅ <b>Auto-install enabled.</b> Click <b>+ Install</b> on any mod and the panel will download from Nexus, extract, and drop it into <span class="mono">/game/Mods/</span> automatically. Restart the server to load. ${k.name ? `(API key: <span class="mono">${esc(k.name)}</span>, Premium ✓)` : ""}
      </div>
    </div>`;
  }
  if (k.configured && !k.premium) {
    return `<div class="panel" style="padding:11px 14px;background:rgba(251,191,36,.06);border-color:rgba(251,191,36,.28)">
      <div style="font-size:12px;color:var(--text-dim);line-height:1.55">
        ⚠ <b>Nexus API key configured, but the account isn't Premium.</b> Nexus only allows direct download URLs via API for Premium members. ${k.error ? `<span class="mono" style="font-size:11px">(${esc(k.error)})</span>` : ""}
        <br><br>
        Until upgraded: click a mod → <b>Open on Nexus</b> → download manually → upload into <span class="mono">/game/Mods/</span> via the <b>Files</b> tab.
      </div>
    </div>`;
  }
  return `<div class="panel" style="padding:11px 14px;background:rgba(95,178,255,.06)">
    <div style="font-size:12px;color:var(--text-dim);line-height:1.55">
      💡 <b>How 7DTD mod installs work today:</b> Click a result → opens the mod's page on Nexus. Click <b>Files</b> → <b>Manual download</b>, save the zip, extract, then upload the <span class="mono">ModName/</span> folder into <span class="mono">/game/Mods/</span> via the <b>Files</b> tab → <b>Game Files</b>. Restart the server to load.
      <br><br>
      🛠 <b>Want one-click auto-install?</b> Get a <a href="https://www.nexusmods.com/users/myaccount?tab=api+access" target="_blank" rel="noopener">Nexus Premium API key</a> ($5/mo or $40 lifetime), then on the controller host:
      <pre class="mono" style="margin:6px 0 0;padding:8px 10px;background:#070a0e;border-radius:var(--radius-pill);font-size:11px;line-height:1.5">sudo systemctl edit panel-controller    # add Environment="PANEL_NEXUS_API_KEY=your-key-here"
sudo systemctl restart panel-controller</pre>
      Then refresh this tab — the Install buttons will activate.
    </div>
  </div>`;
}

function sdtdSearchCard(it) {
  const fmtDownloads = (n) => {
    n = Number(n) || 0;
    if (n < 1000) return String(n);
    if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k";
    return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M";
  };
  const site = it.website || `https://www.nexusmods.com/games/7daystodie`;
  const canInstall = sdtdModsState.nexusKey.configured && sdtdModsState.nexusKey.premium;
  return `<div class="dzm-card" data-sdtdm-card="${esc(it.id)}" title="Click for details">
    ${it.logo_url ? `<img class="dzm-card-img" src="${esc(it.logo_url)}" alt="" style="object-fit:contain;padding:10px;background:#0a0e1a">` : ''}
    <div class="dzm-card-body">
      <div class="dzm-card-title">${esc(it.name || '(untitled)')}</div>
      <div class="dzm-card-meta">
        ${it.author ? `<span>${esc(it.author)}</span>` : ''}
        ${it.download_count ? `<span class="dim">· ⬇ ${fmtDownloads(it.download_count)}</span>` : ''}
      </div>
      <div class="dzm-card-foot" style="display:flex;gap:6px;flex-wrap:wrap">
        ${canInstall ? `<button class="small primary" data-sdtdm-install="${esc(it.id)}" data-sdtdm-name="${esc(it.name || '')}">＋ Install</button>` : ''}
        <a class="small" href="${esc(site)}" target="_blank" rel="noopener">🔗 Nexus</a>
      </div>
    </div>
  </div>`;
}

function renderSdtdSearchPreview(d) {
  const site = d.website || `https://www.nexusmods.com/7daystodie/mods/${d.slug || d.id}`;
  const updated = d.updated || d.released || "";
  return `<div class="dzm-preview">
    ${d.logo_url ? `<img class="dzm-preview-img" src="${esc(d.logo_url)}" style="object-fit:cover" alt="">` : `<div class="dzm-preview-img" style="display:flex;align-items:center;justify-content:center;color:var(--muted)">no preview</div>`}
    <div class="dzm-preview-body">
      <div class="dzm-preview-title">${esc(d.name || 'Mod #' + d.id)}</div>
      <div class="dzm-preview-meta">
        <span>🆔 <span class="mono">${esc(d.id)}</span></span>
        ${d.author ? `<span>👤 ${esc(d.author)}</span>` : ''}
        ${d.file_size_human ? `<span>📦 ${esc(d.file_size_human)}</span>` : ''}
        ${d.download_count ? `<span>⬇ ${Number(d.download_count).toLocaleString()}</span>` : ''}
        ${updated ? `<span>🕒 ${esc(updated)}</span>` : ''}
      </div>
      <div class="dim" style="font-size:12px;margin-top:2px">${esc(d.summary || 'No summary available.')}</div>
    </div>
    <div class="dzm-preview-actions">
      ${(sdtdModsState.nexusKey.configured && sdtdModsState.nexusKey.premium)
        ? `<button class="primary" data-sdtdm-install="${esc(d.id)}" data-sdtdm-name="${esc(d.name || '')}">＋ Install via Nexus</button>`
        : `<a class="primary" href="${esc(site)}" target="_blank" rel="noopener">🔗 Open on Nexus</a>`}
      <a class="small" href="${esc(site)}" target="_blank" rel="noopener">View on Nexus</a>
    </div>
  </div>`;
}

// =============== COMMAND PALETTE (Ctrl/Cmd+K) ===============
// Search-everywhere jumper. Items come from four sources:
//   * Servers (every instance) → opens its Manage modal
//   * Quick actions (Start/Stop/Restart per server)
//   * Pages (Settings, Docs, + New server, Steam Accounts, etc.)
//   * Docs sections (jumps inside the docs modal)
// Filter is plain substring match across the item's title + a
// pre-built keyword string. Up/Down navigates, Enter executes.
const cmdkState = { selectedIndex: 0, visibleItems: [] };

function _cmdkBuildItems() {
  const items = [];
  // Servers
  const modules = allModules || [];
  for (const i of allInstances || []) {
    const mod = modules.find(m => m.id === i.module_id) || {};
    const app = (typeof appearanceFor === "function") ? appearanceFor(i.module_id) : {};
    const ic = (app && app.emoji) || "🎮";
    items.push({
      kind: "server",
      ic, title: i.instance_id,
      sub: `${mod.name || i.module_id} · ${i.status || "unknown"} · ${i.agent_id || "—"}`,
      action: () => openInstanceModal(i.instance_id),
      keywords: `${i.instance_id} ${i.module_id} ${mod.name || ""} ${i.agent_id || ""} ${i.status || ""}`.toLowerCase(),
    });
    // Quick actions per server, contextual to running state.
    if (i.status === "running") {
      items.push({
        kind: "action", ic: "■",
        title: `Stop ${i.instance_id}`,
        sub: "Stop the server (graceful shutdown)",
        action: () => doInstanceAction(i.instance_id, "stop", null),
        keywords: `stop ${i.instance_id}`.toLowerCase(),
      });
      items.push({
        kind: "action", ic: "↻",
        title: `Restart ${i.instance_id}`,
        sub: "Stop, then start",
        action: () => doInstanceRestart(i.instance_id, null),
        keywords: `restart ${i.instance_id}`.toLowerCase(),
      });
    } else {
      items.push({
        kind: "action", ic: "▶",
        title: `Start ${i.instance_id}`,
        sub: "Start the server",
        action: () => doInstanceAction(i.instance_id, "start", null),
        keywords: `start ${i.instance_id} run launch`.toLowerCase(),
      });
    }
    items.push({
      kind: "action", ic: "💾",
      title: `Backup ${i.instance_id}`,
      sub: "Take a snapshot now",
      action: () => { openInstanceModal(i.instance_id); setTimeout(() => { switchTab && switchTab("backups"); $("#im-b-create")?.click(); }, 350); },
      keywords: `backup ${i.instance_id} snapshot save`.toLowerCase(),
    });
  }
  // Pages
  items.push({ kind: "page", ic: "⚙", title: "Settings",        sub: "Density, refresh interval, notifications, About",
    action: () => (typeof openSettingsModal === "function" && openSettingsModal()),
    keywords: "settings preferences density refresh notifications" });
  items.push({ kind: "page", ic: "📘", title: "Docs",            sub: "Open the RGSP documentation",
    action: () => (typeof openDocsModal === "function" && openDocsModal()),
    keywords: "docs documentation help guide" });
  items.push({ kind: "page", ic: "＋", title: "+ New server",    sub: "Spin up a new game server",
    action: () => showModal("#new-server-modal"),
    keywords: "new server create add make" });
  items.push({ kind: "page", ic: "＋", title: "+ Pair agent",    sub: "Add a new agent host",
    action: () => $("#pair-agent")?.click(),
    keywords: "pair agent new host machine" });
  items.push({ kind: "page", ic: "🎮", title: "Steam Accounts",  sub: "Link your Steam ID, manage panel users",
    action: () => (typeof openSteamAccountsModal === "function" && openSteamAccountsModal()),
    keywords: "steam accounts auth users" });
  // Docs sections — jumps inside the docs modal
  const docSections = [
    { id: "welcome",        t: "Docs: Welcome" },
    { id: "architecture",   t: "Docs: Architecture" },
    { id: "quickstart",     t: "Docs: Quick start" },
    { id: "tile",           t: "Docs: Server tile" },
    { id: "manage",         t: "Docs: Manage modal" },
    { id: "tab-config",     t: "Docs: Config tab" },
    { id: "tab-files",      t: "Docs: Files tab" },
    { id: "tab-backups",    t: "Docs: Backups tab" },
    { id: "tab-schedules",  t: "Docs: Schedules tab" },
    { id: "ark",            t: "Docs: ARK Survival Ascended" },
    { id: "ark-mods",       t: "Docs: ARK Mods" },
    { id: "ark-cluster",    t: "Docs: ARK Clusters" },
    { id: "dayz",           t: "Docs: DayZ" },
    { id: "agents",         t: "Docs: Agents" },
    { id: "networking",     t: "Docs: Networking" },
    { id: "shortcuts",      t: "Docs: Keyboard shortcuts" },
    { id: "troubleshooting",t: "Docs: Troubleshooting" },
  ];
  for (const ds of docSections) {
    items.push({
      kind: "doc", ic: "📖", title: ds.t, sub: "RGSP documentation",
      action: () => (typeof openDocsModal === "function" && openDocsModal(ds.id)),
      keywords: ds.t.toLowerCase(),
    });
  }
  return items;
}

function _cmdkRender(query) {
  const all = _cmdkBuildItems();
  const q = (query || "").trim().toLowerCase();
  const filtered = q
    ? all.filter(it => it.keywords.includes(q) || it.title.toLowerCase().includes(q))
    : all.slice(0, 30); // unfiltered: show first 30 so the modal isn't a wall of text
  cmdkState.visibleItems = filtered.slice(0, 60);
  if (cmdkState.selectedIndex >= cmdkState.visibleItems.length) cmdkState.selectedIndex = 0;

  const groups = { server: [], action: [], page: [], doc: [] };
  for (const it of cmdkState.visibleItems) (groups[it.kind] || (groups[it.kind] = [])).push(it);
  const sectionTitle = { server: "Servers", action: "Quick actions", page: "Pages", doc: "Documentation" };

  const host = $("#cmdk-results");
  if (!cmdkState.visibleItems.length) {
    host.innerHTML = `<div class="cmdk-empty"><div class="big">${EMPTY_ICONS.search}</div>No matches for <code>${esc(query)}</code><div style="font-size:11.5px;margin-top:6px;opacity:.8">Try a server name, "start", "backup", or a docs topic.</div></div>`;
    return;
  }
  let html = "";
  let idx = 0;
  for (const k of ["server", "action", "page", "doc"]) {
    if (!groups[k] || !groups[k].length) continue;
    html += `<div class="cmdk-section-h">${sectionTitle[k]}</div>`;
    for (const it of groups[k]) {
      html += `<div class="cmdk-row kind-${k}${idx === cmdkState.selectedIndex ? " selected" : ""}" data-cmdk-idx="${idx}">
        <span class="ic">${esc(it.ic)}</span>
        <div class="text">
          <div class="title">${esc(it.title)}</div>
          <div class="sub">${esc(it.sub)}</div>
        </div>
        <span class="badge">${esc(sectionTitle[k] || k)}</span>
      </div>`;
      idx++;
    }
  }
  host.innerHTML = html;
}

function _cmdkSelect(delta) {
  const n = cmdkState.visibleItems.length;
  if (!n) return;
  cmdkState.selectedIndex = (cmdkState.selectedIndex + delta + n) % n;
  document.querySelectorAll("#cmdk-results .cmdk-row").forEach((r, i) => {
    const sel = i === cmdkState.selectedIndex;
    r.classList.toggle("selected", sel);
    if (sel) r.scrollIntoView({ block: "nearest" });
  });
}

function _cmdkExecute() {
  const it = cmdkState.visibleItems[cmdkState.selectedIndex];
  if (!it) return;
  hideModal("#cmdk-modal");
  // Defer until the close animation has ticked one frame so the next
  // modal/page open doesn't fight the cmdk close.
  setTimeout(() => { try { it.action && it.action(); } catch (err) { console.warn("cmdk action failed:", err); } }, 90);
}

function openCmdK() {
  showModal("#cmdk-modal");
  const inp = $("#cmdk-input");
  if (!inp) return;
  inp.value = "";
  cmdkState.selectedIndex = 0;
  _cmdkRender("");
  setTimeout(() => inp.focus(), 60);
}

// Topbar hint shows ⌘K on Mac, Ctrl K elsewhere.
(() => {
  const k = $("#topbar-cmdk-key");
  if (k && /Mac|iP(hone|ad)/i.test(navigator.platform)) k.textContent = "⌘ K";
})();

// Topbar "+ New server" button — defers to the existing sidebar
// new-server flow so init logic (agents list, module picker tiles)
// runs the same way regardless of which entry point fired it.
const _topbarNewBtn = $("#topbar-new");
if (_topbarNewBtn) _topbarNewBtn.addEventListener("click", () => {
  const sidebarBtn = $("#new-server");
  if (sidebarBtn) sidebarBtn.click();
  else showModal("#new-server-modal");
});

// Wire keyboard + click handlers.
document.addEventListener("keydown", (e) => {
  if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
    e.preventDefault();
    e.stopPropagation();
    openCmdK();
  }
});
const _topbarCmdkBtn = $("#topbar-cmdk");
if (_topbarCmdkBtn) _topbarCmdkBtn.addEventListener("click", () => openCmdK());

const _cmdkInputEl = $("#cmdk-input");
if (_cmdkInputEl) {
  _cmdkInputEl.addEventListener("input", (e) => _cmdkRender(e.target.value));
  _cmdkInputEl.addEventListener("keydown", (e) => {
    if (e.key === "ArrowDown") { e.preventDefault(); _cmdkSelect(+1); }
    else if (e.key === "ArrowUp") { e.preventDefault(); _cmdkSelect(-1); }
    else if (e.key === "Enter") { e.preventDefault(); _cmdkExecute(); }
    else if (e.key === "Escape") { e.preventDefault(); hideModal("#cmdk-modal"); }
  });
}
const _cmdkResultsEl = $("#cmdk-results");
if (_cmdkResultsEl) {
  _cmdkResultsEl.addEventListener("click", (e) => {
    const row = e.target.closest("[data-cmdk-idx]");
    if (!row) return;
    cmdkState.selectedIndex = parseInt(row.dataset.cmdkIdx, 10) || 0;
    _cmdkExecute();
  });
}

// =============== SERVICE WORKER REGISTRATION ===============
// Required for PWA installability on Android Chrome. Also enables
// "Add to Home Screen" with full-screen launch on iOS Safari.
if ("serviceWorker" in navigator) {
  // Defer to load so we don't compete with the initial paint.
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("/sw.js").catch(err => {
      console.warn("RGSP service worker registration failed:", err);
    });
  });
}

// Wire the Settings cog in the topbar + the sidebar Settings/About/Docs links.
const _settingsBtn = $("#open-settings");
if (_settingsBtn) _settingsBtn.addEventListener("click", openSettingsModal);
document.querySelectorAll(".side-link").forEach(l => {
  if (l.dataset.scroll === "settings" || l.dataset.scroll === "about") {
    l.addEventListener("click", e => { e.preventDefault(); openSettingsModal(); });
  }
  if (l.dataset.scroll === "docs") {
    l.addEventListener("click", e => { e.preventDefault(); openDocsModal(); });
  }
});

// =============== DOCS MODAL ===============
function openDocsModal(anchor) {
  showModal("#docs-modal");
  // Default landing: top of welcome.
  const target = anchor || "welcome";
  setTimeout(() => _docsScrollTo(target), 80);
}

function _docsScrollTo(id) {
  const content = $("#docs-content");
  const sec = content && content.querySelector(`#${CSS.escape(id)}`);
  if (!content || !sec) return;
  // Compute the section's offset *relative to the scroll container* —
  // section.offsetTop walks up to the nearest positioned ancestor, which
  // for the docs panel is somewhere near the modal root, NOT .docs-content
  // itself. Using getBoundingClientRect math lands us on the correct
  // scrollTop so the smooth scroll actually moves to the right spot.
  const contentRect = content.getBoundingClientRect();
  const secRect = sec.getBoundingClientRect();
  const target = (secRect.top - contentRect.top) + content.scrollTop - 6;
  content.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
  _docsSetActiveLink(id);
  _docsFlashSection(sec);
}

function _docsFlashSection(sec) {
  sec.classList.remove("docs-flash");
  // Force reflow so the animation can re-trigger on the same element.
  void sec.offsetWidth;
  sec.classList.add("docs-flash");
}

function _docsSetActiveLink(id) {
  document.querySelectorAll("#docs-modal .docs-rail-link").forEach(a => {
    const href = a.getAttribute("href") || "";
    a.classList.toggle("active", href === "#" + id);
  });
}

// Rail + in-content anchor clicks → smooth-scroll inside the modal.
// Use event delegation on the modal root so it covers both the static
// rail links AND any cross-reference <a href="#section"> inside the
// docs prose.
const _docsModal = $("#docs-modal");
if (_docsModal) {
  _docsModal.addEventListener("click", e => {
    const a = e.target.closest("a[href^='#']");
    if (!a || !_docsModal.contains(a)) return;
    const href = a.getAttribute("href") || "";
    if (!href.startsWith("#") || href === "#") return;
    e.preventDefault();
    e.stopPropagation();
    _docsScrollTo(href.slice(1));
  });
}

// Active-section tracker — on every scroll tick, find the last section
// whose top has passed the content's top-band (~80px in). That's the
// section "you're reading right now". Fresh DOM measurements per frame,
// scoped to a requestAnimationFrame so the work is bounded.
(() => {
  const content = $("#docs-content");
  if (!content) return;
  const sections = Array.from(content.querySelectorAll("section[id]"));
  if (!sections.length) return;
  let raf = 0;
  const update = () => {
    raf = 0;
    const contentTop = content.getBoundingClientRect().top;
    let best = sections[0];
    for (const s of sections) {
      const top = s.getBoundingClientRect().top - contentTop;
      if (top <= 90) best = s; else break;
    }
    if (best) _docsSetActiveLink(best.id);
  };
  content.addEventListener("scroll", () => {
    if (!raf) raf = requestAnimationFrame(update);
  }, { passive: true });
})();

// Filter input on the rail — hides non-matching links AND non-matching
// sections so a search like "ark" focuses the operator instantly.
(() => {
  const inp = $("#docs-search");
  if (!inp) return;
  inp.addEventListener("input", () => {
    const q = inp.value.trim().toLowerCase();
    const links = document.querySelectorAll("#docs-modal .docs-rail-link");
    const sections = document.querySelectorAll("#docs-content section");
    if (!q) {
      links.forEach(l => l.removeAttribute("data-doc-hidden"));
      sections.forEach(s => s.removeAttribute("data-doc-hidden"));
      const empty = $("#docs-content .docs-no-results"); if (empty) empty.remove();
      return;
    }
    let anyVisible = 0;
    sections.forEach(s => {
      const hay = (s.textContent || "").toLowerCase();
      const id = s.id;
      const linkText = Array.from(links).find(l => l.getAttribute("href") === "#" + id);
      const linkHay = linkText ? (linkText.textContent || "").toLowerCase() : "";
      const match = hay.includes(q) || linkHay.includes(q);
      if (match) {
        s.removeAttribute("data-doc-hidden");
        if (linkText) linkText.removeAttribute("data-doc-hidden");
        anyVisible++;
      } else {
        s.setAttribute("data-doc-hidden", "1");
        if (linkText) linkText.setAttribute("data-doc-hidden", "1");
      }
    });
    // Show a "no results" placeholder when nothing matches.
    let empty = $("#docs-content .docs-no-results");
    if (anyVisible === 0) {
      if (!empty) {
        empty = document.createElement("div");
        empty.className = "docs-no-results";
        $("#docs-content").appendChild(empty);
      }
      empty.innerHTML = `<div style="font-size:36px;opacity:.7">🔍</div>No sections match <code>${esc(inp.value)}</code><div style="font-size:11.5px;margin-top:8px;opacity:.8">Try a shorter term, or clear to see everything.</div>`;
    } else if (empty) empty.remove();
  });
})();

// About-card "📘 Docs" link inside the Settings modal.
const _aboutDocsLink = $("#about-open-docs");
if (_aboutDocsLink) _aboutDocsLink.addEventListener("click", e => {
  e.preventDefault();
  hideModal("#settings-modal");
  setTimeout(() => openDocsModal(), 180);
});

// Settings change → save + apply.
function _onSettingsChange() {
  userSettings.density = $("#set-density").value;
  userSettings.showBars = $("#set-show-bars").checked;
  userSettings.refreshMs = parseInt($("#set-refresh").value, 10) || 2000;
  const wantNotify = $("#set-notify").checked;
  if (wantNotify && typeof Notification !== "undefined" && Notification.permission === "default") {
    Notification.requestPermission().then(p => {
      userSettings.notifyOnCrash = (p === "granted");
      $("#set-notify").checked = userSettings.notifyOnCrash;
      saveSettings(userSettings);
    });
    return;
  }
  userSettings.notifyOnCrash = wantNotify && (typeof Notification === "undefined" ? false : Notification.permission === "granted");
  saveSettings(userSettings);
}
["#set-density", "#set-show-bars", "#set-refresh", "#set-notify"].forEach(sel => {
  const el = $(sel);
  if (el) el.addEventListener("change", _onSettingsChange);
});

// Topbar search — feeds the new flat-grid filter via serverFilter.search.
// _applyServerFilter is the single source of truth for visibility
// (status + agent + search), so keystrokes don't fight per-render
// display swaps from poll ticks.
(() => {
  const input = $("#srv-search"); if (!input) return;
  const apply = () => {
    serverFilter.search = input.value || "";
    if (typeof _applyServerFilter === "function") _applyServerFilter();
  };
  input.addEventListener("input", apply);
  // Re-apply after each poll-driven re-render.
  const origRender = renderServerCards;
  window.renderServerCards = function() { origRender.apply(this, arguments); apply(); };
})();

// Sidebar scroll-to-section links.
document.querySelectorAll(".side-link[data-scroll]").forEach(link => {
  link.addEventListener("click", e => {
    e.preventDefault();
    const t = link.dataset.scroll;
    document.querySelectorAll(".side-link").forEach(x => x.classList.remove("active"));
    link.classList.add("active");
    // Special-cased "scroll targets" that actually open modals — the
    // sidebar is a hub so any link can route to a modal too. Keeps the
    // shell minimal without sprouting a separate "settings" page tree.
    if (t === "steam") {
      try { openSteamAccountsModal(); } catch (err) { toast("Couldn't open Steam Accounts: " + err.message, "err"); }
      return;
    }
    if (t === "library") {
      try { openTemplateLibrary(); } catch (err) { toast("Couldn't open the Template Library: " + err.message, "err"); }
      return;
    }
    if (t === "top") window.scrollTo({ top: 0, behavior: "smooth" });
    else {
      const el = document.querySelector(t);
      if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
    }
  });
});

// ================ Steam Accounts modal ================
//
// Opens from the sidebar 🎮 Steam Accounts link. Shows the current
// user's Steam linkage with avatar pulled from steamcommunity.com via
// the controller proxy, plus an admin-only list of every panel user.
// Each row supports linking by SteamID64 (paste the 17-digit number)
// and unlinking. Avatars come from /api/steam/profile which fetches +
// caches Steam's XML profile server-side.

async function openSteamAccountsModal() {
  showModal("#steam-accounts-modal");
  await renderSteamAccountsModal();
}
async function renderSteamAccountsModal() {
  const meEl = $("#sa-me");
  const teamEl = $("#sa-team");
  const teamWrap = $("#sa-team-wrap");
  if (!meEl) return;
  meEl.innerHTML = `<div class="dim">Loading…</div>`;
  let me = null;
  try { me = await fetchJSON("/api/me"); } catch {}
  meEl.innerHTML = await renderSteamUserRow(me, /*self*/true);
  // Admin-only — non-admins don't see the team list.
  if (me && me.role === "admin") {
    teamWrap.style.display = "";
    teamEl.innerHTML = `<div class="dim">Loading…</div>`;
    try {
      const r = await fetchJSON("/api/users");
      const others = (r.users || []).filter(u => u.id !== me.id);
      if (!others.length) {
        teamEl.innerHTML = `<div class="dim" style="padding:14px 4px">No other users on this panel.</div>`;
      } else {
        const rows = await Promise.all(others.map(u => renderSteamUserRow(u, /*self*/false)));
        teamEl.innerHTML = rows.join("");
      }
    } catch (err) {
      teamEl.innerHTML = `<div class="dim">Couldn't load users: ${esc(err.message)}</div>`;
    }
  } else {
    teamWrap.style.display = "none";
  }
}
async function renderSteamUserRow(u, isSelf) {
  if (!u) return `<div class="dim">Not signed in.</div>`;
  let avatar = "";
  let displayName = u.email;
  if (u.steam_id) {
    try {
      const p = await fetchJSON(`/api/steam/profile?steamid=${encodeURIComponent(u.steam_id)}`);
      avatar = p.avatar_medium || "";
      if (p.display_name) displayName = `${p.display_name} · ${u.email}`;
    } catch {
      // Profile fetch failed (private, network) — show a placeholder.
    }
  }
  const tile = avatar
    ? `<img src="${esc(avatar)}" alt="" style="width:54px;height:54px;border-radius:var(--radius);border:1px solid var(--border-strong);background:#0a0e15;flex:0 0 auto">`
    : `<div style="width:54px;height:54px;border-radius:var(--radius);background:rgba(95,178,255,.08);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:22px;flex:0 0 auto">${u.steam_id ? "❓" : "🎮"}</div>`;
  const sidLine = u.steam_id
    ? `<span class="mono dim" style="font-size:11px">${esc(u.steam_id)}</span>`
    : `<span class="dim" style="font-size:11px;font-style:italic">No Steam linked</span>`;
  // Action buttons differ for self vs. other users:
  //   - self, no link: "Sign in with Steam" (real OpenID flow, no paste)
  //   - self, linked:  "Unlink"
  //   - other, no link: "Paste SteamID64" (admin convenience for teammates who can't sign in)
  //   - other, linked:  "Unlink"
  let action = "";
  if (isSelf && !u.steam_id) {
    // Real OpenID — redirects to Steam, comes back to /api/auth/steam/link-callback,
    // which links the verified ID to the current session user.
    action = `<a class="btn-steam-signin" href="/api/auth/steam/link-start?next=${encodeURIComponent(location.pathname + "?steam_linked=ok")}">
      <img src="https://steamcdn-a.akamaihd.net/steamcommunity/public/images/steamworks_docs/english/sits_small.png" alt="Sign in through Steam" style="display:block;height:24px">
    </a>`;
  } else if (u.steam_id) {
    action = `<button class="small" data-sa-unlink="${u.id}" title="Remove Steam link">Unlink</button>`;
  } else {
    action = `<button class="small primary" data-sa-link="${u.id}" title="Paste SteamID64 manually">Paste ID</button>`;
  }
  return `<div class="sa-row" style="display:flex;align-items:center;gap:14px;padding:10px 4px;border:1px solid ${isSelf ? "transparent" : "var(--border-soft)"};border-radius:var(--radius)">
    ${tile}
    <div style="flex:1;min-width:0">
      <div style="font-weight:600;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(displayName)}</div>
      <div style="margin-top:2px">${sidLine}</div>
      <div class="dim" style="font-size:10.5px;margin-top:2px">${esc(u.role || "user")}</div>
    </div>
    ${action}
  </div>`;
}

// On page load, detect ?steam_linked=ok (Steam OpenID redirected back
// here after a successful link). Clean the URL + auto-open the Steam
// Accounts modal so the operator sees their newly-linked account
// immediately. Also handles the err variant so they see the reason.
document.addEventListener("DOMContentLoaded", () => {
  const params = new URLSearchParams(location.search);
  const linked = params.get("steam_linked");
  if (linked === "ok") {
    history.replaceState({}, "", location.pathname);
    setTimeout(() => {
      toast("Steam account linked", "ok");
      try { openSteamAccountsModal(); } catch {}
    }, 200);
  } else if (linked === "err") {
    history.replaceState({}, "", location.pathname);
    const msg = params.get("msg") || "unknown";
    toast(`Steam link failed: ${msg}`, "err", 9000);
  }
});
document.addEventListener("DOMContentLoaded", () => {
  const refresh = $("#sa-refresh");
  if (refresh) refresh.addEventListener("click", () => renderSteamAccountsModal());
  const add = $("#sa-add");
  if (add) add.addEventListener("click", async () => {
    const id = prompt("Paste the new user's SteamID64 (17 digits, starts with 7656119):", "");
    if (!id) return;
    const trimmed = id.trim();
    if (!/^\d{17}$/.test(trimmed)) {
      toast("That doesn't look like a SteamID64 (need 17 digits).", "err");
      return;
    }
    const role = (prompt("Role for this user — type 'admin' for full panel access, blank for normal 'user':", "") || "").trim().toLowerCase();
    if (role && role !== "admin" && role !== "user") {
      toast("Role must be 'admin' or 'user' (or blank).", "err");
      return;
    }
    try {
      const r = await fetchJSON("/api/users", {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ steam_id: trimmed, role: role || "user" }),
      });
      toast(`Created user ${r.email} (Steam ${r.steam_id})`, "ok");
      await renderSteamAccountsModal();
    } catch (err) {
      toast(`Add failed: ${err.message}`, "err", 8000);
    }
  });
});
document.addEventListener("click", async e => {
  const linkID = e.target.dataset && e.target.dataset.saLink;
  const unlinkID = e.target.dataset && e.target.dataset.saUnlink;
  if (linkID) {
    e.preventDefault();
    const id = prompt("Paste the 17-digit SteamID64 to link:", "");
    if (!id) return;
    const trimmed = id.trim();
    if (!/^7656119\d{10}$/.test(trimmed)) {
      toast("That doesn't look like a SteamID64 (need 17 digits starting with 7656119).", "err");
      return;
    }
    try {
      await fetchJSON(`/api/users/${encodeURIComponent(linkID)}/steam`, {
        method: "POST", headers: {"Content-Type":"application/json"},
        body: JSON.stringify({ steam_id: trimmed }),
      });
      toast("Steam linked", "ok");
      await renderSteamAccountsModal();
    } catch (err) { toast(`Link failed: ${err.message}`, "err"); }
  }
  if (unlinkID) {
    e.preventDefault();
    if (!confirm("Remove this Steam link?")) return;
    try {
      await fetchJSON(`/api/users/${encodeURIComponent(unlinkID)}/steam`, { method: "DELETE" });
      toast("Steam unlinked", "ok");
      await renderSteamAccountsModal();
    } catch (err) { toast(`Unlink failed: ${err.message}`, "err"); }
  }
});

// ===================================================================
// Empyrion EAH tab — calls controller's /api/instances/{id}/empyrion/*
// proxy, which forwards to the per-agent panel-empyrion-bridge.
// Sub-views: Players / Structures / Factions / Chat / Items / Console
// ===================================================================
// Default sub-tab is Console — Players moved to its own top-level tab,
// so the most-used remaining admin function leads here.
const eahState = { sub: "console" };

async function eahFetch(sub, opts = {}) {
  const id = instanceModalState.id;
  if (!id) throw new Error("no instance");
  const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/${sub}`, opts);
  if (!r.ok) {
    const txt = await r.text();
    let msg = txt;
    try { msg = JSON.parse(txt).error || msg; } catch {}
    throw new Error(`${r.status}: ${msg}`);
  }
  return r.json();
}

function eahActivatePill(pill) {
  if (!pill) return;
  eahState.sub = pill.dataset.eah;
  $$("#im-eah-pills .eah-pill").forEach(b => b.classList.toggle("active", b === pill));
  pill.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
  renderEAHSub();
}

async function loadEmpyrionEAHTab() {
  // Pills wired once.
  if (!$("#im-eah-pills").dataset.wired) {
    $("#im-eah-pills").dataset.wired = "1";
    $("#im-eah-pills").addEventListener("click", e => {
      const p = e.target.closest(".eah-pill"); if (!p) return;
      eahActivatePill(p);
    });
    // Keyboard: 1-9 + 0 jump to pill N; [ ] cycle prev/next. Only fires
    // while the EAH tab is active and no text-entry element has focus.
    document.addEventListener("keydown", e => {
      if (instanceModalState.tab !== "empyrioneah") return;
      if (e.altKey || e.ctrlKey || e.metaKey) return;
      const t = e.target;
      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
      const pills = $$("#im-eah-pills .eah-pill");
      if (!pills.length) return;
      const cur = pills.findIndex(p => p.classList.contains("active"));
      let target = -1;
      if (/^[1-9]$/.test(e.key)) {
        target = parseInt(e.key, 10) - 1;
      } else if (e.key === "0") {
        target = 9;
      } else if (e.key === "[") {
        target = (cur - 1 + pills.length) % pills.length;
      } else if (e.key === "]") {
        target = (cur + 1) % pills.length;
      } else {
        return;
      }
      if (target < 0 || target >= pills.length) return;
      e.preventDefault();
      eahActivatePill(pills[target]);
    });
  }
  renderEAHSub();
}

function renderEAHSub() {
  // Tear down any subscription from the previous pill — the live SSE stream
  // and the ban-list 1s countdown interval (which otherwise keeps ticking
  // against detached DOM until the next Bans render replaces it).
  _eahLiveStop();
  if (window._banTimer) { clearInterval(window._banTimer); window._banTimer = null; }
  const body = $("#im-eah-body");
  body.innerHTML = loadingState("Loading…");
  switch (eahState.sub) {
    case "players":    return renderEAHPlayers(body);
    case "structures": return renderEAHStructures(body);
    case "factions":   return renderEAHFactions(body);
    case "chat":       return renderEAHChat(body);
    case "items":      return renderEAHItems(body);
    case "console":    return renderEAHConsole(body);
    case "scenarios":  return renderEAHScenarios(body);
    case "history":    return renderEAHHistory(body);
    case "templates":  return renderEAHTemplates(body);
    case "map":        return renderEAHMap(body);
    case "chatlog":    return renderEAHChatLog(body);
    case "missions":   return renderEAHMissions(body);
    case "blueprints": return renderEAHBlueprints(body);
    case "recipes":    return renderEAHRecipes(body);
    case "loot":       return renderEAHLoot(body);
    case "health":     return renderEAHHealth(body);
    case "galaxy":     return renderEAHGalaxy(body);
    case "bans":       return renderEAHBans(body);
    case "live":       return renderEAHLive(body);
    case "discoveries": return renderEAHDiscoveries(body);
    case "library":    return renderEAHLibrary(body);
    case "coords":     return renderEAHCoords(body);
    case "warnings":   return renderEAHWarnings(body);
    case "chatbot":    return renderEAHChatBot(body);
    case "dupes":      return renderEAHDupes(body);
    case "freshstart": return renderEAHFreshStart(body);
  }
}

// --- Galaxy / star-map view ---
async function renderEAHGalaxy(body) {
  body.innerHTML = loadingState("Loading galaxy from savegame…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/galaxy`);
    if (!r.ok) {
      const err = await r.text();
      body.innerHTML = `<div class="empty err">${esc(err)}</div>`;
      return;
    }
    const data = await r.json();
    const stars = data.stars || [];
    const sectors = data.sectors || [];
    if (stars.length === 0) {
      body.innerHTML = `<div class="empty">No stars in Sectors.yaml.</div>`;
      return;
    }
    const xs = stars.map(s => s.x), zs = stars.map(s => s.z), ys = stars.map(s => s.y || 0);
    // 5/95 percentile bounds (NOT 2/98 — when a galaxy has a few extreme
    // outliers like Sea-of-Silence at z=-1150, p2 still picks them up
    // and squashes the main cluster). Empyrion saves frequently put 10–20
    // distinct stars on the SAME (x, z) row at varying y; if we projected
    // pure top-down (x, z) they'd visually overlap as "circle after circle
    // on the same line" — what looks like duplication. Isometric tilt below
    // separates them by their y altitude.
    const pctRange = arr => {
      const s = [...arr].sort((a, b) => a - b);
      const lo = s[Math.max(0, Math.floor(0.05 * (s.length - 1)))];
      const hi = s[Math.min(s.length - 1, Math.ceil(0.95 * (s.length - 1)))];
      const pad = Math.max(5, (hi - lo) * 0.08);
      return [lo - pad, hi + pad];
    };
    const [minX, maxX] = pctRange(xs);
    const [minZ, maxZ] = pctRange(zs);
    const [minY, maxY] = pctRange(ys);
    const W = 760, H = 480;
    // Isometric-ish projection: x → screen-x; (z, y) → screen-y.
    // Treat y as up-axis (Empyrion's vertical), z as depth-axis. Stars
    // sharing (x, z) but differing in y separate vertically on screen.
    // yTilt = how much y influences screen-y. 0.35 = noticeable but doesn't
    // dominate the topology.
    const yTilt = 0.35;
    const yRange = (maxY - minY) || 1;
    const sx = x => ((x - minX) / (maxX - minX || 1)) * W;
    const sz = z => H - ((z - minZ) / (maxZ - minZ || 1)) * H;
    const projectY = (z, y) => {
      const baseY  = sz(z);
      const yShift = ((y - minY) / yRange) * H * yTilt - (H * yTilt) / 2;
      return baseY - yShift;
    };
    const isOutlier = s => s.x < minX || s.x > maxX || s.z < minZ || s.z > maxZ;
    const starColor = cls => {
      const c = (cls || "").toUpperCase();
      if (c.startsWith("O") || c.startsWith("B")) return "#9bb5ff";
      if (c.startsWith("A")) return "#cad7ff";
      if (c.startsWith("F")) return "#fff4ea";
      if (c.startsWith("G")) return "#fff2a1";
      if (c.startsWith("K")) return "#ffd2a1";
      if (c.startsWith("M")) return "#ffa899";
      if (c.startsWith("NEUTRON") || c.startsWith("WHITEDWARF")) return "#e0e0ff";
      if (c.startsWith("BLACKHOLE")) return "#222";
      return "var(--warn)";
    };
    // Procedural dim starfield — deterministic per-mount via a tiny LCG so
    // panning doesn't shimmer. ~250 background dots scattered across viewBox.
    let seed = 0xC0FFEE;
    const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 0xFFFFFFFF; };
    const bgDots = [];
    for (let i = 0; i < 250; i++) {
      const bx = rnd() * W, by = rnd() * H;
      const br = 0.4 + rnd() * 1.0;
      const op = 0.15 + rnd() * 0.45;
      bgDots.push(`<circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="${br.toFixed(2)}" fill="rgba(255,255,255,${op.toFixed(2)})"/>`);
    }
    const dots = stars.map(s => {
      const r = 4 + Math.min(8, Math.sqrt(s.playfieldCount));
      // Clamp outliers to canvas with 6 px margin so the glow stays visible.
      const out = isOutlier(s);
      const cx = Math.max(6, Math.min(W - 6, sx(s.x)));
      const cy = Math.max(6, Math.min(H - 6, projectY(s.z, s.y || 0)));
      const stroke = out ? "rgba(255,180,80,.95)" : "rgba(0,0,0,.45)";
      const strokeW = out ? 1.5 : 0.5;
      const titleSuffix = out ? `\n(outlier — true position x=${Math.round(s.x)} z=${Math.round(s.z)}; clamped to edge)` : "";
      return `<g class="galaxy-star${out ? " galaxy-outlier" : ""}" data-name="${esc(s.name)}">
        <title>${esc(s.name)} — class ${esc(s.starClass || "?")}, ${s.sectorCount} sectors, ${s.playfieldCount} playfields\nx=${Math.round(s.x)} y=${Math.round(s.y || 0)} z=${Math.round(s.z)}${titleSuffix}</title>
        <circle class="galaxy-glow" cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${(r * 2.5).toFixed(1)}" fill="${starColor(s.starClass)}" opacity="0.18"/>
        <circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r}" fill="${starColor(s.starClass)}" stroke="${stroke}" stroke-width="${strokeW}"/>
        <text class="galaxy-label" x="${(cx + r + 4).toFixed(1)}" y="${(cy + 4).toFixed(1)}" font-size="10" fill="rgba(255,255,255,.85)">${esc(s.name)}</text>
      </g>`;
    }).join("");
    const outlierCount = stars.filter(isOutlier).length;
    body.innerHTML = `
      <div class="pad">
        <div class="row" style="display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap:wrap">
          <h3 style="margin:0">Galaxy (${stars.length} stars · ${sectors.length} sectors)</h3>
          <div class="galaxy-controls" style="margin-left:auto; display:flex; gap:6px; align-items:center">
            <button class="small" id="galaxy-zoom-out" title="Zoom out (or scroll wheel)">−</button>
            <button class="small" id="galaxy-zoom-in"  title="Zoom in (or scroll wheel)">+</button>
            <button class="small" id="galaxy-reset"    title="Reset view (double-click the map)">Reset</button>
            <span class="dim small">drag to pan · wheel to zoom</span>
          </div>
        </div>
        <div style="display:grid; grid-template-columns: 1fr 280px; gap:12px">
          <div>
            <svg id="galaxy-svg" viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="xMidYMid meet"
                 style="background:radial-gradient(ellipse at 30% 20%, #1a2348 0%, #060914 55%, #02030a 100%); border-radius:var(--radius); cursor:grab; user-select:none; touch-action:none">
              <g id="galaxy-bg">${bgDots.join("")}</g>
              <line x1="0" y1="${H/2}" x2="${W}" y2="${H/2}" stroke="rgba(255,255,255,.05)"/>
              <line x1="${W/2}" y1="0" x2="${W/2}" y2="${H}" stroke="rgba(255,255,255,.05)"/>
              <g id="galaxy-stars">${dots}</g>
            </svg>
            <div class="dim small" style="margin-top:4px">iso projection · x→horiz, z+y→vert · range x:[${Math.round(minX)},${Math.round(maxX)}] y:[${Math.round(minY)},${Math.round(maxY)}] z:[${Math.round(minZ)},${Math.round(maxZ)}] LY${outlierCount ? ` · ${outlierCount} outlier${outlierCount===1?"":"s"} clamped (orange ring; hover for true coords)` : ""} · source: ${esc(data.path || "")}</div>
          </div>
          <div id="galaxy-detail" style="max-height:60vh; overflow:auto"><div class="empty small">Click a star to see its sectors and playfields.</div></div>
        </div>
      </div>`;
    const svg = document.getElementById("galaxy-svg");
    // Pan / zoom state. We mutate viewBox in place; SVG handles scaling.
    const view = { x: 0, y: 0, w: W, h: H };
    const apply = () => svg.setAttribute("viewBox", `${view.x.toFixed(2)} ${view.y.toFixed(2)} ${view.w.toFixed(2)} ${view.h.toFixed(2)}`);
    const reset = () => { view.x = 0; view.y = 0; view.w = W; view.h = H; apply(); };
    // Convert a client (px) point to user-space (viewBox) coords.
    const toUser = (clientX, clientY) => {
      const r = svg.getBoundingClientRect();
      return { x: view.x + ((clientX - r.left) / r.width) * view.w,
               y: view.y + ((clientY - r.top)  / r.height) * view.h };
    };
    svg.addEventListener("wheel", e => {
      e.preventDefault();
      const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
      const newW = view.w * factor, newH = view.h * factor;
      // Clamp: don't zoom out past 4× or in past 0.05×.
      if (newW > W * 4 || newW < W * 0.05) return;
      const before = toUser(e.clientX, e.clientY);
      view.w = newW; view.h = newH;
      const after = toUser(e.clientX, e.clientY);
      view.x += before.x - after.x;
      view.y += before.y - after.y;
      apply();
    }, { passive: false });
    let drag = null;
    svg.addEventListener("pointerdown", e => {
      if (e.target.closest(".galaxy-star")) return; // let click handler win
      drag = { startX: e.clientX, startY: e.clientY, vx: view.x, vy: view.y };
      svg.setPointerCapture(e.pointerId);
      svg.style.cursor = "grabbing";
    });
    svg.addEventListener("pointermove", e => {
      if (!drag) return;
      const r = svg.getBoundingClientRect();
      const dx = (e.clientX - drag.startX) * (view.w / r.width);
      const dy = (e.clientY - drag.startY) * (view.h / r.height);
      view.x = drag.vx - dx;
      view.y = drag.vy - dy;
      apply();
    });
    const endDrag = e => {
      if (drag) { svg.releasePointerCapture?.(e.pointerId); drag = null; svg.style.cursor = "grab"; }
    };
    svg.addEventListener("pointerup", endDrag);
    svg.addEventListener("pointercancel", endDrag);
    svg.addEventListener("dblclick", reset);
    document.getElementById("galaxy-reset").addEventListener("click", reset);
    document.getElementById("galaxy-zoom-in").addEventListener("click", () => {
      const cx = view.x + view.w / 2, cy = view.y + view.h / 2;
      view.w /= 1.4; view.h /= 1.4;
      view.x = cx - view.w / 2; view.y = cy - view.h / 2;
      apply();
    });
    document.getElementById("galaxy-zoom-out").addEventListener("click", () => {
      const cx = view.x + view.w / 2, cy = view.y + view.h / 2;
      const newW = view.w * 1.4, newH = view.h * 1.4;
      if (newW > W * 4) return;
      view.w = newW; view.h = newH;
      view.x = cx - view.w / 2; view.y = cy - view.h / 2;
      apply();
    });
    body.querySelectorAll(".galaxy-star").forEach(el => el.addEventListener("click", () => {
      const name = el.dataset.name;
      const star = stars.find(s => s.name === name);
      const localSectors = sectors.filter(s => s.star === name);
      const totalPF = localSectors.reduce((a, s) => a + (s.playfields || []).length, 0);
      $("#galaxy-detail").innerHTML = `
        <h4 style="margin:0 0 4px">${esc(name)}</h4>
        <div class="dim small">Class ${esc(star?.starClass || "?")} · ${star?.x.toFixed(0)}, ${star?.y.toFixed(0)}, ${star?.z.toFixed(0)} LY · ${star?.sectorCount} sectors · ${totalPF} playfields</div>
        ${localSectors.map(sec => `
          <div class="galaxy-sector">
            <div><strong>Sector</strong> [${sec.x},${sec.y},${sec.z}] <span class="dim small">${esc(sec.type || "")}</span></div>
            ${(sec.playfields || []).map(p => `<div class="galaxy-pf">${esc(p.name)} <span class="dim small">${esc(p.type)}</span></div>`).join("")}
          </div>`).join("")}`;
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Bans (with expiry countdown) ---
async function renderEAHBans(body) {
  body.innerHTML = loadingState("Loading ban list…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/banned`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const list = (data && data.BannedPlayers) || [];
    if (!list.length) {
      body.innerHTML = `<div class="empty"><span class="dim">No banned players.</span> <button class="small" id="bans-refresh">↻ Refresh</button></div>`;
      $("#bans-refresh")?.addEventListener("click", () => renderEAHBans(body));
      return;
    }
    const now = Date.now();
    const rows = list.map(b => {
      // dateTime is .NET DateTime.ToBinary() — we receive raw int64.
      // Empyrion stores ban expiry as the ToBinary representation of a UTC
      // DateTime. Heuristic conversion to ms epoch:
      // ticks (100ns since 0001-01-01) → ms since 1970-01-01:
      //   raw & 0x3FFFFFFFFFFFFFFF gives the ticks; (ticks - 621355968000000000) / 10000 = ms
      const raw = BigInt(b.dateTime || 0);
      const ticks = raw & 0x3FFFFFFFFFFFFFFFn;
      const ms = Number((ticks - 621355968000000000n) / 10000n);
      const expires = isFinite(ms) && ms > 0 ? ms : null;
      const remaining = expires ? Math.max(0, expires - now) : null;
      return { steamId: b.steam64Id, raw: b.dateTime, expires, remaining };
    });
    body.innerHTML = `
      <div class="pad">
        <div class="row" style="display:flex; gap:8px; align-items:center; margin-bottom:8px">
          <h3 style="margin:0">Ban list (${rows.length})</h3>
          <button class="small" id="bans-refresh">↻ Refresh</button>
        </div>
        <table class="eah-table">
          <thead><tr><th>Steam ID</th><th>Expires</th><th>Time remaining</th><th></th></tr></thead>
          <tbody>${rows.map(b => `
            <tr data-steam="${b.steamId}">
              <td><code>${esc(String(b.steamId))}</code></td>
              <td class="dim small">${b.expires ? new Date(b.expires).toLocaleString() : "permanent"}</td>
              <td class="ban-remaining" data-expires="${b.expires || 0}">${b.remaining !== null ? formatDuration(b.remaining) : "—"}</td>
              <td><button class="small primary" data-unban="${b.steamId}">Unban</button></td>
            </tr>`).join("")}
          </tbody>
        </table>
      </div>`;
    // Live countdown — tick once per second.
    const tick = () => {
      const now = Date.now();
      body.querySelectorAll(".ban-remaining[data-expires]").forEach(td => {
        const exp = parseInt(td.dataset.expires, 10);
        if (!exp) return;
        td.textContent = formatDuration(Math.max(0, exp - now));
      });
    };
    if (window._banTimer) clearInterval(window._banTimer);
    window._banTimer = setInterval(tick, 1000);
    $("#bans-refresh").addEventListener("click", () => renderEAHBans(body));
    body.querySelectorAll("button[data-unban]").forEach(b => b.addEventListener("click", async () => {
      const steam = b.dataset.unban;
      // Empyrion's "ban remove" console command takes the steam ID.
      try {
        const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/console`, {
          method: "POST", headers: { "Content-Type": "text/plain" },
          body: `ban remove ${steam}`
        });
        if (!res.ok) throw new Error(await res.text());
        toast("Unban sent — refresh in a few seconds", "ok");
        setTimeout(() => renderEAHBans(body), 2000);
      } catch (err) { toast(err.message, "err"); }
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

function formatDuration(ms) {
  if (ms <= 0) return "expired";
  const s = Math.floor(ms / 1000);
  const d = Math.floor(s / 86400);
  const h = Math.floor((s % 86400) / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (d > 0) return `${d}d ${h}h ${m}m`;
  if (h > 0) return `${h}h ${m}m ${sec}s`;
  if (m > 0) return `${m}m ${sec}s`;
  return `${sec}s`;
}

// --- Live event tail (SSE) ---
//
// Subscribes to the bridge's /events stream via the controller proxy and
// renders incoming Event_* packages as a scrolling log. Buffer is capped
// so a busy server doesn't blow up the DOM.
let _eahLiveES = null;
function _eahLiveStop() {
  if (_eahLiveES) { try { _eahLiveES.close(); } catch {} _eahLiveES = null; }
}

function renderEAHLive(body) {
  const id = instanceModalState.id;
  const MAX_ROWS = 500;
  const buf = []; // newest first
  let filterCmd = "";
  let paused = false;

  body.innerHTML = `
    <div class="pad" style="display:flex; flex-direction:column; gap:8px; height:60vh">
      <div class="row" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">
        <h3 style="margin:0">Live event stream</h3>
        <span id="live-status" class="dim small" style="margin-left:8px">connecting…</span>
        <span style="margin-left:auto"></span>
        <input type="text" id="live-filter" placeholder="filter by event name (e.g. Chat, Player_Connected)" style="width:280px; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit">
        <button class="small" id="live-pause">⏸ Pause</button>
        <button class="small" id="live-clear">Clear</button>
      </div>
      <div class="dim small">Streaming directly from the bridge — every <code>Event_*</code> the mod fans out lands here. Buffer holds the last ${MAX_ROWS} entries.</div>
      <div id="live-log" style="flex:1; overflow:auto; font-family:ui-monospace, Menlo, Consolas, monospace; font-size:12px; line-height:1.45; background:rgba(0,0,0,.3); border:1px solid rgba(255,255,255,.06); border-radius:var(--radius); padding:8px"></div>
    </div>`;

  const statusEl = body.querySelector("#live-status");
  const logEl = body.querySelector("#live-log");
  const filterEl = body.querySelector("#live-filter");
  const pauseBtn = body.querySelector("#live-pause");
  const clearBtn = body.querySelector("#live-clear");

  const fmtCell = (v) => v === undefined || v === null ? "" : esc(String(v));
  const renderRow = (ev) => {
    const cmd = (ev.cmd || "").replace(/^Event_/, "");
    const playerLabel = ev.playerName ? ev.playerName : (ev.playerId ? `#${ev.playerId}` : "");
    const detailJson = ev.data ? JSON.stringify(ev.data) : "";
    const detail = detailJson.length > 220 ? detailJson.slice(0, 220) + "…" : detailJson;
    return `<div class="live-row"><span class="live-ts">${fmtCell(ev.ts || new Date().toISOString().slice(11,19))}</span> <span class="live-cmd">${fmtCell(cmd)}</span>${playerLabel ? ` <span class="live-player">${fmtCell(playerLabel)}</span>` : ""}${ev.playfield ? ` <span class="live-pf">${fmtCell(ev.playfield)}</span>` : ""}${detail ? ` <span class="live-detail">${fmtCell(detail)}</span>` : ""}</div>`;
  };

  const repaint = () => {
    if (paused) return;
    const filtered = filterCmd
      ? buf.filter(e => (e.cmd || "").toLowerCase().includes(filterCmd))
      : buf;
    logEl.innerHTML = filtered.slice(0, MAX_ROWS).map(renderRow).join("");
  };

  filterEl.addEventListener("input", () => { filterCmd = filterEl.value.trim().toLowerCase(); repaint(); });
  pauseBtn.addEventListener("click", () => {
    paused = !paused;
    pauseBtn.textContent = paused ? "▶ Resume" : "⏸ Pause";
    if (!paused) repaint();
  });
  clearBtn.addEventListener("click", () => { buf.length = 0; logEl.innerHTML = ""; });

  _eahLiveStop();
  try {
    const es = new EventSource(`/api/instances/${encodeURIComponent(id)}/empyrion/events`);
    _eahLiveES = es;
    es.addEventListener("open", () => { statusEl.textContent = "connected"; statusEl.style.color = "rgba(80,220,140,.85)"; });
    es.addEventListener("error", () => { statusEl.textContent = "disconnected — retrying"; statusEl.style.color = "rgba(255,160,90,.85)"; });
    es.addEventListener("message", (e) => {
      let payload;
      try { payload = JSON.parse(e.data); } catch { return; }
      // bridge wraps as { cmd, seq, data, ts? } — we tolerate either flat or nested.
      const ev = {
        ts: payload.ts || new Date().toISOString().slice(11, 19),
        cmd: payload.cmd || "(unknown)",
        playerId: payload.playerId ?? payload.data?.id ?? payload.data?.entityId,
        playerName: payload.playerName ?? payload.data?.playerName ?? payload.data?.name,
        playfield: payload.playfield ?? payload.data?.playfield,
        data: payload.data ?? payload,
      };
      buf.unshift(ev);
      if (buf.length > MAX_ROWS) buf.length = MAX_ROWS;
      repaint();
    });
  } catch (err) {
    statusEl.textContent = `error: ${err.message}`;
    statusEl.style.color = "rgba(255,90,90,.9)";
  }
}

// --- Discoveries (per-player POI map) ---
//
// Queries the empyrion server's global.db SQLite directly via the agent.
// Two modes: "personal" (POIs THIS player walked into) and "faction"
// (everything anyone in their faction has — matches the in-game shared
// map). Operator picks a player from the known-players autocomplete.
async function renderEAHDiscoveries(body) {
  const id = instanceModalState.id;
  // Pull the known-players list to populate the autocomplete. Reuses
  // the same DB-backed projection the History pill shows.
  let knownPlayers = [];
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players`);
    if (r.ok) {
      const data = await r.json();
      knownPlayers = (data.players || []).filter(p => p.steamId);
    }
  } catch {}
  body.innerHTML = `
    <div class="pad" style="display:flex; flex-direction:column; gap:14px">
      <div style="display:flex; align-items:flex-end; gap:10px; flex-wrap:wrap">
        <div style="flex:1; min-width:240px">
          <label class="dim small" style="display:block; margin-bottom:4px">Player</label>
          <select id="eah-disc-player" style="width:100%; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit">
            <option value="">— pick a known player —</option>
            ${knownPlayers.map(p => `<option value="${esc(p.steamId)}">${esc(p.playerName || p.steamId)} <span class="dim">(${esc(p.steamId)})</span></option>`).join("")}
          </select>
          <div class="form-hint">Or paste a SteamID64 below if the player isn't in the list yet.</div>
          <input id="eah-disc-steamid" placeholder="76561198…" style="width:100%; margin-top:4px; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit; font-family:var(--mono); font-size:12px">
        </div>
        <div>
          <label class="dim small" style="display:block; margin-bottom:4px">Mode</label>
          <select id="eah-disc-mode" style="padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit">
            <option value="personal" selected>Personal</option>
            <option value="faction">Faction-shared</option>
          </select>
        </div>
        <button class="primary" id="eah-disc-go">Look up</button>
      </div>
      <div id="eah-disc-results"><div class="dim small">Pick a player and click Look up. Personal mode = POIs they walked into themselves. Faction mode = the in-game shared map (anyone in their faction's discoveries).</div></div>
    </div>`;
  const goBtn = $("#eah-disc-go");
  const resultsEl = $("#eah-disc-results");
  goBtn.addEventListener("click", async () => {
    const sid = ($("#eah-disc-steamid").value || $("#eah-disc-player").value || "").trim();
    const mode = $("#eah-disc-mode").value;
    if (!sid) { toast("Pick a player or paste a SteamID64", "err"); return; }
    if (!/^\d{17}$/.test(sid)) {
      toast("SteamID64 should be 17 digits", "err");
      return;
    }
    goBtn.disabled = true; goBtn.textContent = "Loading…";
    resultsEl.innerHTML = `<div class="empty">querying global.db on the agent…</div>`;
    try {
      const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/discoveries?player=${encodeURIComponent(sid)}&mode=${encodeURIComponent(mode)}`);
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      if (data.error) throw new Error(data.error);
      renderEAHDiscoveriesTable(resultsEl, data, mode);
    } catch (err) {
      resultsEl.innerHTML = `<div class="empty err">${esc(err.message)}</div>`;
    } finally {
      goBtn.disabled = false; goBtn.textContent = "Look up";
    }
  });
}

function renderEAHDiscoveriesTable(wrap, data, mode) {
  const pois = data.pois || [];
  const playerLabel = data.player_name ? `${esc(data.player_name)} (${esc(data.steam_id)})` : esc(data.steam_id);
  if (!pois.length) {
    wrap.innerHTML = `<div class="empty">
      <div class="big">🗺️</div>
      No discoveries yet for ${playerLabel}.
      <span class="hint">${mode === "faction" ? "No one in their faction has discovered anything." : "They haven't walked into a POI yet — the in-game game registers POIs when the player gets close enough to read the name overhead."}</span>
    </div>`;
    return;
  }
  // Group by solar system → playfield → POIs.
  const bySys = new Map();
  for (const p of pois) {
    const sys = p.solar_system || "—";
    if (!bySys.has(sys)) bySys.set(sys, new Map());
    const byPF = bySys.get(sys);
    if (!byPF.has(p.playfield)) byPF.set(p.playfield, []);
    byPF.get(p.playfield).push(p);
  }
  const sysList = [...bySys.keys()].sort();
  wrap.innerHTML = `
    <div style="display:flex; align-items:baseline; gap:10px; margin-bottom:8px">
      <strong>${pois.length} discoveries</strong>
      <span class="dim small">${mode === "faction" ? "(faction-shared view)" : "(personal)"} for ${playerLabel}</span>
      <span style="margin-left:auto"><input id="eah-disc-filter" placeholder="filter…" style="padding:4px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit; width:200px"></span>
    </div>
    <div id="eah-disc-list">
    ${sysList.map(sys => {
      const byPF = bySys.get(sys);
      const pfList = [...byPF.keys()].sort();
      return `<details class="eah-disc-sys" open>
        <summary><strong>🌟 ${esc(sys)}</strong> <span class="dim small">${pfList.length} playfield${pfList.length === 1 ? "" : "s"}</span></summary>
        ${pfList.map(pf => {
          const list = byPF.get(pf);
          return `<details class="eah-disc-pf" open style="margin-left:18px">
            <summary>🪐 <strong>${esc(pf)}</strong> <span class="dim small">${list.length} POI${list.length === 1 ? "" : "s"}</span></summary>
            <table class="eah-table" style="margin:6px 0 12px 18px; width:calc(100% - 18px)"><thead>
              <tr><th>POI</th><th>Type</th><th>Discoverer</th><th>Game-time</th></tr>
            </thead><tbody>
              ${list.map(p => `<tr class="eah-disc-row" data-search="${esc((p.name + " " + (p.discoverer_name||"") + " " + sys + " " + pf).toLowerCase())}">
                <td>${esc(p.name)}</td>
                <td class="dim small">${p.type}</td>
                <td class="dim small">${esc(p.discoverer_name || "—")}</td>
                <td class="dim small">${p.game_time}</td>
              </tr>`).join("")}
            </tbody></table>
          </details>`;
        }).join("")}
      </details>`;
    }).join("")}
    </div>`;
  // Cheap client-side filter — hide rows whose data-search doesn't match.
  $("#eah-disc-filter").addEventListener("input", e => {
    const q = e.target.value.toLowerCase().trim();
    document.querySelectorAll("#eah-disc-list .eah-disc-row").forEach(tr => {
      tr.style.display = (!q || tr.dataset.search.includes(q)) ? "" : "none";
    });
  });
}

// --- Chat log (DB-backed) ---
async function renderEAHChatLog(body) {
  body.innerHTML = loadingState("Loading chat log…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/chat?limit=400`);
    const data = await r.json();
    const msgs = data.messages || [];
    const channels = ["Global", "Faction", "Allies", "Whisper", "Server"];
    body.innerHTML = `
      <div class="pad">
        <div class="row" style="display:flex; gap:8px; margin-bottom:12px">
          <select id="chat-channel"><option value="">All channels</option>${channels.map((c, i) => `<option value="${i}">${c}</option>`).join("")}</select>
          <button class="small" id="chat-r">↻ Refresh</button>
          <span class="dim small" style="margin-left:auto">${msgs.length} messages (latest first)</span>
        </div>
        <div id="chat-out">${msgs.map(m => `
          <div class="chat-row">
            <span class="dim small">${esc(m.ts)}</span>
            <span class="chat-ch chat-ch-${m.channel}">${esc(channels[m.channel] || "?")}</span>
            <strong>${esc(m.playerName || ("#" + (m.playerId || 0)))}</strong>
            <span>${esc(m.msg || "")}</span>
          </div>`).join("") || `<div class="empty">No chat captured yet — players need to talk.</div>`}
        </div>
      </div>`;
    $("#chat-r").addEventListener("click", () => renderEAHChatLog(body));
    $("#chat-channel").addEventListener("change", async e => {
      const ch = e.target.value;
      const r2 = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/chat?limit=400${ch !== "" ? `&channel=${ch}` : ""}`);
      const d = await r2.json();
      $("#chat-out").innerHTML = (d.messages || []).map(m => `
        <div class="chat-row">
          <span class="dim small">${esc(m.ts)}</span>
          <span class="chat-ch chat-ch-${m.channel}">${esc(channels[m.channel] || "?")}</span>
          <strong>${esc(m.playerName || ("#" + (m.playerId || 0)))}</strong>
          <span>${esc(m.msg || "")}</span>
        </div>`).join("");
    });
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- PDA Missions ---
async function renderEAHMissions(body) {
  body.innerHTML = loadingState("Loading missions…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/missions`);
    const data = await r.json();
    const list = data.missions || [];
    body.innerHTML = `
      <div class="pad">
        <h3 style="margin:0 0 8px">PDA mission progress</h3>
        <div class="dim small" style="margin-bottom:8px">Captured from Event_PdaStateChange. Each row is the most recent state for a player's chapter.</div>
        ${list.length === 0 ? `<div class="empty">No PDA events yet — players need to interact with PDA missions.</div>` : `
        <table class="eah-table">
          <thead><tr><th>Time</th><th>Player</th><th>State</th></tr></thead>
          <tbody>${list.map(m => `
            <tr>
              <td class="dim small">${esc(m.ts)}</td>
              <td>${esc(m.playerName || ("#" + (m.playerId || 0)))}</td>
              <td><pre style="margin:0; font-size:11px; max-width:560px; white-space:pre-wrap">${esc(JSON.stringify(m.state))}</pre></td>
            </tr>`).join("")}</tbody>
        </table>`}
      </div>`;
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Blueprints ---
async function renderEAHBlueprints(body) {
  body.innerHTML = loadingState("Scanning Saves/Blueprints…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprints`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const list = data.blueprints || [];
    body.innerHTML = `
      <div class="pad">
        <h3 style="margin:0 0 8px">Blueprints (${list.length})</h3>
        <div class="dim small" style="margin-bottom:8px">Files under <code>Saves/Blueprints/&lt;steamId&gt;/</code>. Click "Spawn" to drop one near a connected player via the console.</div>
        ${list.length === 0 ? `<div class="empty">No blueprints yet — players save them in-game with the build menu.</div>` : `
        <table class="eah-table">
          <thead><tr><th>Name</th><th>Size</th><th>Path</th><th></th></tr></thead>
          <tbody>${list.map(b => `
            <tr>
              <td><strong>${esc(b.name.replace(/\.epb$/, ""))}</strong></td>
              <td class="dim small">${(b.size / 1024).toFixed(1)} KB</td>
              <td class="dim small">${esc(b.path)}</td>
              <td><button class="small" data-spawn="${esc(b.name.replace(/\.epb$/, ""))}">Spawn near…</button></td>
            </tr>`).join("")}</tbody>
        </table>`}
      </div>`;
    body.querySelectorAll("[data-spawn]").forEach(b => b.addEventListener("click", async () => {
      const pid = prompt("Spawn near which player ID?");
      if (!pid) return;
      try {
        const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprints/${encodeURIComponent(b.dataset.spawn)}/spawn`, {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ playerId: parseInt(pid, 10) })
        });
        if (!res.ok) throw new Error(await res.text());
        toast(`Spawn requested: ${b.dataset.spawn}`, "ok");
      } catch (err) { toast(err.message, "err"); }
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Recipes ---
let _eahRecipeItem = null;
async function renderEAHRecipes(body) {
  const data = await _eahLoadItems();
  body.innerHTML = `
    <div class="pad">
      <input id="recipe-q" placeholder="Search items by name…" autocomplete="off" style="width:100%; margin-bottom:8px">
      <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px">
        <div id="recipe-list" style="max-height:60vh; overflow:auto; padding-right:8px"></div>
        <div id="recipe-detail" style="max-height:60vh; overflow:auto; padding-left:8px; border-left:1px solid rgba(255,255,255,.06)"><div class="empty small">Pick an item.</div></div>
      </div>
    </div>`;
  const drawList = q => {
    const ql = q.toLowerCase();
    const list = data.items.filter(it => !ql || it.name.toLowerCase().includes(ql)).slice(0, 200);
    $("#recipe-list").innerHTML = list.map(it => `
      <div class="eah-give-row" data-name="${esc(it.name)}" data-id="${it.id}" style="display:flex; gap:8px; align-items:center">
        <img class="eah-item-icon" src="/api/empyrion/items/${it.id}/icon.png" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
        <div><strong>${esc(it.name)}</strong> <span class="dim">#${it.id}</span></div>
      </div>`).join("");
    $("#recipe-list").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => loadRecipe(row.dataset.name, row.dataset.id)));
  };
  drawList("");
  $("#recipe-q").addEventListener("input", e => drawList(e.target.value));

  async function loadRecipe(name, id) {
    $("#recipe-detail").innerHTML = `<div class="empty small">Loading…</div>`;
    try {
      const r = await fetch(`/api/empyrion/recipe/${encodeURIComponent(name)}`);
      if (r.status === 404) {
        $("#recipe-detail").innerHTML = `<div class="empty small"><strong>${esc(name)}</strong> is a base item — no crafting recipe.</div>`;
        return;
      }
      const d = await r.json();
      const recipe = d.recipe;
      const used = d.usedIn || [];
      const itemsByName = {};
      data.items.forEach(it => { itemsByName[it.name.toLowerCase()] = it; });
      $("#recipe-detail").innerHTML = `
        <h3 style="margin:0 0 8px"><img class="eah-item-icon" src="/api/empyrion/items/${id}/icon.png" alt="" onerror="this.style.visibility='hidden'" style="vertical-align:middle"> ${esc(recipe.name)}</h3>
        <div class="dim small"><strong>Craft target:</strong> ${esc(recipe.target || "(any)")}</div>
        <div class="dim small"><strong>Craft time:</strong> ${esc(recipe.craftTime || "?")}s</div>
        ${recipe.outputCount ? `<div class="dim small"><strong>Output count:</strong> ${esc(recipe.outputCount)}</div>` : ""}
        ${recipe.baseItem ? `<div class="dim small">⛏️ Base ingredient (no recipe; mined / harvested directly)</div>` : ""}
        <h4 style="margin:12px 0 4px">Inputs</h4>
        <div>${Object.entries(recipe.inputs || {}).map(([k, v]) => {
          const item = itemsByName[k.toLowerCase()];
          return `<div class="eah-give-row" style="display:flex; gap:8px; align-items:center">
            ${item ? `<img class="eah-item-icon" src="/api/empyrion/items/${item.id}/icon.png" alt="" onerror="this.style.visibility='hidden'">` : ""}
            <div style="flex:1"><strong>${esc(k)}</strong>${item ? ` <span class="dim">#${item.id}</span>` : ""}</div>
            <div>×${esc(v)}</div>
          </div>`;
        }).join("") || `<div class="empty small">No inputs (free recipe).</div>`}
        </div>
        <h4 style="margin:12px 0 4px">Used in (${used.length})</h4>
        <div>${used.slice(0, 30).map(u => {
          const item = itemsByName[u.name.toLowerCase()];
          return `<div class="eah-give-row" data-name="${esc(u.name)}" data-id="${item?.id || 0}" style="display:flex; gap:8px; align-items:center; cursor:pointer">
            ${item ? `<img class="eah-item-icon" src="/api/empyrion/items/${item.id}/icon.png" alt="" onerror="this.style.visibility='hidden'">` : ""}
            <strong>${esc(u.name)}</strong>${item ? ` <span class="dim">#${item.id}</span>` : ""}
          </div>`;
        }).join("") || `<div class="empty small">Not used in any recipe.</div>`}</div>`;
      $("#recipe-detail").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => loadRecipe(row.dataset.name, row.dataset.id)));
    } catch (err) { $("#recipe-detail").innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
  }
}

// --- Loot tables ---
async function renderEAHLoot(body) {
  body.innerHTML = loadingState("Loading loot tables…");
  try {
    const r = await fetch("/api/empyrion/loot");
    const d = await r.json();
    const itemData = await _eahLoadItems();
    const itemsByName = {};
    itemData.items.forEach(it => { itemsByName[it.name.toLowerCase()] = it; });
    body.innerHTML = `
      <div class="pad">
        <input id="loot-q" placeholder="Search loot groups…" autocomplete="off" style="width:100%; margin-bottom:8px">
        <div style="display:grid; grid-template-columns: 240px 1fr; gap:12px">
          <div id="loot-list" style="max-height:60vh; overflow:auto; padding-right:8px"></div>
          <div id="loot-detail" style="max-height:60vh; overflow:auto"><div class="empty small">Pick a group.</div></div>
        </div>
      </div>`;
    const groups = d.groups || [];
    const drawList = q => {
      const ql = q.toLowerCase();
      const list = groups.filter(g => !ql || g.name.toLowerCase().includes(ql)).slice(0, 200);
      $("#loot-list").innerHTML = list.map(g => `
        <div class="eah-give-row" data-name="${esc(g.name)}">
          <strong>${esc(g.name)}</strong> <span class="dim small">(${(g.items || []).length})</span>
        </div>`).join("");
      $("#loot-list").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => drawDetail(row.dataset.name)));
    };
    const drawDetail = async name => {
      $("#loot-detail").innerHTML = `<div class="empty small">Loading…</div>`;
      const r2 = await fetch(`/api/empyrion/loot/${encodeURIComponent(name)}`);
      const g = await r2.json();
      $("#loot-detail").innerHTML = `
        <h3 style="margin:0 0 8px">${esc(g.name)}</h3>
        <div class="dim small">Count strategy: ${esc(g.count || "default")}</div>
        <table class="eah-table" style="margin-top:8px">
          <thead><tr><th></th><th>Item</th><th>Count</th><th>Probability</th></tr></thead>
          <tbody>${(g.items || []).map(it => {
            const item = itemsByName[it.item.toLowerCase()];
            return `<tr>
              <td>${item ? `<img class="eah-item-icon" src="/api/empyrion/items/${item.id}/icon.png" alt="" onerror="this.style.visibility='hidden'">` : ""}</td>
              <td><strong>${esc(it.item)}</strong>${item ? ` <span class="dim">#${item.id}</span>` : ""}</td>
              <td>${esc(it.count || "")}</td>
              <td class="dim small">${esc(it.probability || "")}</td>
            </tr>`;
          }).join("")}</tbody>
        </table>`;
    };
    drawList("");
    $("#loot-q").addEventListener("input", e => drawList(e.target.value));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Server health (dedi-stats sparkline) ---
async function renderEAHHealth(body) {
  body.innerHTML = loadingState("Loading server health…");
  try {
    const id = instanceModalState.id;
    let window = "1h";
    const draw = async () => {
      const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/dedi-stats?window=${window}`);
      const d = await r.json();
      const pts = d.points || [];
      body.innerHTML = `
        <div class="pad">
          <div class="row" style="display:flex; gap:8px; margin-bottom:12px; align-items:center">
            <h3 style="margin:0">Server health</h3>
            <select id="health-w" style="margin-left:auto">
              <option value="1h"${window === "1h" ? " selected" : ""}>Last hour</option>
              <option value="6h"${window === "6h" ? " selected" : ""}>Last 6h</option>
              <option value="24h"${window === "24h" ? " selected" : ""}>Last 24h</option>
              <option value="7d"${window === "7d" ? " selected" : ""}>Last 7d</option>
            </select>
            <button class="small" id="health-r">↻</button>
          </div>
          ${pts.length === 0 ? `<div class="empty">No dedi-stats heartbeats yet — they fire every ~60s once the server is up and the bridge is connected.</div>` : `
          <div class="health-grid">
            ${sparkline("FPS", pts.map(p => p.fps), pts, "fps")}
            ${sparkline("Heap (MB)", pts.map(p => p.heap), pts, "heap")}
            ${sparkline("Players", pts.map(p => p.players), pts, "players")}
            ${sparkline("Pfs running", pts.map(p => p.pfsRunning), pts, "pfsRunning")}
            ${sparkline("Pfs inactive", pts.map(p => p.pfsInactive), pts, "pfsInactive")}
            ${sparkline("Pfs active", pts.map(p => p.pfsActive), pts, "pfsActive")}
          </div>
          <div class="dim small" style="margin-top:8px">${pts.length} samples in ${window}.</div>`}
        </div>`;
      $("#health-w")?.addEventListener("change", e => { window = e.target.value; draw(); });
      $("#health-r")?.addEventListener("click", draw);
    };
    draw();
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

function sparkline(label, values, pts, key) {
  if (!values.length) return `<div class="health-card"><div class="dim small">${esc(label)}</div><div>—</div></div>`;
  const W = 220, H = 60;
  const min = Math.min(...values), max = Math.max(...values);
  const span = max - min || 1;
  const last = values[values.length - 1];
  const path = values.map((v, i) => {
    const x = (i / (values.length - 1 || 1)) * W;
    const y = H - ((v - min) / span) * (H - 4) - 2;
    return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
  }).join(" ");
  return `
    <div class="health-card">
      <div class="dim small">${esc(label)}</div>
      <div style="display:flex; align-items:baseline; gap:6px">
        <strong style="font-size:18px">${typeof last === "number" ? (Number.isInteger(last) ? last : last.toFixed(1)) : "?"}</strong>
        <span class="dim small">range ${typeof min === "number" ? min.toFixed(0) : "?"}–${typeof max === "number" ? max.toFixed(0) : "?"}</span>
      </div>
      <svg viewBox="0 0 ${W} ${H}" width="100%" height="60" preserveAspectRatio="none">
        <path d="${path}" fill="none" stroke="#5c7cfa" stroke-width="1.5"/>
      </svg>
    </div>`;
}

// --- History (DB-backed: known players + recent events) ---
async function renderEAHHistory(body) {
  body.innerHTML = loadingState("Loading history…");
  try {
    const id = instanceModalState.id;
    const [pRes, eRes] = await Promise.all([
      fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players`).then(r => r.json()),
      fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/events?limit=300`).then(r => r.json()),
    ]);
    const players = pRes.players || [];
    const events = eRes.events || [];
    body.innerHTML = `
      <div class="pad">
        <h3 style="margin:0 0 8px">Known players (${players.length})</h3>
        <div class="dim small" style="margin-bottom:8px">Projected from event log. Updates live as players join/leave. Notes are private to panel admins; auto-save on blur.</div>
        <table class="eah-table">
          <thead><tr><th>ID</th><th>Steam</th><th>Name</th><th>First seen</th><th>Last seen</th><th>Logins</th><th>Last playfield</th><th style="min-width:220px">Notes</th></tr></thead>
          <tbody>${players.map(p => `
            <tr>
              <td>${p.playerId}</td>
              <td>${esc(p.steamId || "")}</td>
              <td>${esc(p.playerName || "")}</td>
              <td class="dim small">${esc(p.firstSeen)}</td>
              <td class="dim small">${esc(p.lastSeen)}</td>
              <td>${p.loginCount}</td>
              <td>${esc(p.lastPlayfield || "")}</td>
              <td><textarea class="eah-note" data-pid="${p.playerId}" rows="1" placeholder="Add a note…">${esc(p.notes || "")}</textarea></td>
            </tr>`).join("") || `<tr><td colspan="8" class="empty">No history yet — players need to log in at least once after the bridge subscriber starts.</td></tr>`}
          </tbody>
        </table>
        <h3 style="margin:24px 0 8px">Recent events (${events.length})</h3>
        <table class="eah-table">
          <thead><tr><th>Time</th><th>Event</th><th>Player</th><th>Playfield</th><th>Details</th></tr></thead>
          <tbody>${events.map(e => `
            <tr>
              <td class="dim small">${esc(e.ts)}</td>
              <td>${esc(e.cmd.replace("Event_", ""))}</td>
              <td>${e.playerId ? esc(e.playerName || e.playerId) : ""}</td>
              <td>${esc(e.playfield || "")}</td>
              <td class="dim small">${esc(e.msg || "")}</td>
            </tr>`).join("") || `<tr><td colspan="5" class="empty">No events yet.</td></tr>`}
          </tbody>
        </table>
      </div>`;
    body.querySelectorAll("textarea.eah-note").forEach(ta => {
      let lastSaved = ta.value;
      ta.addEventListener("blur", async () => {
        if (ta.value === lastSaved) return;
        const pid = ta.dataset.pid;
        ta.classList.add("saving");
        try {
          const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players/${pid}/notes`, {
            method: "PUT",
            headers: { "Content-Type": "text/plain" },
            body: ta.value,
          });
          if (!res.ok) throw new Error(await res.text());
          lastSaved = ta.value;
          ta.classList.remove("saving");
          ta.classList.add("saved");
          setTimeout(() => ta.classList.remove("saved"), 1200);
        } catch (err) {
          ta.classList.remove("saving");
          ta.classList.add("save-err");
          toast(`Save note failed: ${err.message}`, "err");
        }
      });
    });
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Templates (inventory kits) ---
async function renderEAHTemplates(body) {
  body.innerHTML = loadingState("Loading kits…");
  try {
    const data = await _eahLoadItems();
    const r = await fetch("/api/empyrion/templates");
    const tdata = await r.json();
    const tpls = tdata.templates || [];
    body.innerHTML = `
      <div class="pad">
        <div class="row" style="display:flex; gap:8px; align-items:center; margin-bottom:12px">
          <h3 style="margin:0">Inventory kits (${tpls.length})</h3>
          <button class="primary small" id="eah-new-tpl">+ New kit</button>
        </div>
        <div class="dim small" style="margin-bottom:8px">Define named bundles of items. Apply to any online player from the Players tab. Kits are global to the panel — shared across all empyrion instances.</div>
        <div id="eah-tpl-list">${tpls.map(t => `
          <div class="eah-tpl">
            <div style="flex:1">
              <strong>${esc(t.name)}</strong> <span class="dim small">— ${(t.items || []).length} items</span>
              <div class="dim small">${esc(t.description || "")}</div>
            </div>
            <button class="small" data-edit="${t.id}">Edit</button>
            <button class="small danger" data-del="${t.id}">Delete</button>
          </div>`).join("") || `<div class="empty">No kits yet. Create one to get started.</div>`}
        </div>
      </div>`;
    $("#eah-new-tpl").addEventListener("click", () => eahShowTemplateEditor(null, data));
    body.querySelectorAll("button[data-edit]").forEach(b => b.addEventListener("click", () => {
      const t = tpls.find(x => x.id === parseInt(b.dataset.edit, 10));
      eahShowTemplateEditor(t, data);
    }));
    body.querySelectorAll("button[data-del]").forEach(b => b.addEventListener("click", async () => {
      if (!confirm("Delete this kit?")) return;
      try {
        const res = await fetch(`/api/empyrion/templates/${b.dataset.del}`, { method: "DELETE" });
        if (!res.ok) throw new Error(await res.text());
        toast("Kit deleted", "ok");
        renderEAHTemplates(body);
      } catch (err) { toast(err.message, "err"); }
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

function eahShowTemplateEditor(tpl, itemData) {
  const isEdit = !!tpl;
  const overlay = document.createElement("div");
  overlay.className = "eah-modal";
  overlay.innerHTML = `
    <div class="eah-modal-card" style="width:min(800px, 92vw)">
      <div class="eah-modal-head">${isEdit ? "Edit kit" : "New kit"} <button data-close>✕</button></div>
      <div style="padding:12px; display:flex; flex-direction:column; gap:8px">
        <input id="tpl-name" placeholder="Kit name" value="${isEdit ? esc(tpl.name) : ""}" autofocus>
        <input id="tpl-desc" placeholder="Description (optional)" value="${isEdit ? esc(tpl.description || "") : ""}">
      </div>
      <div style="padding:0 12px 12px"><strong>Items in kit</strong></div>
      <div id="tpl-items" class="eah-modal-list" style="max-height:30vh"></div>
      <div style="padding:8px 12px"><strong>Add item</strong></div>
      <input id="tpl-item-q" placeholder="Search…" autocomplete="off" style="margin:0 12px">
      <div id="tpl-item-list" class="eah-modal-list" style="max-height:30vh; margin-top:6px"></div>
      <div class="eah-modal-foot" style="display:flex; gap:8px; justify-content:flex-end">
        <button class="small" data-close>Cancel</button>
        <button class="primary" id="tpl-save">${isEdit ? "Save" : "Create"}</button>
      </div>
    </div>`;
  document.body.appendChild(overlay);
  const close = () => overlay.remove();
  overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); });
  let items = isEdit ? (tpl.items || []).map(i => ({...i})) : [];
  const drawItems = () => {
    overlay.querySelector("#tpl-items").innerHTML = items.map((it, idx) => {
      const def = itemData.items.find(d => d.id === it.itemId);
      return `<div class="eah-give-row" style="display:flex; gap:8px; align-items:center">
        <strong>${esc(def?.name || "?")}</strong> <span class="dim">#${it.itemId}</span>
        <input type="number" min="1" value="${it.count || 1}" data-idx="${idx}" data-field="count" style="width:80px; margin-left:auto">
        <button class="small danger" data-rm="${idx}">×</button>
      </div>`;
    }).join("") || `<div class="empty small">No items yet. Pick from below.</div>`;
    overlay.querySelectorAll("input[data-field='count']").forEach(inp => inp.addEventListener("input", e => {
      items[+e.target.dataset.idx].count = parseInt(e.target.value, 10) || 1;
    }));
    overlay.querySelectorAll("button[data-rm]").forEach(b => b.addEventListener("click", () => { items.splice(+b.dataset.rm, 1); drawItems(); }));
  };
  drawItems();
  const drawSearch = q => {
    const list = itemData.items.filter(it => !q || it.name.toLowerCase().includes(q.toLowerCase())).slice(0, 100);
    overlay.querySelector("#tpl-item-list").innerHTML = list.map(it => `<div class="eah-give-row" data-add="${it.id}"><strong>${esc(it.name)}</strong> <span class="dim">#${it.id}</span> <span class="dim small">${esc(it.category || "")}</span></div>`).join("");
  };
  drawSearch("");
  overlay.querySelector("#tpl-item-q").addEventListener("input", e => drawSearch(e.target.value));
  overlay.querySelector("#tpl-item-list").addEventListener("click", e => {
    const row = e.target.closest("[data-add]"); if (!row) return;
    const itemId = parseInt(row.dataset.add, 10);
    if (items.some(x => x.itemId === itemId)) return;
    items.push({ itemId, count: 1 });
    drawItems();
  });
  overlay.querySelector("#tpl-save").addEventListener("click", async () => {
    const name = overlay.querySelector("#tpl-name").value.trim();
    if (!name) { toast("Name is required", "err"); return; }
    try {
      const res = await fetch("/api/empyrion/templates", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: tpl?.id || 0, name, description: overlay.querySelector("#tpl-desc").value, items }),
      });
      if (!res.ok) throw new Error(await res.text());
      toast(isEdit ? "Saved" : "Created", "ok");
      close();
      renderEAHSub();
    } catch (err) { toast(err.message, "err"); }
  });
}

// --- Map view: 2D rendering of structures by playfield ---
async function renderEAHMap(body) {
  body.innerHTML = loadingState("Loading structures…");
  try {
    const id = instanceModalState.id;
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/structures`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const list = data.structures || [];
    if (!list.length) {
      body.innerHTML = `<div class="empty"><span class="dim">No structures known yet.</span> <button class="small" id="eah-map-r">Refresh</button></div>`;
      $("#eah-map-r")?.addEventListener("click", () => renderEAHMap(body));
      return;
    }
    // Group by playfield
    const byPF = {};
    list.forEach(s => { (byPF[s.playfield || "(unknown)"] ||= []).push(s); });
    const pfNames = Object.keys(byPF).sort();
    body.innerHTML = `
      <div class="pad">
        <div class="row" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px">
          <select id="eah-map-pf">${pfNames.map((p, i) => `<option value="${esc(p)}" ${i === 0 ? "selected" : ""}>${esc(p)} (${byPF[p].length})</option>`).join("")}</select>
          <button class="small" id="eah-map-r">↻ Refresh</button>
        </div>
        <div id="eah-map-canvas" class="eah-map-canvas"></div>
        <div style="margin-top:16px;border-top:1px solid rgba(255,255,255,.06);padding-top:12px">
          <strong>Playfields</strong>
          <div id="eah-pf-sidebar" style="margin-top:8px;display:flex;flex-direction:column;gap:4px"></div>
        </div>
      </div>`;
    // Fetch playfields for lifecycle buttons
    const pfFetch = fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/playfields`).then(r => r.ok ? r.json() : {playfields:[]}).catch(() => ({playfields:[]}));
    pfFetch.then(pfData => {
      const pfList = pfData.playfields || [];
      eahState.playfields = pfList;
      const sb = $("#eah-pf-sidebar");
      if (!sb) return;
      sb.innerHTML = pfList.map(pf => {
        const pfName = esc(typeof pf === 'string' ? pf : (pf.name || pf.playfield || ""));
        return `<div style="display:flex;align-items:center;gap:4px;padding:4px 6px;background:rgba(255,255,255,.02);border-radius:4px">
          <span style="flex:1;font-size:12px">${pfName}</span>
          <button class="small" data-pf-load="${pfName}">Load</button>
          <button class="small" data-pf-unload="${pfName}">Unload</button>
          <button class="small" data-pf-reset="${pfName}">Reset</button>
          <button class="small" data-pf-wipe="${pfName}" title="Wipe (prompts for scope)">Wipe</button>
        </div>`;
      }).join("") || '<div class="dim">No playfields.</div>';
      sb.querySelectorAll('[data-pf-load]').forEach(b => b.addEventListener('click', async e => {
        try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfLoad)}/load`, {method:'POST'}); toast('Loaded','ok'); renderEAHMap(body); } catch(ex) { toast(ex.message,'err'); }
      }));
      sb.querySelectorAll('[data-pf-unload]').forEach(b => b.addEventListener('click', async e => {
        try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfUnload)}/unload`, {method:'POST'}); toast('Unloaded','ok'); } catch(ex) { toast(ex.message,'err'); }
      }));
      sb.querySelectorAll('[data-pf-reset]').forEach(b => b.addEventListener('click', async e => {
        try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfReset)}/reset`, {method:'POST'}); toast('Reset','ok'); } catch(ex) { toast(ex.message,'err'); }
      }));
      sb.querySelectorAll('[data-pf-wipe]').forEach(b => b.addEventListener('click', async e => {
        const scope = prompt('Wipe scope (poi/all/terrain/deposit):', 'poi');
        if (!scope) return;
        try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfWipe)}/wipe`, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({scope})}); toast('Wiped','ok'); } catch(ex) { toast(ex.message,'err'); }
      }));
    });
    const drawPF = pf => {
      const items = byPF[pf];
      const xs = items.map(s => s.pos[0]);
      const zs = items.map(s => s.pos[2]);
      const minX = Math.min(...xs) - 100, maxX = Math.max(...xs) + 100;
      const minZ = Math.min(...zs) - 100, maxZ = Math.max(...zs) + 100;
      const W = 760, H = 460;
      const sx = x => ((x - minX) / (maxX - minX)) * W;
      const sz = z => H - ((z - minZ) / (maxZ - minZ)) * H;
      const colors = ["#5c7cfa", "var(--ok)", "#f59e0b", "#ec4899", "#10b981", "#a855f7"];
      const dots = items.map((s, i) => `
        <g class="eah-map-dot" data-id="${s.id}">
          <circle cx="${sx(s.pos[0])}" cy="${sz(s.pos[2])}" r="6" fill="${colors[s.factionId % colors.length]}" stroke="rgba(0,0,0,.5)"/>
          <text x="${sx(s.pos[0]) + 9}" y="${sz(s.pos[2]) + 4}" font-size="10" fill="rgba(255,255,255,.85)">${esc(s.name || "#" + s.id)}</text>
        </g>`).join("");
      $("#eah-map-canvas").innerHTML = `
        <svg viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="xMidYMid meet" style="background:rgba(0,0,0,.25); border-radius:var(--radius)">
          <line x1="0" y1="${H/2}" x2="${W}" y2="${H/2}" stroke="rgba(255,255,255,.05)"/>
          <line x1="${W/2}" y1="0" x2="${W/2}" y2="${H}" stroke="rgba(255,255,255,.05)"/>
          ${dots}
        </svg>
        <div class="dim small" style="margin-top:6px">${items.length} structures · X/Z plane (Y altitude not rendered) · Range x:[${Math.round(minX)},${Math.round(maxX)}] z:[${Math.round(minZ)},${Math.round(maxZ)}]</div>`;
    };
    drawPF(pfNames[0]);
    $("#eah-map-pf").addEventListener("change", e => drawPF(e.target.value));
    $("#eah-map-r").addEventListener("click", () => renderEAHMap(body));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Scenarios (Reforged Eden 2 etc.) ---
async function renderEAHScenarios(body) {
  body.innerHTML = loadingState("Loading scenario presets…");
  const id = instanceModalState.id;
  try {
    const [presetsRes, installedRes] = await Promise.all([
      fetch("/api/empyrion/scenarios/presets").then(r => r.json()).catch(() => ({})),
      fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenarios/installed`).then(r => r.ok ? r.json() : { scenarios: [] }).catch(() => ({ scenarios: [] })),
    ]);
    const installed = installedRes.scenarios || [];
    const inst = allInstances.find(x => x.instance_id === id) || {};
    const activeScenario = (inst.config_values && inst.config_values.SCENARIO) || "";
    body.innerHTML = `
      <div class="pad">
        <!-- Active scenario picker — top of the pill so it's always visible.
             Lists what's actually on disk in Content/Scenarios/ (vanilla
             scenarios shipped with the game + anything the operator has
             installed via the workshop downloader below). Selecting one
             flips SCENARIO in config_values and prompts for a restart. -->
        <div class="eah-scen-active" style="background:rgba(95,178,255,.06); border:1px solid rgba(95,178,255,.18); border-radius:var(--radius); padding:12px 14px; margin-bottom:18px">
          <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap">
            <strong>Active scenario</strong>
            <select id="eah-scen-active" style="flex:1; min-width:240px; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit">
              <option value="">— Default Random (vanilla)</option>
              ${installed.map(s => `<option value="${esc(s.name)}"${s.name === activeScenario ? " selected" : ""}>${esc(s.name)}</option>`).join("")}
            </select>
            <button class="primary small" id="eah-scen-apply">Set as active</button>
          </div>
          <div class="dim small" style="margin-top:8px">${installed.length} scenarios on disk · the entrypoint reads SCENARIO from config and patches dedicated.yaml on each boot · changing this requires a server restart to take effect.</div>
        </div>

        <strong>Install from Steam Workshop</strong>
        <div class="dim small" style="margin-top:4px">Pick a community scenario. The panel runs SteamCMD on the agent, downloads the workshop item, copies it into <code>Content/Scenarios/</code>, and auto-activates it.</div>
        <div style="margin-top:12px">
          ${(presetsRes.presets || []).map(p => `
            <div class="eah-scenario">
              <div style="flex:1">
                <strong>${esc(p.name)}</strong>
                <div class="dim small">Workshop ID ${esc(p.id)} — ${esc(p.blurb || "")}</div>
              </div>
              <button class="primary small" data-wid="${esc(p.id)}" data-name="${esc(p.name)}">Install</button>
            </div>`).join("")}
        </div>
        <div style="margin-top:16px; padding-top:12px; border-top:1px solid rgba(255,255,255,.06)">
          <strong>Custom workshop ID</strong>
          <div class="dim small">Paste any Empyrion workshop ID. Use the folder name the scenario expects in dedicated.yaml.</div>
          <div class="row" style="display:flex; gap:8px; margin-top:6px">
            <input id="eah-scen-wid" placeholder="Workshop ID" style="width:160px">
            <input id="eah-scen-name" placeholder="Folder name" style="flex:1">
            <button class="primary small" id="eah-scen-go">Install</button>
          </div>
        </div>
        <div id="eah-scen-jobs" style="margin-top:16px"></div>
      </div>`;
    $("#eah-scen-apply").addEventListener("click", () => eahActivateScenario($("#eah-scen-active").value));
    body.querySelectorAll("button[data-wid]").forEach(b => b.addEventListener("click", () => eahStartScenarioJob(b.dataset.wid, b.dataset.name)));
    $("#eah-scen-go").addEventListener("click", () => {
      const wid = $("#eah-scen-wid").value.trim(), name = $("#eah-scen-name").value.trim();
      if (!wid || !name) { toast("Both workshop ID and folder name are required", "err"); return; }
      eahStartScenarioJob(wid, name);
    });
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// PATCHes config_values.SCENARIO on the instance and tells the operator
// the change needs a restart to take effect. The "Restart now" button
// in the toast triggers the existing instance-action flow.
async function eahActivateScenario(name) {
  const id = instanceModalState.id; if (!id) return;
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/activate`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ scenario: name }),
    });
    if (!r.ok) throw new Error(await r.text());
    // Update the in-memory cache so other UI surfaces (e.g. switching
    // tabs and coming back) reflect the new active scenario without a
    // full instances-list refetch.
    const inst = allInstances.find(x => x.instance_id === id);
    if (inst) {
      inst.config_values = inst.config_values || {};
      if (name) inst.config_values.SCENARIO = name; else delete inst.config_values.SCENARIO;
    }
    eahShowRestartPrompt(name);
  } catch (err) { toast("Couldn't set active scenario: " + err.message, "err"); }
}

// Sticky toast with a "Restart now" action button. Operators can choose
// to defer the restart (toast auto-dismisses after 30s) or click to
// trigger an immediate stop+start of the instance.
function eahShowRestartPrompt(scenarioName) {
  const id = instanceModalState.id;
  const label = scenarioName ? `"${scenarioName}"` : "Default Random (vanilla)";
  // Use the toast() helper but customize for an action button via DOM.
  const t = document.createElement("div");
  t.className = "toast warn eah-restart-toast";
  t.innerHTML = `
    <div style="display:flex; align-items:flex-start; gap:10px">
      <div style="flex:1">
        <strong>Active scenario set: ${esc(label)}</strong>
        <div class="dim small" style="margin-top:4px">Restart the server for the new scenario to take effect — Empyrion only reads dedicated.yaml on boot.</div>
      </div>
      <button class="small primary eah-restart-now">Restart now</button>
      <button class="small eah-restart-dismiss">Later</button>
    </div>`;
  Object.assign(t.style, {
    position: "fixed", bottom: "24px", right: "24px", maxWidth: "440px",
    padding: "12px 14px", background: "rgba(217,119,6,0.18)",
    border: "1px solid rgba(217,119,6,0.55)", borderRadius: "8px",
    color: "#fef3c7", zIndex: "10001",
    boxShadow: "0 12px 32px rgba(0,0,0,.45)",
  });
  document.body.appendChild(t);
  const dismiss = () => { try { t.remove(); } catch {} };
  t.querySelector(".eah-restart-dismiss").addEventListener("click", dismiss);
  t.querySelector(".eah-restart-now").addEventListener("click", async () => {
    const btn = t.querySelector(".eah-restart-now");
    btn.disabled = true; btn.textContent = "Restarting…";
    try {
      // Reuse the dashboard's existing restart flow (stop with grace,
      // poll for container exit, then start) so the per-card "starting"
      // overlay + ready-confirm logic kicks in normally.
      await doInstanceRestart(id, btn);
      toast(`Restarting server — new scenario will take effect once it's back up`, "ok");
    } catch (err) {
      toast("Restart failed: " + err.message, "err");
    } finally {
      dismiss();
    }
  });
  setTimeout(dismiss, 30000);
}

async function eahStartScenarioJob(workshopID, scenarioName) {
  const id = instanceModalState.id; if (!id) return;
  try {
    const post = () => fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/install`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ workshop_id: workshopID, scenario_name: scenarioName })
    }).then(async r => {
      if (!r.ok) throw new Error(await r.text());
      return r.json();
    });
    let resp = await post();
    // Mirror the DayZ flow: controller returns {steam_login_required:true}
    // when no Steam creds are configured. Pop the credential modal,
    // retry on success.
    if (resp && resp.steam_login_required) {
      const ok = await openSteamLoginModal(resp.reason || "Empyrion workshop downloads need a Steam account that owns the game.");
      if (!ok) {
        toast("Steam login cancelled — install aborted", "err");
        return;
      }
      // After credential modal succeeds, the agent on the target host
      // will do ITS OWN Steam login when it starts the workshop download
      // — that's a SECOND Steam Guard push, on a different docker daemon
      // than the controller's test-login. Surface a sticky toast so the
      // operator keeps watching their phone instead of putting it down
      // after the first approval. Without this, the second push times
      // out unread and the install fails with "Timed out waiting for
      // confirmation".
      toast("📱 Watch for a SECOND Steam Mobile push — the agent on the target host now needs its own login approval. Approve it when it pops.", "warn", 30000);
      resp = await post();
      if (resp && resp.steam_login_required) {
        throw new Error("Steam login didn't take; try again.");
      }
    }
    toast(`Started install: ${scenarioName}`, "ok");
    eahPollScenarioJob(resp.jobId);
  } catch (err) { toast("Install failed to start: " + err.message, "err"); }
}

async function eahPollScenarioJob(jobId) {
  const id = instanceModalState.id; if (!id) return;
  const wrap = $("#eah-scen-jobs");
  if (!wrap) return;
  let last;
  for (let i = 0; i < 200; i++) { // ~30 min @ 9s avg
    try {
      const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/jobs/${encodeURIComponent(jobId)}`);
      if (!r.ok) break;
      const j = await r.json();
      last = j;
      wrap.innerHTML = `
        <div class="eah-job">
          <div><strong>${esc(j.scenarioName)}</strong> <span class="dim">(${esc(j.workshopId)})</span> — <span class="${j.state === 'error' ? 'err' : ''}">${esc(j.state)}</span></div>
          ${j.error ? `<div class="err small">${esc(j.error)}</div>` : ""}
          <pre class="eah-job-log">${esc(j.log || "")}</pre>
        </div>`;
      if (j.state === "done" || j.state === "error") break;
    } catch {}
    await new Promise(r => setTimeout(r, 5000));
  }
  if (last && last.state === "done") {
    // Auto-activate the freshly-installed scenario — the operator
    // installed it because they want to use it. No reason to make
    // them go to a separate dropdown to enable it after.
    try {
      await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/activate`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ scenario: last.scenarioName }),
      });
      const inst = allInstances.find(x => x.instance_id === id);
      if (inst) {
        inst.config_values = inst.config_values || {};
        inst.config_values.SCENARIO = last.scenarioName;
      }
    } catch (err) {
      toast(`Installed but couldn't auto-activate: ${err.message}`, "warn");
      return;
    }
    toast(`Scenario "${last.scenarioName}" installed and set as active`, "ok");
    eahShowRestartPrompt(last.scenarioName);
    // Re-render the scenarios pill so the "Active scenario" dropdown
    // refreshes (now lists the newly-installed folder + shows it as
    // selected).
    if (eahState.sub === "scenarios") {
      const body = $("#im-eah-body"); if (body) renderEAHScenarios(body);
    }
  }
}

// --- Blueprint Library ---
async function renderEAHLibrary(body) {
  body.innerHTML = loadingState("Loading…");
  try {
    const data = await fetchJSON('/api/empyrion/blueprint-library');
    const list = data.blueprints || [];
    body.innerHTML = `
      <div style="display:flex;flex-direction:column;gap:14px;padding:12px">
        <div class="panel" style="padding:14px">
          <div style="font-weight:600;margin-bottom:8px">📤 Upload blueprint (.epb)</div>
          <form id="bplib-up" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
            <input type="file" name="blueprint" accept=".epb" required>
            <input type="text" name="notes" placeholder="Notes (optional)" style="flex:1;min-width:200px">
            <button class="primary" type="submit">Upload</button>
          </form>
        </div>
        <div class="panel">
          <header style="padding:12px 18px;border-bottom:1px solid var(--border);font-weight:600">${list.length} blueprints</header>
          <table class="eah-table">
            <thead><tr><th>Name</th><th>Size</th><th>Uploaded</th><th>Notes</th><th></th></tr></thead>
            <tbody>${list.map(bp => `
              <tr>
                <td>${esc(bp.name)}</td>
                <td>${formatBytes(bp.size_bytes)}</td>
                <td class="dim">${esc(fmtTime(bp.uploaded_at))}</td>
                <td class="dim">${esc(bp.notes || '')}</td>
                <td style="text-align:right">
                  <button class="small" data-bplib-spawn="${bp.id}">Spawn</button>
                  <button class="small danger" data-bplib-del="${bp.id}">Delete</button>
                </td>
              </tr>`).join('') || '<tr><td colspan="5" class="dim" style="padding:14px">No blueprints yet — upload one above.</td></tr>'}
            </tbody>
          </table>
        </div>
      </div>`;
    body.querySelector('#bplib-up').addEventListener('submit', async (e) => {
      e.preventDefault();
      const fd = new FormData(e.target);
      try {
        const r = await fetch('/api/empyrion/blueprint-library', { method:'POST', body: fd });
        if (!r.ok) throw new Error(await r.text());
        toast('Uploaded','ok');
        renderEAHLibrary(body);
      } catch (err) { toast(err.message,'err'); }
    });
    body.querySelectorAll('[data-bplib-del]').forEach(b => b.addEventListener('click', async () => {
      if (!confirm('Delete this blueprint from the library?')) return;
      try {
        const r = await fetch(`/api/empyrion/blueprint-library/${b.dataset.bplibDel}`, { method:'DELETE' });
        if (!r.ok) throw new Error(await r.text());
        toast('Deleted','ok');
        renderEAHLibrary(body);
      } catch (err) { toast(err.message, 'err'); }
    }));
    body.querySelectorAll('[data-bplib-spawn]').forEach(b => b.addEventListener('click', async () => {
      const pid = prompt('Spawn near player ID:'); if (!pid) return;
      const fid = prompt('Faction ID (optional, leave blank for default):');
      const id = instanceModalState.id;
      const payload = { playerId: Number(pid) };
      if (fid && fid.trim()) payload.factionId = Number(fid);
      try {
        const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprint-library/${b.dataset.bplibSpawn}/spawn`, {
          method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
        });
        if (!r.ok) throw new Error(await r.text());
        toast('Spawn dispatched','ok');
      } catch (err) { toast(err.message, 'err'); }
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}
function formatBytes(n) { if (!n) return '0 B'; const u=['B','KB','MB','GB']; let i=0,v=n; while(v>=1024&&i<u.length-1){v/=1024;i++;} return v.toFixed(v<10&&i?1:0)+' '+u[i]; }

// --- Players ---
async function renderEAHPlayers(body) {
  try {
    const r = await eahFetch("players");
    const ids = r.playerIds || [];
    if (!ids.length) {
      body.innerHTML = `<div class="empty"><span class="dim">No players online.</span></div>`;
      return;
    }
    // Fetch each player's info in parallel. Cached on eahState so other
    // actions (teleport-to-player) can look up target coords without a refetch.
    //
    // Pin entityId to the ID we already have from the list. The bridge
    // returns an empty PlayerInfo `{}` for players who are mid-connect /
    // un-spawned (no last playfield, no entity yet) — without this, every
    // action button would render data-pid="undefined" and 404 on click.
    const infos = await Promise.all(ids.map(id =>
      eahFetch(`players/${id}`)
        .then(info => ({ ...(info || {}), entityId: (info && (info.entityId ?? info.EntityId)) ?? id }))
        .catch(() => ({ entityId: id, playerName: "?", _loadFailed: true }))
    ));
    eahState.players = infos;
    body.innerHTML = `
      <table class="eah-table">
        <thead><tr>
          <th>ID</th><th>Name</th><th>Playfield</th><th>Faction</th><th>HP</th><th>Credits</th><th>Actions</th>
        </tr></thead>
        <tbody>${infos.map(p => {
          // A "stub" record is one where the bridge returned an empty
          // PlayerInfo (`{}`) — common for players who've connected but
          // haven't fully spawned (mid-login, no last playfield, etc.).
          // We surface this visibly so the operator knows actions like
          // give-credits will likely no-op until they spawn.
          const stub = !p.playerName && !p.playfield && !p._loadFailed;
          const nameCell = p.playerName
            ? esc(p.playerName)
            : (stub
                ? `<span class="dim" title="Player is mid-connect — no PlayerInfo yet">(connecting…)</span>`
                : (p._loadFailed ? `<span class="dim">(load failed)</span>` : ""));
          return `
          <tr${stub ? ' class="eah-player-stub"' : ''} oncontextmenu="eahPlayerCtxById(event,${p.entityId})">
            <td>${esc(p.entityId)}</td>
            <td>${nameCell}</td>
            <td>${esc(p.playfield || "")}</td>
            <td>${esc(p.factionId || 0)}</td>
            <td>${Math.round(p.health || 0)}/${Math.round(p.healthMax || 0)}</td>
            <td>${Math.round(p.credits || 0)}</td>
            <td>
              <button class="small" data-act="kick"     data-pid="${p.entityId}">Kick</button>
              <button class="small danger" data-act="ban"      data-pid="${p.entityId}">Ban</button>
              <button class="small" data-act="give"     data-pid="${p.entityId}"${stub ? ' disabled title="Player not fully spawned yet"' : ''}>Give item</button>
              <button class="small" data-act="kit"      data-pid="${p.entityId}"${stub ? ' disabled title="Player not fully spawned yet"' : ''}>Drop kit</button>
              <button class="small" data-act="credits"  data-pid="${p.entityId}"${stub ? ' disabled title="Player not fully spawned yet"' : ''}>+1k</button>
              <button class="small" data-act="whisper"  data-pid="${p.entityId}">Whisper</button>
              <button class="small" data-act="profile" data-pid="${p.entityId}">📜 Profile</button>
              <button class="small" data-act="role"     data-pid="${p.entityId}">Role…</button>
              <button class="small" data-act="tpto"     data-pid="${p.entityId}" data-name="${esc(p.playerName || "")}"${stub ? ' disabled title="Player not fully spawned yet"' : ''}>TP→</button>
            </td>
          </tr>`;
        }).join("")}
        </tbody>
      </table>`;
    body.querySelectorAll("button[data-act]").forEach(b => b.addEventListener("click", eahPlayerAction));
  } catch (err) {
    body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`;
  }
}

// Modal-style picker for ban duration. Returns the duration string Empyrion's
// `ban add` verb expects (`30s`, `5m`, `1d`, `0` for permanent), or null if
// the operator cancels.
function eahShowBanPicker(pid) {
  return new Promise(resolve => {
    const presets = [
      { dur: "5m",  label: "5 minutes" },
      { dur: "1h",  label: "1 hour" },
      { dur: "1d",  label: "1 day" },
      { dur: "7d",  label: "7 days" },
      { dur: "30d", label: "30 days" },
      { dur: "1y",  label: "1 year" },
      { dur: "0",   label: "Permanent", danger: true },
    ];
    const dlg = document.createElement("dialog");
    dlg.className = "eah-ban-dlg";
    dlg.innerHTML = `
      <form method="dialog" class="pad" style="min-width:280px">
        <h3 style="margin:0 0 4px">Ban player ${esc(String(pid))}</h3>
        <div class="dim small" style="margin-bottom:10px">Pick a duration or enter a custom one (e.g. <code>3h</code>, <code>14d</code>).</div>
        <div class="eah-ban-grid">
          ${presets.map(p => `<button type="button" class="${p.danger ? "danger" : ""}" data-dur="${p.dur}">${esc(p.label)}</button>`).join("")}
        </div>
        <div style="display:flex; gap:6px; margin-top:10px">
          <input type="text" id="eah-ban-custom" placeholder="custom (e.g. 3h, 14d)" style="flex:1; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25); color:inherit">
          <button type="button" class="primary" id="eah-ban-custom-go">Apply</button>
        </div>
        <div id="eah-ban-err" class="dim small" style="color:#ff8888; margin-top:6px; min-height:14px"></div>
        <div style="display:flex; justify-content:flex-end; margin-top:6px">
          <button type="button" class="small" id="eah-ban-cancel">Cancel</button>
        </div>
      </form>`;
    document.body.appendChild(dlg);
    const finish = (val) => { try { dlg.close(); } catch {} dlg.remove(); resolve(val); };
    dlg.querySelectorAll("button[data-dur]").forEach(b => b.addEventListener("click", () => finish(b.dataset.dur)));
    dlg.querySelector("#eah-ban-cancel").addEventListener("click", () => finish(null));
    dlg.addEventListener("close", () => finish(null));
    const customInput = dlg.querySelector("#eah-ban-custom");
    const errLine = dlg.querySelector("#eah-ban-err");
    const customGo = () => {
      const v = customInput.value.trim();
      if (!v) return;
      if (!/^[0-9]+(s|m|h|d|y)$/.test(v) && v !== "0") {
        errLine.textContent = "Format: 30s / 5m / 7h / 30d / 1y, or 0 for permanent.";
        return;
      }
      errLine.textContent = "";
      finish(v);
    };
    dlg.querySelector("#eah-ban-custom-go").addEventListener("click", customGo);
    customInput.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); customGo(); } });
    dlg.showModal();
  });
}

async function eahPlayerAction(e) {
  const pid = e.target.dataset.pid;
  const act = e.target.dataset.act;
  try {
    if (act === "kick") {
      const reason = prompt("Kick reason?", "kicked by admin");
      if (reason === null) return;
      await eahFetch(`players/${pid}/kick`, { method: "POST", body: reason });
      toast(`Kicked ${pid}`, "ok");
    } else if (act === "ban") {
      const dur = await eahShowBanPicker(pid);
      if (!dur) return;
      await eahFetch(`players/${pid}/ban?dur=${encodeURIComponent(dur)}`, { method: "POST" });
      toast(`Banned ${pid} (${dur === "0" ? "permanent" : dur})`, "ok");
    } else if (act === "give") {
      eahShowGiveItemPicker(pid);
      return;
    } else if (act === "kit") {
      await eahShowKitPicker(pid);
      return;
    } else if (act === "credits") {
      const amt = prompt("Credits to add (negative subtracts)?", "1000");
      if (amt === null) return;
      await eahFetch(`players/${pid}/give-credits`, { method: "POST", body: amt });
      toast(`+${amt} credits to ${pid}`, "ok");
    } else if (act === "whisper") {
      const msg = prompt("Whisper to player:");
      if (!msg) return;
      await eahFetch(`chat/whisper/${pid}`, { method: "POST", body: msg });
      toast("Whispered", "ok");
    } else if (act === "profile") {
      showPlayerProfileModal(pid);
      return;
    } else if (act === "role") {
      const role = await eahShowRolePicker(pid);
      if (!role) return;
      await eahFetch(`players/${pid}/set-role?role=${encodeURIComponent(role)}`, { method: "POST" });
      toast(`Set role: ${role}`, "ok");
    } else if (act === "tpto") {
      const target = await eahShowTeleportPicker(pid, e.target.dataset.name);
      if (!target) return;
      await eahFetch(`players/${pid}/teleport`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ x: target.pos.x, y: target.pos.y, z: target.pos.z, playfield: target.playfield || "" }),
      });
      toast(`Teleported ${pid} → ${target.playerName}`, "ok");
    }
    renderEAHSub();
  } catch (err) { toast(err.message, "err"); }
}

// Role picker — four-button dialog for Empyrion's documented role names.
function eahShowRolePicker(pid) {
  return new Promise(resolve => {
    const roles = [
      { v: "admin",      label: "Admin",       desc: "Full server permissions" },
      { v: "gamemaster", label: "Game Master", desc: "Most admin commands" },
      { v: "moderator",  label: "Moderator",   desc: "Moderation commands only" },
      { v: "player",     label: "Player",      desc: "Strip back to default" },
    ];
    const dlg = document.createElement("dialog");
    dlg.className = "eah-ban-dlg";
    dlg.innerHTML = `
      <form method="dialog" class="pad" style="min-width:300px">
        <h3 style="margin:0 0 4px">Set role for player ${esc(String(pid))}</h3>
        <div class="dim small" style="margin-bottom:10px">Wraps the <code>setrole &lt;id&gt; &lt;role&gt;</code> console verb. Takes effect immediately.</div>
        <div style="display:flex; flex-direction:column; gap:6px">
          ${roles.map(r => `<button type="button" data-role="${r.v}" style="text-align:left; padding:8px 10px; border-radius:var(--radius); border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.05); color:inherit; cursor:pointer">
            <strong>${esc(r.label)}</strong> <span class="dim small" style="margin-left:6px">${esc(r.desc)}</span>
          </button>`).join("")}
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:12px">
          <button type="button" class="small" id="eah-role-cancel">Cancel</button>
        </div>
      </form>`;
    document.body.appendChild(dlg);
    const finish = v => { try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
    dlg.querySelectorAll("button[data-role]").forEach(b => b.addEventListener("click", () => finish(b.dataset.role)));
    dlg.querySelector("#eah-role-cancel").addEventListener("click", () => finish(null));
    dlg.addEventListener("close", () => finish(null));
    dlg.showModal();
  });
}

// Teleport-to-player picker. Lists every other online player from the
// cached eahState.players and resolves with the chosen target's
// {playerName, playfield, pos:{x,y,z}}.
function eahShowTeleportPicker(actorPid, actorName) {
  return new Promise(resolve => {
    const candidates = (eahState.players || []).filter(p => p.entityId !== Number(actorPid) && p.pos);
    const dlg = document.createElement("dialog");
    dlg.className = "eah-ban-dlg";
    dlg.innerHTML = `
      <form method="dialog" class="pad" style="min-width:320px; max-width:420px">
        <h3 style="margin:0 0 4px">Teleport ${esc(actorName || ("#" + actorPid))} to…</h3>
        <div class="dim small" style="margin-bottom:10px">Pick a destination player. Actor is moved to their current playfield + position.</div>
        <div style="display:flex; flex-direction:column; gap:4px; max-height:50vh; overflow:auto">
          ${candidates.length ? candidates.map(p => `<button type="button" data-pid="${p.entityId}" style="text-align:left; padding:8px 10px; border-radius:var(--radius); border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.05); color:inherit; cursor:pointer">
            <strong>${esc(p.playerName || "?")}</strong>
            <span class="dim small" style="margin-left:6px">${esc(p.playfield || "?")} · (${Math.round(p.pos?.x||0)}, ${Math.round(p.pos?.y||0)}, ${Math.round(p.pos?.z||0)})</span>
          </button>`).join("") : `<div class="empty small">No other online players to teleport to.</div>`}
        </div>
        <div style="display:flex; justify-content:flex-end; margin-top:12px">
          <button type="button" class="small" id="eah-tp-cancel">Cancel</button>
        </div>
      </form>`;
    document.body.appendChild(dlg);
    const finish = v => { try { dlg.close(); } catch {} dlg.remove(); resolve(v); };
    dlg.querySelectorAll("button[data-pid]").forEach(b => b.addEventListener("click", () => {
      const target = candidates.find(p => p.entityId === Number(b.dataset.pid));
      finish(target || null);
    }));
    dlg.querySelector("#eah-tp-cancel").addEventListener("click", () => finish(null));
    dlg.addEventListener("close", () => finish(null));
    dlg.showModal();
  });
}

// --- Items / Give-item picker ---
let _eahItemsCache = null;
async function _eahLoadItems() {
  if (_eahItemsCache) return _eahItemsCache;
  const r = await fetch("/api/empyrion/items?limit=5000");
  const data = await r.json();
  _eahItemsCache = data;
  return data;
}

async function renderEAHItems(body) {
  try {
    const data = await _eahLoadItems();
    body.innerHTML = `
      <div class="eah-search">
        <input id="eah-item-q" placeholder="Search ${data.total} items by name…" autocomplete="off">
        <select id="eah-item-cat">
          <option value="">All categories</option>
          ${(data.categories || []).map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join("")}
        </select>
      </div>
      <div id="eah-item-list" class="eah-item-grid"></div>`;
    const drawList = (q, cat) => {
      const ql = q.toLowerCase();
      const list = data.items.filter(it =>
        (!cat || it.category === cat) &&
        (!ql || it.name.toLowerCase().includes(ql)));
      $("#eah-item-list").innerHTML = list.slice(0, 300).map(it => `
        <div class="eah-item-row">
          <img class="eah-item-icon" src="/api/empyrion/items/${it.id}/icon.png" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
          <div style="flex:1">
            <div><strong>${esc(it.name)}</strong> <span class="dim">#${it.id}</span></div>
            <div class="dim small">${esc(it.category || "")} ${esc(it.group || "")}</div>
          </div>
        </div>`).join("") + (list.length > 300 ? `<div class="dim">… ${list.length - 300} more</div>` : "");
    };
    drawList("", "");
    $("#eah-item-q").addEventListener("input", e => drawList(e.target.value, $("#eah-item-cat").value));
    $("#eah-item-cat").addEventListener("change", e => drawList($("#eah-item-q").value, e.target.value));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

async function eahShowGiveItemPicker(pid) {
  const data = await _eahLoadItems();
  const overlay = document.createElement("div");
  overlay.className = "eah-modal";
  overlay.innerHTML = `
    <div class="eah-modal-card">
      <div class="eah-modal-head">Give item to player ${pid} <button data-close>✕</button></div>
      <input id="eah-give-q" placeholder="Search…" autocomplete="off" autofocus>
      <div class="dim small" style="padding:0 12px">Click to give 1× to player.</div>
      <div id="eah-give-list" class="eah-modal-list"></div>
      <div class="eah-modal-foot">
        <label>Count <input id="eah-give-count" type="number" value="1" min="1" max="9999" style="width:80px"></label>
      </div>
    </div>`;
  document.body.appendChild(overlay);
  const close = () => overlay.remove();
  overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); });
  const draw = q => {
    const list = data.items.filter(it => !q || it.name.toLowerCase().includes(q.toLowerCase())).slice(0, 200);
    overlay.querySelector("#eah-give-list").innerHTML = list.map(it =>
      `<div class="eah-give-row" data-id="${it.id}" style="display:flex; gap:10px; align-items:center">
         <img class="eah-item-icon" src="/api/empyrion/items/${it.id}/icon.png" alt="" loading="lazy" onerror="this.style.visibility='hidden'">
         <div style="flex:1"><strong>${esc(it.name)}</strong> <span class="dim">#${it.id}</span> <span class="dim small">${esc(it.category || "")}</span></div>
       </div>`).join("");
  };
  draw("");
  overlay.querySelector("#eah-give-q").addEventListener("input", e => draw(e.target.value));
  overlay.querySelector("#eah-give-list").addEventListener("click", async e => {
    const row = e.target.closest(".eah-give-row"); if (!row) return;
    const itemId = parseInt(row.dataset.id, 10);
    const count = parseInt(overlay.querySelector("#eah-give-count").value, 10) || 1;
    try {
      await eahFetch(`players/${pid}/give-item`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId, count }) });
      toast(`Gave ${count}× item ${itemId}`, "ok");
      close();
    } catch (err) { toast(err.message, "err"); }
  });
}

async function eahShowKitPicker(pid) {
  const r = await fetch("/api/empyrion/templates");
  const data = await r.json();
  const tpls = data.templates || [];
  if (!tpls.length) { toast("No kits defined yet — create one in the Kits tab", "err"); return; }
  const overlay = document.createElement("div");
  overlay.className = "eah-modal";
  overlay.innerHTML = `
    <div class="eah-modal-card">
      <div class="eah-modal-head">Drop kit on player ${pid} <button data-close>✕</button></div>
      <div class="eah-modal-list">${tpls.map(t => `
        <div class="eah-give-row" data-tid="${t.id}">
          <strong>${esc(t.name)}</strong> <span class="dim">${(t.items || []).length} items</span>
          <div class="dim small">${esc(t.description || "")}</div>
        </div>`).join("")}</div>
    </div>`;
  document.body.appendChild(overlay);
  const close = () => overlay.remove();
  overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); });
  overlay.querySelectorAll("[data-tid]").forEach(row => row.addEventListener("click", async () => {
    const tid = row.dataset.tid;
    const id = instanceModalState.id;
    try {
      const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/templates/${tid}/apply/${pid}`, { method: "POST" });
      const out = await res.json();
      toast(`Applied ${out.applied}/${out.applied + out.failed} items`, out.failed ? "err" : "ok");
      close();
    } catch (err) { toast(err.message, "err"); }
  }));
}

// --- Structures ---
async function renderEAHStructures(body) {
  try {
    const r = await eahFetch("structures");
    const list = r.structures || [];
    eahState.structures = list;
    if (!list.length) {
      body.innerHTML = `<div class="empty"><span class="dim">No structures yet (or list isn't loaded; click Refresh).</span> <button class="small" id="eah-str-r">Refresh</button></div>`;
      $("#eah-str-r")?.addEventListener("click", () => renderEAHStructures(body));
      return;
    }
    body.innerHTML = `
      <table class="eah-table">
        <thead><tr><th>ID</th><th>Name</th><th>Type</th><th>Playfield</th><th>Pos</th><th>Faction</th><th>Blocks</th><th>Actions</th></tr></thead>
        <tbody>${list.map(s => `
          <tr oncontextmenu="eahStructureCtxById(event,${s.id})">
            <td>${s.id}</td>
            <td>${esc(s.name || "")}</td>
            <td>${s.type}</td>
            <td>${esc(s.playfield || "")}</td>
            <td>${(s.pos || []).map(n => Math.round(n)).join(",")}</td>
            <td>${s.factionId}</td>
            <td>${s.blocks || 0}</td>
            <td>
              <button class="small" onclick="eahStructureCtx({clientX:this.getBoundingClientRect().left,clientY:this.getBoundingClientRect().bottom,preventDefault:()=>{}},eahState.structures.find(x=>x.id===${s.id}))">⋮</button>
            </td>
          </tr>`).join("")}
        </tbody>
      </table>`;
    body.querySelectorAll("button[data-act]").forEach(b => b.addEventListener("click", eahStructureAction));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

async function eahStructureAction(e) {
  const sid = e.target.dataset.sid; const act = e.target.dataset.act;
  try {
    if (act === "rename") {
      const name = prompt("New name?");
      if (!name) return;
      await eahFetch(`structures/${sid}/rename`, { method: "POST", body: name });
      toast("Renamed", "ok");
    } else if (act === "destroy") {
      if (!confirm(`Destroy structure ${sid}? This cannot be undone.`)) return;
      await eahFetch(`structures/${sid}/destroy`, { method: "POST" });
      toast("Destroyed", "ok");
    } else if (act === "faction") {
      const fid = prompt("New faction id (number):"); if (fid === null) return;
      await eahFetch(`structures/${sid}/faction`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ factionId: Number(fid) }) });
      toast("Faction changed", "ok");
    } else if (act === "heal") {
      await eahFetch(`structures/${sid}/heal`, { method: "POST" });
      toast("Healed", "ok");
    } else if (act === "refuel") {
      await eahFetch(`structures/${sid}/refuel`, { method: "POST" });
      toast("Refueled", "ok");
    } else if (act === "set-core") {
      const mode = prompt("Core mode (admin|player|hardcore):"); if (!mode) return;
      await eahFetch(`structures/${sid}/set-core`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ mode }) });
      toast("Core set", "ok");
    } else if (act === "move") {
      const c = prompt("Move to (x y z [playfield]):"); if (!c) return;
      const parts = c.split(/\s+/);
      const body = { x: Number(parts[0]), y: Number(parts[1]), z: Number(parts[2]) };
      if (parts[3]) body.playfield = parts[3];
      await eahFetch(`structures/${sid}/move`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body) });
      toast("Moved", "ok");
    } else if (act === "save-as-bp") {
      const name = prompt("Blueprint name (optional):");
      const body = name ? { name } : {};
      await eahFetch(`structures/${sid}/save-as-blueprint`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body) });
      toast("Saved as blueprint", "ok");
    }
    renderEAHSub();
  } catch (err) { toast(err.message, "err"); }
}

// --- Context-menu helpers for structures and players ---
// Picker for the per-faction "Regenerate as…" right-click flow on
// structures. Empty faction = vanilla regenerate (whatever was there).
async function eahShowRegenPicker(sid) {
  const factions = ["any", "Aln", "Civilian", "Kriel", "Pirates", "Polaris", "Talon", "Traders", "UCH", "Zirax"];
  const choice = prompt(
    `Regenerate structure ${sid} as which faction?\n` +
    `Options: ${factions.join(" · ")}\n\n` +
    `(blank or "any" = default regen, no faction override)`,
    "any"
  );
  if (choice === null) return;
  const trimmed = (choice || "").trim();
  const body = trimmed && trimmed.toLowerCase() !== "any" ? { faction: trimmed } : {};
  try {
    await eahFetch(`structures/${sid}/regenerate`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    toast(`Regenerated ${sid}${trimmed ? ` as ${trimmed}` : ""}`, "ok");
  } catch (err) { toast(err.message, "err"); }
}

function eahStructureCtxById(e, id) {
  e.preventDefault();
  const s = (eahState.structures || []).find(x => x.id === id);
  if (s) eahStructureCtx(e, s);
}
function eahStructureCtx(e, s) {
  e.preventDefault();
  panelContextMenu({ x: e.clientX, y: e.clientY, items: [
    { icon:'??', label:'Rename.',           onClick: () => triggerEahStrAct('rename', s.id) },
    { icon:'??', label:'Change faction.',  onClick: () => triggerEahStrAct('faction', s.id) },
    { icon:'??', label:'Heal full',         onClick: () => triggerEahStrAct('heal', s.id) },
    { icon:'?', label:'Refuel full',       onClick: () => triggerEahStrAct('refuel', s.id) },
    { icon:'???', label:'Set admin core.',   onClick: () => triggerEahStrAct('set-core', s.id) },
    { icon:'??', label:'Move to coords.',   onClick: () => triggerEahStrAct('move', s.id) },
    { icon:'??', label:'Save as blueprint.',onClick: () => triggerEahStrAct('save-as-bp', s.id) },
    { divider:true },
    { icon:'🔄', label:'Regenerate as…',  onClick: () => eahShowRegenPicker(s.id) },
    { icon:'📋', label:'Activity',      onClick: () => showStructureActivityModal(s.id) },
    { divider:true },
    { icon:'??', label:'Destroy', danger:true, onClick: () => triggerEahStrAct('destroy', s.id) },
  ]});
}
async function triggerEahStrAct(act, sid) {
  try {
    if (act === 'rename') {
      const name = prompt('New name?'); if (!name) return;
      await eahFetch(`structures/${sid}/rename`, { method:'POST', body: name });
      toast('Renamed','ok');
    } else if (act === 'destroy') {
      if (!confirm(`Destroy structure ${sid}? This cannot be undone.`)) return;
      await eahFetch(`structures/${sid}/destroy`, { method:'POST' });
      toast('Destroyed','ok');
    } else if (act === 'faction') {
      const fid = prompt('New faction id (number):'); if (fid === null) return;
      await eahFetch(`structures/${sid}/faction`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ factionId: Number(fid) }) });
      toast('Faction changed','ok');
    } else if (act === 'heal') {
      await eahFetch(`structures/${sid}/heal`, { method:'POST' });
      toast('Healed','ok');
    } else if (act === 'refuel') {
      await eahFetch(`structures/${sid}/refuel`, { method:'POST' });
      toast('Refueled','ok');
    } else if (act === 'set-core') {
      const mode = prompt('Core mode (admin|player|hardcore):'); if (!mode) return;
      await eahFetch(`structures/${sid}/set-core`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ mode }) });
      toast('Core set','ok');
    } else if (act === 'move') {
      const c = prompt('Move to (x y z [playfield]):'); if (!c) return;
      const parts = c.split(/\s+/);
      const body = { x: Number(parts[0]), y: Number(parts[1]), z: Number(parts[2]) };
      if (parts[3]) body.playfield = parts[3];
      await eahFetch(`structures/${sid}/move`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
      toast('Moved','ok');
    } else if (act === 'save-as-bp') {
      const name = prompt('Blueprint name (optional):');
      const body = name ? { name } : {};
      await eahFetch(`structures/${sid}/save-as-blueprint`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
      toast('Saved as blueprint','ok');
    }
    renderEAHSub();
  } catch (err) { toast(err.message, 'err'); }
}
function eahPlayerCtxById(e, id) {
  e.preventDefault();
  const p = (eahState.players || []).find(x => x.entityId === id);
  if (p) eahPlayerCtx(e, p);
}
function eahPlayerCtx(e, p) {
  e.preventDefault();
  const stub = !p.playerName && !p.playfield;
  panelContextMenu({ x: e.clientX, y: e.clientY, items: [
    { icon:'👢', label:'Kick',          onClick: () => triggerEahPlayerAct('kick', p.entityId) },
    { icon:'🚫', label:'Ban…',         onClick: () => triggerEahPlayerAct('ban', p.entityId) },
    { icon:'✅', label:'Unban',        onClick: () => triggerEahPlayerAct('unban', p.entityId) },
    { divider:true },
    { icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) },
    { icon:'🎁', label:'Drop kit…',     disabled:stub, onClick: () => triggerEahPlayerAct('kit', p.entityId) },
    { icon:'🧪', label:'Give item…',    disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
    { icon:'💰', label:'+1k credits',   disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) },
    { icon:'💵', label:'Set credits…',  disabled:stub, onClick: () => triggerEahPlayerAct('setcredits', p.entityId) },
    { divider:true },
    { icon:'💬', label:'Whisper…',      onClick: () => triggerEahPlayerAct('whisper', p.entityId) },
    { icon:'🛡️', label:'Set role…',     onClick: () => triggerEahPlayerAct('role', p.entityId) },
    { icon:'🚀', label:'Teleport →',    disabled:stub, onClick: () => triggerEahPlayerAct('tpto', p.entityId, p) },
    { icon:'📍', label:'Teleport to coords…', disabled:stub, onClick: () => triggerEahPlayerAct('tpcoord', p.entityId) },
  ]});
}
function triggerEahPlayerAct(act, pid, p) {
  if (act === 'unban')      return eahFetch(`players/${pid}/unban`, { method:'POST' }).then(()=>toast('Unbanned','ok')).catch(e=>toast(e.message,'err'));
  if (act === 'setcredits') {
    const amt = prompt('Set credits to:'); if (amt === null) return;
    return eahFetch(`players/${pid}/set-credits`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ amount: Number(amt) }) })
      .then(()=>toast('Credits set','ok')).catch(e=>toast(e.message,'err'));
  }
  if (act === 'tpcoord') {
    const c = prompt('Teleport to coords (x y z):'); if (!c) return;
    const [x,y,z] = c.split(/\s+/).map(Number);
    return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) })
      .then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
  }
  if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.');
  // Fallthrough — replay through eahPlayerAction.
  const fakeBtn = document.createElement('button');
  fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid;
  if (p && p.playerName) fakeBtn.dataset.name = p.playerName;
  const ev = new MouseEvent('click', { bubbles: true });
  Object.defineProperty(ev, 'target', { value: fakeBtn });
  return eahPlayerAction(ev);
}

// --- Factions ---
async function renderEAHFactions(body) {
  try {
    const r = await eahFetch("factions");
    const list = r.factions || [];
    body.innerHTML = `
      <div class="dim small" style="padding:8px 12px">${list.length} factions on server.</div>
      <table class="eah-table">
        <thead><tr><th>ID</th><th>Name</th><th>Abbrev</th><th>Origin</th><th>Actions</th></tr></thead>
        <tbody>${list.map(f => `
          <tr>
            <td>${f.factionId}</td>
            <td>${esc(f.name || "")}</td>
            <td>${esc(f.abbrev || "")}</td>
            <td>${f.origin}</td>
            <td>
              <button class="small" data-act="msg" data-fid="${f.factionId}">Message</button>
              <button class="small" data-act="activity" data-fid="${f.factionId}">Activity</button>
            </td>
          </tr>`).join("")}
        </tbody>
      </table>
      <div style="margin-top:16px;border-top:1px solid rgba(255,255,255,.06);padding-top:12px">
        <h3 style="margin:0 0 8px;font-size:13px">Faction Reputation Matrix</h3>
        <div id="eah-faction-matrix" class="dim">Loading alliances…</div>
      </div>`;
    // Wire message buttons
    body.querySelectorAll("button[data-act='msg']").forEach(b => b.addEventListener("click", async e => {
      const fid = e.target.dataset.fid;
      const msg = prompt(`Message faction ${fid}?`); if (!msg) return;
      try { await eahFetch(`chat/faction/${fid}`, { method: "POST", body: msg }); toast("Sent", "ok"); }
      catch (err) { toast(err.message, "err"); }
    }));
    // Wire activity buttons
    body.querySelectorAll("button[data-act='activity']").forEach(b => b.addEventListener("click", async e => {
      showFactionActivityModal(e.target.dataset.fid);
    }));
    // Reputation matrix
    try {
      const aData = await eahFetch("factions/alliances");
      const matrix = aData && aData.alliances ? aData.alliances : (aData || {});
      const factionIds = list.map(f => String(f.factionId));
      const matrixWrap = document.getElementById("eah-faction-matrix");
      if (!matrixWrap) return;
      if (!factionIds.length) { matrixWrap.innerHTML = '<span class="dim">No factions to show.</span>'; return; }
      let html = '<div style="overflow-x:auto"><table class="eah-table"><thead><tr><th style="min-width:80px">\\</th>';
      factionIds.forEach(id => { html += `<th style="text-align:center;min-width:50px">${esc(id)}</th>`; });
      html += '</tr></thead><tbody>';
      factionIds.forEach(rowId => {
        html += `<tr><td><strong>${esc(rowId)}</strong>`;
        factionIds.forEach(colId => {
          if (rowId === colId) {
            html += '<td style="text-align:center;color:rgba(255,255,255,.2);background:rgba(255,255,255,.02)">—</td>';
          } else {
            const key = rowId + '_' + colId;
            const state = matrix[key] || 'neutral';
            const colors = { ally: 'var(--ok)', neutral: 'rgba(255,255,255,.35)', hostile: 'var(--err)' };
            const labels = { ally: 'A', neutral: '○', hostile: 'X' };
            html += `<td style="text-align:center;cursor:pointer;color:${colors[state] || colors.neutral};font-size:16px" data-matrix-rel="${key}" data-matrix-state="${state}">${labels[state] || labels.neutral}</td>`;
          }
        });
        html += '</tr>';
      });
      html += '</tbody></table></div>';
      html += '<div class="dim small" style="margin-top:4px">Click a cell to cycle: Ally (green) / Neutral (gray) / Hostile (red). Diagonal is self.</div>';
      matrixWrap.innerHTML = html;
      matrixWrap.querySelectorAll('[data-matrix-rel]').forEach(td => td.addEventListener('click', async e => {
        const key = e.target.dataset.matrixRel;
        const parts = key.split('_');
        if (parts.length !== 2) return;
        const [factionA, factionB] = parts;
        const cycle = { ally: 'neutral', neutral: 'hostile', hostile: 'ally' };
        const curState = e.target.dataset.matrixState;
        const nextState = cycle[curState] || 'neutral';
        e.target.dataset.matrixState = nextState;
        const colors = { ally: 'var(--ok)', neutral: 'rgba(255,255,255,.35)', hostile: 'var(--err)' };
        const labels = { ally: 'A', neutral: '○', hostile: 'X' };
        e.target.style.color = colors[nextState] || colors.neutral;
        e.target.textContent = labels[nextState] || labels.neutral;
        try {
          await eahFetch('factions/alliance-state', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ factionA: Number(factionA), factionB: Number(factionB), state: nextState }) });
          toast(`${factionA} vs ${factionB}: ${nextState}`, 'ok');
        } catch(err) { toast(err.message, 'err'); }
      }));
    } catch (e) {
      const mw = document.getElementById("eah-faction-matrix");
      if (mw) mw.innerHTML = `<div class="empty err">${esc(e.message)}</div>`;
    }
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Chat ---
function renderEAHChat(body) {
  body.innerHTML = `
    <div class="pad">
      <div class="dim small">Server-wide broadcast (visible to all online players).</div>
      <textarea id="eah-bc-msg" rows="2" style="width:100%; margin:8px 0" placeholder="Message to broadcast"></textarea>
      <div class="row" style="display:flex; gap:8px; align-items:center">
        <label>Priority
          <select id="eah-bc-prio">
            <option value="0">Info</option>
            <option value="1">Warning</option>
            <option value="2" selected>Alert</option>
          </select>
        </label>
        <label>Duration <input id="eah-bc-time" type="number" value="8" min="1" max="60" style="width:60px">s</label>
        <button class="primary" id="eah-bc-send">Broadcast</button>
      </div>
    </div>`;
  $("#eah-bc-send").addEventListener("click", async () => {
    const msg = $("#eah-bc-msg").value.trim();
    if (!msg) { toast("Empty message", "err"); return; }
    const prio = $("#eah-bc-prio").value, time = $("#eah-bc-time").value;
    try {
      await eahFetch(`chat/broadcast?prio=${prio}&time=${time}`, { method: "POST", body: msg });
      toast("Broadcast sent", "ok");
      $("#eah-bc-msg").value = "";
    } catch (err) { toast(err.message, "err"); }
  });
}

// --- Console (raw mod-API console command) ---
function renderEAHConsole(body) {
  body.innerHTML = `
    <div class="pad">
      <div class="dim small">Send any in-game console command via the Mod API. Output appears in the main Console tab.</div>
      <input id="eah-cc-cmd" placeholder="e.g.  saveandexit  /  help  /  ban add 12345 1d" autocomplete="off" style="width:100%; margin:8px 0">
      <div class="row" style="display:flex; gap:8px">
        <button class="primary" id="eah-cc-send">Send</button>
        <button class="small" id="eah-cc-save">Save world</button>
        <button class="small" id="eah-cc-stats">Get dedi stats</button>
      </div>
      <pre id="eah-cc-out" style="margin-top:12px; max-height:200px; overflow:auto; padding:8px; background:rgba(0,0,0,0.2); border-radius:4px"></pre>
    </div>`;
  const out = $("#eah-cc-out");
  $("#eah-cc-send").addEventListener("click", async () => {
    const cmd = $("#eah-cc-cmd").value.trim(); if (!cmd) return;
    try { const r = await eahFetch("console", { method: "POST", body: cmd }); out.textContent += `> ${cmd}\n${JSON.stringify(r)}\n`; $("#eah-cc-cmd").value = ""; }
    catch (err) { toast(err.message, "err"); }
  });
  $("#eah-cc-save").addEventListener("click", async () => {
    try { await eahFetch("save", { method: "POST" }); toast("save sent", "ok"); }
    catch (err) { toast(err.message, "err"); }
  });
  $("#eah-cc-stats").addEventListener("click", async () => {
    try { const r = await eahFetch("dedi/stats", { method: "POST" }); out.textContent += JSON.stringify(r, null, 2) + "\n"; }
    catch (err) { toast(err.message, "err"); }
  });
}

// --- Coords (CRUD saved coordinates) ---
async function renderEAHCoords(body) {
  body.innerHTML = loadingState("Loading coords.…");
  const instId = instanceModalState.id;
  if (!instId) { body.innerHTML = `<div class="empty err">no instance</div>`; return; }
  try {
    const data = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`)).json();
    const list = Array.isArray(data) ? data : (data.coords || []);
    const renderList = () => {
      const tbody = list.length ? list.map(c => {
        const posStr = `${c.x ?? 0} ${c.y ?? 0} ${c.z ?? 0}`;
        return `<tr>
          <td>${esc(c.name || "")}</td>
          <td>${esc(c.playfield || "")}</td>
          <td>${posStr}</td>
          <td>${esc(c.notes || "")}</td>
          <td>
            <button class="small" data-coord-tp="${c.id || c._id}">📍 TP player</button>
            <button class="small" data-coord-copy="${posStr}">📋 Use as TP</button>
            <button class="small danger" data-coord-del="${c.id || c._id}">🗑 Delete</button>
          </td>
        </tr>`;
      }).join("") : `<tr><td colspan="5" class="dim" style="text-align:center">No coords saved yet.</td></tr>`;
      const table = document.createElement('table');
      table.className = 'eah-table';
      table.innerHTML = `<thead><tr><th>Name</th><th>Playfield</th><th>Pos</th><th>Notes</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody>`;
      return table;
    };
    body.innerHTML = `
      <div class="pad">
        <div style="background:rgba(95,178,255,.06); border:1px solid rgba(95,178,255,.18); border-radius:var(--radius); padding:12px; margin-bottom:12px">
          <strong>Add coord</strong>
          <div style="display:grid; grid-template-columns:1fr 1fr 60px 60px 60px 1fr; gap:6px; margin-top:8px; align-items:end">
            <input id="eah-crd-name" placeholder="Name" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <input id="eah-crd-pf" placeholder="Playfield" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <input id="eah-crd-x" placeholder="X" type="number" style="width:60px;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <input id="eah-crd-y" placeholder="Y" type="number" style="width:60px;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <input id="eah-crd-z" placeholder="Z" type="number" style="width:60px;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <input id="eah-crd-notes" placeholder="Notes (optional)" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          </div>
          <button class="primary small" style="margin-top:8px" id="eah-crd-save">Save</button>
        </div>
        <div id="eah-crd-list"></div>
      </div>`;
    const listWrap = $("#eah-crd-list");
    listWrap.appendChild(renderList());
    // Refresh list helper
    const refreshList = async () => {
      const d = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`)).json();
      list.length = 0;
      const arr = Array.isArray(d) ? d : (d.coords || []);
      arr.forEach(c => list.push(c));
      listWrap.innerHTML = '';
      listWrap.appendChild(renderList());
      wireCoordButtons();
    };
    const wireCoordButtons = () => {
      body.querySelectorAll('[data-coord-tp]').forEach(b => b.addEventListener('click', async e => {
        const cid = e.target.dataset.coordTp;
        const pid = prompt('Player ID to teleport:'); if (!pid) return;
        try {
          await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords/${encodeURIComponent(cid)}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ playerId: Number(pid) }) });
          toast('Teleported','ok');
        } catch (err) { toast(err.message,'err'); }
      }));
      body.querySelectorAll('[data-coord-copy]').forEach(b => b.addEventListener('click', e => {
        navigator.clipboard.writeText(e.target.dataset.coordCopy).then(() => toast('Coords copied','ok')).catch(() => toast('Copy failed','err'));
      }));
      body.querySelectorAll('[data-coord-del]').forEach(b => b.addEventListener('click', async e => {
        const cid = e.target.dataset.coordDel;
        if (!confirm(`Delete coord ${cid}?`)) return;
        try {
          await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords/${encodeURIComponent(cid)}`, { method:'DELETE' });
          toast('Deleted','ok');
          refreshList();
        } catch (err) { toast(err.message,'err'); }
      }));
    };
    wireCoordButtons();
    $("#eah-crd-save").addEventListener('click', async () => {
      const name = $("#eah-crd-name").value.trim();
      const playfield = $("#eah-crd-pf").value.trim();
      const x = Number($("#eah-crd-x").value);
      const y = Number($("#eah-crd-y").value);
      const z = Number($("#eah-crd-z").value);
      if (!name || !playfield) { toast('Name and playfield required','err'); return; }
      const notes = $("#eah-crd-notes").value.trim();
      try {
        await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, playfield, x, y, z, notes }) });
        toast('Saved','ok');
        $("#eah-crd-name").value = ''; $("#eah-crd-pf").value = ''; $("#eah-crd-x").value = ''; $("#eah-crd-y").value = ''; $("#eah-crd-z").value = ''; $("#eah-crd-notes").value = '';
        refreshList();
      } catch (err) { toast(err.message,'err'); }
    });
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}
// --- Warnings ---
async function renderEAHWarnings(body) {
  body.innerHTML = loadingState("Loading warnings.…");
  const instId = instanceModalState.id;
  if (!instId) { body.innerHTML = `<div class="empty err">no instance</div>`; return; }
  try {
    const [rulesData, warnsData] = await Promise.all([
      fetch(`/api/empyrion/warning-rules`).then(r => r.ok ? r.json() : []).catch(() => []),
      fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/warnings?limit=100`).then(r => r.ok ? r.json() : []).catch(() => []),
    ]);
    const rules = Array.isArray(rulesData) ? rulesData : (rulesData.rules || []);
    const warns = Array.isArray(warnsData) ? warnsData : (warnsData.warnings || []);
    body.innerHTML = `
      <div class="pad">
        <h3 style="margin:0 0 8px">Active rules</h3>
        <div id="eah-warn-rules"></div>
        <button class="small primary" style="margin:8px 0" id="eah-warn-add-rule">+ Add rule</button>
        <div id="eah-warn-rule-form" style="display:none;background:rgba(95,178,255,.06);border:1px solid rgba(95,178,255,.18);border-radius:var(--radius);padding:12px;margin-bottom:12px"></div>
        <h3 style="margin:12px 0 8px">Recent warnings (last 100)</h3>
        <div id="eah-warn-list"></div>
      </div>`;
    const renderRules = () => {
      const wrap = $("#eah-warn-rules");
      if (!rules.length) { wrap.innerHTML = '<div class="dim">No rules defined.</div>'; return; }
      const tbl = document.createElement('table'); tbl.className = 'eah-table';
      tbl.innerHTML = `<thead><tr><th>Description</th><th>Threshold</th><th>Action</th><th>Enabled</th><th></th></tr></thead>
        <tbody>${rules.map(r => `<tr>
          <td>${esc(r.description || "")}</td>
          <td>${esc(r.threshold || "")}</td>
          <td>${esc(r.thresholdAction || "")}</td>
          <td><input type="checkbox" ${r.enabled ? 'checked' : ''} data-rule-toggle="${r.id || r._id}"></td>
          <td>
            <button class="small" data-rule-edit="${r.id || r._id}">Edit</button>
            <button class="small danger" data-rule-del="${r.id || r._id}">Delete</button>
          </td>
        </tr>`).join('')}</tbody>`;
      wrap.innerHTML = ''; wrap.appendChild(tbl);
      wrap.querySelectorAll('[data-rule-toggle]').forEach(cb => cb.addEventListener('change', async e => {
        const id = e.target.dataset.ruleToggle;
        try { await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) }); toast('Updated','ok'); } catch (err) { toast(err.message,'err'); }
      }));
      wrap.querySelectorAll('[data-rule-del]').forEach(b => b.addEventListener('click', async e => {
        const id = e.target.dataset.ruleDel;
        if (!confirm(`Delete rule ${id}?`)) return;
        try { await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(id)}`, { method:'DELETE' }); toast('Deleted','ok'); renderEAHWarnings(body); } catch (err) { toast(err.message,'err'); }
      }));
      wrap.querySelectorAll('[data-rule-edit]').forEach(b => b.addEventListener('click', e => {
        const id = e.target.dataset.ruleEdit;
        const r = rules.find(x => (x.id || x._id) == id);
        if (!r) return;
        showRuleForm(r);
      }));
    };
    const showRuleForm = (existing) => {
      const f = $("#eah-warn-rule-form");
      f.style.display = 'block';
      const desc = existing ? existing.description : '';
      const cel = existing ? (existing.cel || existing.condition || '') : '';
      const warnMsg = existing ? (existing.warnMessage || existing.message || '') : '';
      const thr = existing ? existing.threshold : '';
      const thrAct = existing ? existing.thresholdAction : '';
      const en = existing ? existing.enabled : true;
      f.innerHTML = `
        <strong>${existing ? 'Edit' : 'Add'} warning rule</strong>
        <div style="display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:8px">
          <input id="ewr-desc" placeholder="Description" value="${esc(desc)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ewr-cel" placeholder="CEL expression" value="${esc(cel)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ewr-warn" placeholder="Warning message" value="${esc(warnMsg)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ewr-thr" placeholder="Threshold" value="${esc(thr)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ewr-act" placeholder="Action" value="${esc(thrAct)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="ewr-en" ${en ? 'checked' : ''}> Enabled</label>
        </div>
        <button class="primary small" style="margin-top:8px" id="ewr-save">${existing ? 'Update' : 'Create'}</button>`;
      $("#ewr-save").addEventListener('click', async () => {
        const payload = {
          description: $("#ewr-desc").value.trim(),
          cel: $("#ewr-cel").value.trim(),
          warnMessage: $("#ewr-warn").value.trim(),
          threshold: $("#ewr-thr").value.trim(),
          thresholdAction: $("#ewr-act").value.trim(),
          enabled: $("#ewr-en").checked,
        };
        try {
          if (existing) {
            await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(existing.id || existing._id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
            toast('Updated','ok');
          } else {
            await fetch(`/api/empyrion/warning-rules`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
            toast('Created','ok');
          }
          f.style.display = 'none';
          renderEAHWarnings(body);
        } catch (err) { toast(err.message,'err'); }
      });
    };
    renderRules();
    $("#eah-warn-add-rule").addEventListener('click', () => showRuleForm(null));
    // Render warnings list
    const warnWrap = $("#eah-warn-list");
    if (!warns.length) { warnWrap.innerHTML = '<div class="dim">No warnings issued.</div>'; }
    else {
      const tbl = document.createElement('table'); tbl.className = 'eah-table';
      tbl.innerHTML = `<thead><tr><th>Player</th><th>Reason</th><th>By</th><th>When</th></tr></thead>
        <tbody>${warns.slice(0,100).map(w => `<tr>
          <td>${esc(w.playerName || w.playerId || "")} (${esc(w.playerId || "")})</td>
          <td>${esc(w.reason || "")}</td>
          <td>${esc(w.given_by || w.givenBy || "")}</td>
          <td>${esc(w.given_at || w.created_at || w.timestamp || "")}</td>
        </tr>`).join('')}</tbody>`;
      warnWrap.innerHTML = ''; warnWrap.appendChild(tbl);
    }
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}
// --- ChatBot rules ---
async function renderEAHChatBot(body) {
  body.innerHTML = loadingState("Loading chatbot rules.…");
  try {
    const data = await (await fetch(`/api/empyrion/chatbot-rules`)).json();
    const rules = Array.isArray(data) ? data : (data.rules || []);
    body.innerHTML = `
      <div class="pad">
        <div style="background:rgba(95,178,255,.06);border:1px solid rgba(95,178,255,.18);border-radius:var(--radius);padding:10px 14px;margin-bottom:12px;font-size:12px">
          <strong>Available placeholders:</strong>
          <span class="dim"> {playerName}, {playerId}, {factionId}, {playfield}, {now} </span>
        </div>
        <div id="eah-cbr-list"></div>
        <button class="small primary" style="margin:8px 0" id="eah-cbr-add">+ Add rule</button>
        <div id="eah-cbr-form" style="display:none;background:rgba(95,178,255,.06);border:1px solid rgba(95,178,255,.18);border-radius:var(--radius);padding:12px;margin-bottom:12px"></div>
      </div>`;
    const renderRules = () => {
      const wrap = $("#eah-cbr-list");
      if (!rules.length) { wrap.innerHTML = '<div class="dim">No chatbot rules defined.</div>'; return; }
      const tbl = document.createElement('table'); tbl.className = 'eah-table';
      tbl.innerHTML = `<thead><tr><th>Command</th><th>Description</th><th>Channel</th><th>Response</th><th>Enabled</th><th></th></tr></thead>
        <tbody>${rules.map(r => `<tr>
          <td>${esc(r.command || "")}</td>
          <td>${esc(r.description || "")}</td>
          <td>${esc(r.channel || r.channelType || "")}</td>
          <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.response || "")}">${esc((r.response||"").slice(0,80))}</td>
          <td><input type="checkbox" ${r.enabled ? 'checked' : ''} data-cbr-toggle="${r.id || r._id}"></td>
          <td>
            <button class="small" data-cbr-edit="${r.id || r._id}">Edit</button>
            <button class="small danger" data-cbr-del="${r.id || r._id}">Delete</button>
          </td>
        </tr>`).join('')}</tbody>`;
      wrap.innerHTML = ''; wrap.appendChild(tbl);
      wrap.querySelectorAll('[data-cbr-toggle]').forEach(cb => cb.addEventListener('change', async e => {
        const id = e.target.dataset.cbrToggle;
        try { await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) }); toast('Updated','ok'); } catch (err) { toast(err.message,'err'); }
      }));
      wrap.querySelectorAll('[data-cbr-del]').forEach(b => b.addEventListener('click', async e => {
        const id = e.target.dataset.cbrDel;
        if (!confirm(`Delete rule ${id}?`)) return;
        try { await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(id)}`, { method:'DELETE' }); toast('Deleted','ok'); renderEAHChatBot(body); } catch (err) { toast(err.message,'err'); }
      }));
      wrap.querySelectorAll('[data-cbr-edit]').forEach(b => b.addEventListener('click', e => {
        const id = e.target.dataset.cbrEdit;
        const r = rules.find(x => (x.id || x._id) == id);
        if (r) showRuleForm(r);
      }));
    };
    const showRuleForm = (existing) => {
      const f = $("#eah-cbr-form");
      f.style.display = 'block';
      const cmd = existing ? existing.command : '';
      const desc = existing ? existing.description : '';
      const resp = existing ? existing.response : '';
      const ch = existing ? (existing.channel || existing.channelType || 'global') : 'global';
      const en = existing ? existing.enabled : true;
      f.innerHTML = `
        <strong>${existing ? 'Edit' : 'Add'} chatbot rule</strong>
        <div style="display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-top:8px">
          <input id="ecr-cmd" placeholder="Command (e.g. !help)" value="${esc(cmd)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ecr-desc" placeholder="Description" value="${esc(desc)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <input id="ecr-resp" placeholder="Response text" value="${esc(resp)}" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
          <select id="ecr-ch" style="padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
            <option value="global" ${ch==='global'?'selected':''}>Global</option>
            <option value="faction" ${ch==='faction'?'selected':''}>Faction</option>
            <option value="private" ${ch==='private'?'selected':''}>Private</option>
          </select>
          <label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="ecr-en" ${en ? 'checked' : ''}> Enabled</label>
        </div>
        <button class="primary small" style="margin-top:8px" id="ecr-save">${existing ? 'Update' : 'Create'}</button>`;
      $("#ecr-save").addEventListener('click', async () => {
        const payload = { command: $("#ecr-cmd").value.trim(), description: $("#ecr-desc").value.trim(), response: $("#ecr-resp").value.trim(), channel: $("#ecr-ch").value, enabled: $("#ecr-en").checked };
        try {
          if (existing) {
            await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(existing.id || existing._id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
            toast('Updated','ok');
          } else {
            await fetch(`/api/empyrion/chatbot-rules`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
            toast('Created','ok');
          }
          f.style.display = 'none';
          renderEAHChatBot(body);
        } catch (err) { toast(err.message,'err'); }
      });
    };
    renderRules();
    $("#eah-cbr-add").addEventListener('click', () => showRuleForm(null));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}
// --- Dupes ---
async function renderEAHDupes(body) {
  body.innerHTML = loadingState("Loading dupes.…");
  const instId = instanceModalState.id;
  if (!instId) { body.innerHTML = `<div class="empty err">no instance</div>`; return; }
  try {
    const data = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/dupes`)).json();
    const groups = Array.isArray(data) ? data : (data.groups || []);
    if (!groups.length) { body.innerHTML = `<div class="empty"><span class="dim">No dupes found.</span></div>`; return; }
    body.innerHTML = `<div class="pad"><div class="dim small" style="margin-bottom:8px">${groups.length} dupe group(s).</div><div id="eah-dupe-groups"></div></div>`;
    const wrap = $("#eah-dupe-groups");
    groups.forEach(g => {
      const card = document.createElement('div');
      card.style.cssText = 'background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:var(--radius);padding:12px;margin-bottom:8px';
      const members = g.members || g.structures || [];
      card.innerHTML = `
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
          <strong>${esc(g.name || g.structureName || "Group")}</strong>
          <span class="dim">${g.blockCount || g.block_count || "?"} blocks</span>
        </div>
        <table class="eah-table">
          <thead><tr><th>ID</th><th>Playfield</th><th>Faction</th><th></th></tr></thead>
          <tbody>${members.map(m => `<tr>
            <td>${esc(m.id || m.structureId || "")}</td>
            <td>${esc(m.playfield || "")}</td>
            <td>${esc(m.factionId || m.faction || "")}</td>
            <td><button class="small danger" data-dupe-destroy="${m.id || m.structureId}">Destroy</button></td>
          </tr>`).join('')}</tbody>
        </table>`;
      wrap.appendChild(card);
    });
    body.querySelectorAll('[data-dupe-destroy]').forEach(b => b.addEventListener('click', async e => {
      const sid = e.target.dataset.dupeDestroy;
      if (!confirm(`Destroy structure ${sid}?`)) return;
      try { await eahFetch(`structures/${sid}/destroy`, { method:'POST' }); toast('Destroyed','ok'); renderEAHDupes(body); } catch (err) { toast(err.message,'err'); }
    }));
  } catch (err) { body.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}
// --- Fresh Start ---
async function renderEAHFreshStart(body) {
  body.innerHTML = loadingState("Loading.…");
  const instId = instanceModalState.id;
  if (!instId) { body.innerHTML = `<div class="empty err">no instance</div>`; return; }
  body.innerHTML = `
    <div class="pad">
      <div style="background:rgba(95,178,255,.06);border:1px solid rgba(95,178,255,.18);border-radius:var(--radius);padding:16px;max-width:500px">
        <h3 style="margin:0 0 12px">Fresh Start</h3>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
          <label>Announcement minutes <input id="eh-fs-ann" type="number" value="10" min="1" style="width:100%;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit"></label>
          <label>Wipe scope
            <select id="eh-fs-scope" style="width:100%;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit">
              <option value="poi">POI</option>
              <option value="all">All</option>
              <option value="terrain">Terrain</option>
              <option value="deposit">Deposit</option>
            </select>
          </label>
          <label>Playfield (optional) <input id="eh-fs-pf" placeholder="Leave empty for all" style="width:100%;padding:4px 8px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:inherit"></label>
          <label style="display:flex;align-items:center;gap:6px;padding-top:20px"><input type="checkbox" id="eh-fs-restart" checked> Restart after wipe</label>
        </div>
        <button class="primary" style="margin-top:12px" id="eh-fs-launch">Launch</button>
        <div id="eh-fs-result" style="margin-top:12px"></div>
      </div>
    </div>`;
  $("#eh-fs-launch").addEventListener('click', async () => {
    const btn = $("#eh-fs-launch");
    const resultDiv = $("#eh-fs-result");
    btn.disabled = true; btn.textContent = 'Launching...';
    resultDiv.innerHTML = '';
    try {
      const payload = {
        announcement_minutes: Number($("#eh-fs-ann").value),
        wipe_scope: $("#eh-fs-scope").value,
        playfield: $("#eh-fs-pf").value.trim() || undefined,
        restart: $("#eh-fs-restart").checked,
      };
      const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/fresh-start`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
      if (!r.ok) throw new Error(await r.text());
      const { job_id } = await r.json();
      resultDiv.innerHTML = `<div class="dim">Job ID: ${esc(job_id)}</div><div id="eh-fs-progress" style="margin-top:8px"></div>`;
      // Poll for progress
      const poll = async () => {
        try {
          const jr = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/fresh-start/jobs/${encodeURIComponent(job_id)}`);
          if (!jr.ok) { resultDiv.innerHTML += `<div class="empty err">Poll failed: ${jr.status}</div>`; return; }
          const jd = await jr.json();
          const prog = $("#eh-fs-progress");
          prog.innerHTML = `<div>Phase: ${esc(jd.phase || jd.status || "?")}</div><div class="dim">${esc(jd.message || "")}</div>${jd.done ? '<div style="color:var(--ok);margin-top:4px">Complete!</div>' : ''}`;
          if (!jd.done) setTimeout(poll, 2000);
          else { btn.disabled = false; btn.textContent = 'Launch'; }
        } catch (e) { resultDiv.innerHTML += `<div class="empty err">${esc(e.message)}</div>`; btn.disabled = false; btn.textContent = 'Launch'; }
      };
      poll();
    } catch (err) { resultDiv.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; btn.disabled = false; btn.textContent = 'Launch'; }
  });
}

// --- Per-player profile modal ---
async function showPlayerProfileModal(pid) {
  const instId = instanceModalState.id;
  if (!instId) { toast("no instance","err"); return; }
  const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex";
  bg.innerHTML = `
    <div class="modal large">
      <div class="modal-head"><h3>Player ${esc(pid)}</h3><button class="small" data-close>Close</button></div>
      <div class="modal-body"><div class="empty"><span class="dim">Loading profile…</span></div></div>
    </div>`;
  document.body.appendChild(bg);
  // Stack above the parent instance modal via the shared modal stack
  // (z-bump, aria, focus trap, backdrop click, Escape-pops-top).
  initDynamicModal(bg);
  const mb = bg.querySelector(".modal-body");
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion-history/players/${encodeURIComponent(pid)}/profile`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const playerName = esc(data.playerName || data.name || "");
    const lastSeen = esc(data.lastSeen || data.last_seen || "");
    const loginCount = data.loginCount ?? data.login_count ?? "?";
    const events = Array.isArray(data.events) ? data.events : [];
    const chat = Array.isArray(data.chat) ? data.chat : [];
    const warnings = Array.isArray(data.warnings) ? data.warnings : [];
    const escPid = esc(pid);
    mb.innerHTML = `
      <div class="pad">
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
          <div><strong>${playerName}</strong> <span class="dim">(ID ${escPid})</span></div>
          <div class="dim small">Last seen: ${lastSeen} &middot; Logins: ${loginCount}</div>
        </div>
        <button class="small" id="pp-warn-btn">⚠ Issue warning</button>
        <div style="margin-top:12px">
          <div class="eah-pills" id="pp-tabs" style="padding:0 0 8px;border-bottom:1px solid rgba(255,255,255,.06);margin-bottom:8px">
            <button class="eah-pill active" data-tab="events">Events (${events.length})</button>
            <button class="eah-pill" data-tab="chat">Chat (${chat.length})</button>
            <button class="eah-pill" data-tab="warnings">Warnings (${warnings.length})</button>
          </div>
          <div id="pp-tab-content"></div>
        </div>
      </div>`;
    function renderTab(tab) {
      var tc = document.getElementById("pp-tab-content");
      if (tab === "events") {
        if (!events.length) { tc.innerHTML = '<div class="dim">No events.</div>'; return; }
        var h = '<table class="eah-table"><thead><tr><th>Time</th><th>Type</th><th>Detail</th></tr></thead><tbody>';
        for (var i=0;i<events.length;i++) { var e=events[i]; h += "<tr><td>" + esc(e.timestamp || e.ts || "") + "</td><td>" + esc(e.type || "") + "</td><td>" + esc((e.detail || e.message || JSON.stringify(e)).slice(0,120)) + "</td></tr>"; }
        h += "</tbody></table>"; tc.innerHTML = h;
      } else if (tab === "chat") {
        if (!chat.length) { tc.innerHTML = '<div class="dim">No chat messages.</div>'; return; }
        var h = '<table class="eah-table"><thead><tr><th>Time</th><th>From</th><th>Message</th></tr></thead><tbody>';
        for (var i=0;i<chat.length;i++) { var c=chat[i]; h += "<tr><td>" + esc(c.timestamp || c.ts || "") + "</td><td>" + esc(c.from || c.playerName || "") + "</td><td>" + esc((c.message || c.text || "").slice(0,200)) + "</td></tr>"; }
        h += "</tbody></table>"; tc.innerHTML = h;
      } else if (tab === "warnings") {
        if (!warnings.length) { tc.innerHTML = '<div class="dim">No warnings.</div>'; return; }
        var h = '<table class="eah-table"><thead><tr><th>Time</th><th>Reason</th><th>By</th></tr></thead><tbody>';
        for (var i=0;i<warnings.length;i++) { var w=warnings[i]; h += "<tr><td>" + esc(w.timestamp || w.given_at || "") + "</td><td>" + esc(w.reason || "") + "</td><td>" + esc(w.given_by || w.givenBy || "") + "</td></tr>"; }
        h += "</tbody></table>"; tc.innerHTML = h;
      }
    };
    renderTab("events");
    var tabs = mb.querySelectorAll("#pp-tabs .eah-pill");
    for (var i=0;i<tabs.length;i++) { tabs[i].addEventListener("click", function() {
      var ts = mb.querySelectorAll("#pp-tabs .eah-pill");
      for (var j=0;j<ts.length;j++) ts[j].classList.remove("active");
      this.classList.add("active");
      renderTab(this.dataset.tab);
    }); }
    document.getElementById("pp-warn-btn").addEventListener("click", async function() {
      var reason = prompt("Warning reason:"); if (!reason) return;
      try {
        await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/warnings`, {
          method:"POST", headers:{"Content-Type":"application/json"},
          body: JSON.stringify({ playerId: Number(pid), reason })
        });
        toast("Warning issued","ok");
      } catch (err) { toast(err.message,"err"); }
    });
  } catch (err) { mb.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// --- Activity drill-down modals (factions & structures) ---
async function showFactionActivityModal(fid) {
  const instId = instanceModalState.id;
  if (!instId) { toast("no instance","err"); return; }
  const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex";
  bg.innerHTML = `
    <div class="modal large">
      <div class="modal-head"><h3>Faction ${esc(fid)} Activity</h3><button class="small" data-close>Close</button></div>
      <div class="modal-body"><div class="empty"><span class="dim">Loading activity…</span></div></div>
    </div>`;
  document.body.appendChild(bg);
  // Stack above the parent instance modal via the shared modal stack
  // (z-bump, aria, focus trap, backdrop click, Escape-pops-top).
  initDynamicModal(bg);
  const mb = bg.querySelector(".modal-body");
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/factions/${encodeURIComponent(fid)}/activity?limit=200`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const events = Array.isArray(data) ? data : (data.events || data.activity || []);
    if (!events.length) { mb.innerHTML = '<div class="pad"><div class="dim">No activity recorded.</div></div>'; return; }
    let html = '<div class="pad"><table class="eah-table"><thead><tr><th>Time</th><th>Command</th><th>Player</th><th>Detail</th></tr></thead><tbody>';
    events.sort((a,b) => (a.ts || a.timestamp || "") > (b.ts || b.timestamp || "") ? -1 : 1);
    for (const ev of events) {
      const detail = JSON.stringify(ev).slice(0, 80);
      html += `<tr><td>${esc(ev.ts || ev.timestamp || "")}</td><td>${esc(ev.cmd || ev.command || ev.type || "")}</td><td>${esc(ev.player || ev.playerName || "")}</td><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(detail)}">${esc(detail)}</td></tr>`;
    }
    html += '</tbody></table></div>';
    mb.innerHTML = html;
  } catch (err) { mb.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

async function showStructureActivityModal(sid) {
  const instId = instanceModalState.id;
  if (!instId) { toast("no instance","err"); return; }
  const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex";
  bg.innerHTML = `
    <div class="modal large">
      <div class="modal-head"><h3>Structure ${esc(sid)} Activity</h3><button class="small" data-close>Close</button></div>
      <div class="modal-body"><div class="empty"><span class="dim">Loading activity…</span></div></div>
    </div>`;
  document.body.appendChild(bg);
  // Stack above the parent instance modal via the shared modal stack
  // (z-bump, aria, focus trap, backdrop click, Escape-pops-top).
  initDynamicModal(bg);
  const mb = bg.querySelector(".modal-body");
  try {
    const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/structures/${encodeURIComponent(sid)}/activity?limit=200`);
    if (!r.ok) throw new Error(await r.text());
    const data = await r.json();
    const events = Array.isArray(data) ? data : (data.events || data.activity || []);
    if (!events.length) { mb.innerHTML = '<div class="pad"><div class="dim">No activity recorded.</div></div>'; return; }
    let html = '<div class="pad"><table class="eah-table"><thead><tr><th>Time</th><th>Command</th><th>Player</th><th>Detail</th></tr></thead><tbody>';
    events.sort((a,b) => (a.ts || a.timestamp || "") > (b.ts || b.timestamp || "") ? -1 : 1);
    for (const ev of events) {
      const detail = JSON.stringify(ev).slice(0, 80);
      html += `<tr><td>${esc(ev.ts || ev.timestamp || "")}</td><td>${esc(ev.cmd || ev.command || ev.type || "")}</td><td>${esc(ev.player || ev.playerName || "")}</td><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(detail)}">${esc(detail)}</td></tr>`;
    }
    html += '</tbody></table></div>';
    mb.innerHTML = html;
  } catch (err) { mb.innerHTML = `<div class="empty err">${esc(err.message)}</div>`; }
}

// ================ Template Library (WI-09) ================
//
// Browses /api/template-catalog (the amp-distill'd CubeCoders AMPTemplates
// catalog, embedded in the controller) and scaffolds starter modules via
// the admin-gated POST /api/scaffold-module. The library modal is static
// shell markup (#template-library-modal); each entry's detail modal is
// built dynamically and bumps _modalZ so it stacks above the library.
//
// Art policy: never hotlink the Steam CDN at runtime. If local art exists
// at /game-art/<module_id or id>.jpg we show it (onerror falls back);
// otherwise the tile is a CSS game-initial monogram.

const libState = { entries: null, search: "", filters: new Set(), meRole: "" };

async function openTemplateLibrary() {
  showModal("#template-library-modal");
  if (!libState.entries) {
    try {
      const [entries, me] = await Promise.all([
        fetchJSON("/api/template-catalog"),
        fetchJSON("/api/me").catch(() => null),
      ]);
      libState.entries = entries;
      libState.meRole = (me && me.role) || "";
      const cnt = $("#side-library-count"); if (cnt) cnt.textContent = String(entries.length);
      const lc = $("#lib-count"); if (lc) lc.textContent = `${entries.length} game templates`;
    } catch (err) {
      $("#lib-grid").innerHTML = `<div class="empty err" style="grid-column:1/-1">${esc(err.message)}</div>`;
      return;
    }
    const search = $("#lib-search");
    if (search && !search._libWired) {
      search._libWired = true;
      search.addEventListener("input", () => { libState.search = search.value; renderLibraryGrid(); });
      $$("#lib-filters .lib-chip").forEach(chip => {
        chip.addEventListener("click", () => {
          const f = chip.dataset.libf;
          // ported/unported and native/wine are mutually exclusive pairs.
          const excl = { ported: "unported", unported: "ported", native: "wine", wine: "native" };
          if (libState.filters.has(f)) libState.filters.delete(f);
          else { libState.filters.delete(excl[f]); libState.filters.add(f); }
          $$("#lib-filters .lib-chip").forEach(c => c.classList.toggle("active", libState.filters.has(c.dataset.libf)));
          renderLibraryGrid();
        });
      });
      $("#lib-grid").addEventListener("click", e => {
        const card = e.target.closest("[data-lib-id]");
        if (card) openLibraryDetail(card.dataset.libId);
      });
    }
  }
  renderLibraryGrid();
}

function libFiltered() {
  const q = (libState.search || "").trim().toLowerCase();
  const f = libState.filters;
  return (libState.entries || []).filter(e => {
    if (f.has("native") && e.wine) return false;
    if (f.has("wine") && !e.wine) return false;
    if (f.has("steamlogin") && !e.steam_login_required) return false;
    if (f.has("ported") && !e.ported) return false;
    if (f.has("unported") && e.ported) return false;
    if (!q) return true;
    return (e.name || "").toLowerCase().includes(q) ||
           (e.id || "").toLowerCase().includes(q) ||
           (e.description || "").toLowerCase().includes(q);
  });
}

// libArtHTML: local art when present (module_id first — that's how
// /game-art files are named for ported games), monogram otherwise.
// The <img> onerror collapses to the CSS monogram; no external requests.
function libArtHTML(e) {
  const initial = esc((e.name || "?").trim().charAt(0).toUpperCase());
  // Deterministic hue from the id so tiles vary without art assets.
  let hash = 0; for (const ch of e.id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
  const hue = hash % 360;
  const mono = `<span class="lib-mono" style="--mono-h:${hue}">${initial}</span>`;
  const artId = e.module_id || e.id;
  return `<span class="lib-art">${mono}<img src="/game-art/${esc(artId)}.jpg" alt="" loading="lazy" onerror="this.remove()"></span>`;
}

function renderLibraryGrid() {
  const grid = $("#lib-grid"); if (!grid) return;
  const list = libFiltered();
  if (!list.length) {
    grid.innerHTML = `<div class="empty" style="grid-column:1/-1;padding:32px">No templates match.</div>`;
    return;
  }
  grid.innerHTML = list.map(e => `
    <button class="lib-card" data-lib-id="${esc(e.id)}" title="${esc(e.description || e.name)}">
      ${libArtHTML(e)}
      <span class="lib-card-body">
        <span class="lib-name">${esc(e.name)}</span>
        <span class="lib-tags">
          <span class="lib-tag ${e.wine ? "wine" : "native"}">${e.wine ? "wine" : "native"}</span>
          ${e.steam_login_required ? `<span class="lib-tag login">steam login</span>` : ""}
          ${e.ported ? `<span class="lib-tag ported">ported${e.module_id && e.module_id !== e.id ? " → " + esc(e.module_id) : ""}</span>` : ""}
        </span>
      </span>
    </button>`).join("");
}

function openLibraryDetail(id) {
  const e = (libState.entries || []).find(x => x.id === id);
  if (!e) return;
  const ports = (e.ports || []).map(p =>
    `<tr><td>${esc(p.name)}</td><td class="mono">${p.port}</td><td>${esc(p.proto)}</td></tr>`).join("");
  const events = Object.entries(e.events || {}).map(([k, v]) =>
    `<tr><td>${esc(k)}</td><td class="mono lib-rx">${esc(v)}</td></tr>`).join("");
  const stages = (e.update_stages || []).map(s =>
    `<tr><td>${esc(s.kind)}</td><td>${esc(s.name || "")}</td><td class="mono">${esc(s.app_id || s.repo || s.url || "")}</td><td>${s.conditional ? "optional" : ""}</td></tr>`).join("");
  const isAdmin = libState.meRole === "admin";

  const bg = document.createElement("div");
  bg.className = "modal-bg";
  bg.style.display = "flex";
  // Dynamic modal: stack above the library modal via the shared stack.
  const close = initDynamicModal(bg);
  bg.innerHTML = `
  <div class="modal" style="width: min(760px, calc(100vw - 32px))">
    <div class="modal-head">
      <h3>${libArtHTML(e)} ${esc(e.name)}</h3>
      <button data-lib-close>Close</button>
    </div>
    <div class="modal-body"><div class="pad lib-detail">
      <div class="dim" style="margin-bottom:10px">${esc(e.description || "")}</div>
      <div class="lib-tags" style="margin-bottom:14px">
        <span class="lib-tag ${e.wine ? "wine" : "native"}">${e.wine ? "Wine + Xvfb" : "native linux"}</span>
        ${e.steam_login_required ? `<span class="lib-tag login">steam login required</span>` : ""}
        ${e.ported ? `<span class="lib-tag ported">already ported → modules/${esc(e.module_id)}</span>` : ""}
        ${e.rcon_adapter ? `<span class="lib-tag">console: ${esc(e.rcon_adapter)}</span>` : ""}
      </div>
      ${e.steam ? `<div class="lib-kv"><b>Steam app ids</b><span class="mono">server ${esc(e.steam.app_id)}${e.steam.game_app_id ? " · game " + esc(e.steam.game_app_id) : ""}${e.steam.platform ? " · " + esc(e.steam.platform) + " depot" : ""}</span></div>` : ""}
      ${ports ? `<h4>Ports</h4><table class="lib-table"><thead><tr><th>Name</th><th>Port</th><th>Proto</th></tr></thead><tbody>${ports}</tbody></table>` : ""}
      ${e.ready_pattern ? `<h4>Ready pattern</h4><div class="mono lib-rx lib-rx-block">${esc(e.ready_pattern)}</div>` : ""}
      ${events ? `<h4>Event regexes</h4><table class="lib-table"><tbody>${events}</tbody></table>` : ""}
      ${stages ? `<h4>Install pipeline</h4><table class="lib-table"><thead><tr><th>Kind</th><th>Stage</th><th>Source</th><th></th></tr></thead><tbody>${stages}</tbody></table>` : ""}
      <div class="lib-scaffold-row">
        ${e.ported
          ? `<span class="dim">This game already has a panel module (<span class="mono">modules/${esc(e.module_id)}</span>).</span>`
          : isAdmin
            ? `<button class="primary" data-lib-scaffold>⚗ Scaffold module</button><span class="dim">Writes an EXPERIMENTAL modules/${esc(e.id)}/ starter — needs a real boot test.</span>`
            : `<span class="dim">Scaffolding requires an admin account.</span>`}
      </div>
      <div class="lib-scaffold-out" style="display:none"></div>
    </div></div>
  </div>`;
  document.body.appendChild(bg);
  bg.addEventListener("click", ev => { if (ev.target.closest("[data-lib-close]")) close(); });

  const sBtn = bg.querySelector("[data-lib-scaffold]");
  if (sBtn) sBtn.addEventListener("click", async () => {
    sBtn.disabled = true; sBtn.textContent = "Scaffolding…";
    const out = bg.querySelector(".lib-scaffold-out");
    try {
      const r = await fetch("/api/scaffold-module", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: e.id }),
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(body.message || body.error || `HTTP ${r.status}`);
      out.style.display = "";
      out.innerHTML = `
        <div class="lib-scaffold-ok">
          <b>Scaffolded ${esc(body.dir || body.module_id)}</b> — EXPERIMENTAL, needs a real boot test.
          <ul>${(body.files || []).map(f => `<li class="mono">${esc(f)}</li>`).join("")}</ul>
          ${(body.warnings || []).length ? `<div class="lib-scaffold-warn">${(body.warnings || []).map(w => `⚠ ${esc(w)}`).join("<br>")}</div>` : ""}
        </div>`;
      sBtn.textContent = "Scaffolded ✓";
      toast(`Scaffolded modules/${body.module_id}`, "ok");
    } catch (err) {
      out.style.display = "";
      out.innerHTML = `<div class="empty err">${esc(err.message)}</div>`;
      sBtn.disabled = false; sBtn.textContent = "⚗ Scaffold module";
    }
  });
}
