/* ==========================================================================
   TESM -- Frontend-Verhalten
   --------------------------------------------------------------------------
   Ein ES-Modul, keine Abhaengigkeiten, kein Build-Schritt. Alles laeuft ueber
   deklarative data-Attribute, damit Templates kein eigenes Inline-Skript
   brauchen -- Voraussetzung fuer die strikte CSP (kein 'unsafe-inline').

   Konventionen:
     data-behavior="..."    ein Verhalten an ein Element binden
     data-*                 Parameter des Verhaltens
   ========================================================================== */

"use strict";

const STORAGE = {
  theme: "tesm.theme",
  sidebar: "tesm.sidebar",
  sections: "tesm.sections",
};

/* -- Kleine Helfer -------------------------------------------------------- */

const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => Array.from(scope.querySelectorAll(selector));

function store(key, value) {
  try {
    if (value === null) localStorage.removeItem(key);
    else localStorage.setItem(key, value);
  } catch {
    /* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
  }
}

function read(key, fallback = null) {
  try {
    const value = localStorage.getItem(key);
    return value === null ? fallback : value;
  } catch {
    return fallback;
  }
}

function csrfToken() {
  return document.body?.dataset.csrf || "";
}

/** fetch mit CSRF-Header und JSON-Erwartung. */
export async function api(url, options = {}) {
  const opts = { credentials: "same-origin", ...options };
  opts.headers = {
    "X-Requested-With": "fetch",
    "X-CSRF-Token": csrfToken(),
    ...(options.headers || {}),
  };
  const response = await fetch(url, opts);
  const type = response.headers.get("content-type") || "";
  const payload = type.includes("application/json") ? await response.json() : await response.text();
  if (!response.ok) {
    const message =
      (payload && typeof payload === "object" && (payload.error || payload.message)) ||
      `Anfrage fehlgeschlagen (HTTP ${response.status})`;
    throw new Error(message);
  }
  return payload;
}

/* -- Toasts --------------------------------------------------------------- */

let toastStack = null;

export function showToast(message, type = "info", { timeout = 6000 } = {}) {
  if (!message) return;
  if (!toastStack) {
    toastStack = document.createElement("div");
    toastStack.className = "toast-stack";
    toastStack.setAttribute("role", "status");
    toastStack.setAttribute("aria-live", "polite");
    document.body.appendChild(toastStack);
  }
  const toast = document.createElement("div");
  toast.className = `toast toast--${type}`;
  const body = document.createElement("div");
  body.className = "toast__body";
  body.textContent = message;
  const close = document.createElement("button");
  close.type = "button";
  close.className = "toast__close";
  close.setAttribute("aria-label", "Meldung schliessen");
  close.textContent = "×";
  toast.append(body, close);
  toastStack.appendChild(toast);

  let timer = 0;
  const dismiss = () => {
    window.clearTimeout(timer);
    toast.classList.add("is-leaving");
    window.setTimeout(() => toast.remove(), 180);
  };
  close.addEventListener("click", dismiss);
  if (timeout > 0) timer = window.setTimeout(dismiss, timeout);
  return dismiss;
}

function initFlashes() {
  const node = $("#flashed-messages");
  if (!node) return;
  let entries = [];
  try {
    entries = JSON.parse(node.textContent || "[]");
  } catch {
    entries = [];
  }
  entries.forEach(([category, message]) => {
    const type = { error: "danger", danger: "danger", success: "success", warning: "warning" }[
      category
    ] || "info";
    showToast(message, type, { timeout: type === "danger" ? 0 : 6000 });
  });
}

/* -- Theme (System / hell / dunkel) --------------------------------------- */

function applyTheme(mode) {
  const root = document.documentElement;
  if (mode === "light" || mode === "dark") root.dataset.theme = mode;
  else delete root.dataset.theme;
  $$('[data-behavior="theme-toggle"] [data-theme-option]').forEach((btn) => {
    btn.setAttribute("aria-pressed", String(btn.dataset.themeOption === mode));
  });
}

function initTheme() {
  const mode = read(STORAGE.theme, "system");
  applyTheme(mode);
  $$('[data-behavior="theme-toggle"]').forEach((group) => {
    group.addEventListener("click", (event) => {
      const button = event.target.closest("[data-theme-option]");
      if (!button) return;
      const next = button.dataset.themeOption;
      store(STORAGE.theme, next === "system" ? null : next);
      applyTheme(next);
    });
  });
}

/* -- Seitenleiste --------------------------------------------------------- */

function initSidebar() {
  const body = document.body;
  const desktop = () => window.matchMedia("(min-width: 1001px)").matches;
  const saved = read(STORAGE.sidebar, "full");
  if (desktop() && saved === "rail") body.dataset.sidebar = "rail";

  $$('[data-behavior="sidebar-toggle"]').forEach((button) => {
    button.addEventListener("click", () => {
      if (desktop()) {
        const rail = body.dataset.sidebar === "rail";
        body.dataset.sidebar = rail ? "full" : "rail";
        store(STORAGE.sidebar, rail ? "full" : "rail");
      } else {
        body.dataset.sidebar = body.dataset.sidebar === "open" ? "closed" : "open";
      }
    });
  });

  const scrim = $(".sidebar__scrim");
  scrim?.addEventListener("click", () => {
    body.dataset.sidebar = "closed";
  });

  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape" && body.dataset.sidebar === "open") body.dataset.sidebar = "closed";
  });
}

/* -- Aufklappbare Bereiche (Zustand bleibt erhalten) ---------------------- */

function initCollapsibles() {
  let state = {};
  try {
    state = JSON.parse(read(STORAGE.sections, "{}")) || {};
  } catch {
    state = {};
  }
  $$("details[data-persist]").forEach((element) => {
    const key = element.dataset.persist;
    if (key in state) element.open = Boolean(state[key]);
    element.addEventListener("toggle", () => {
      state[key] = element.open;
      store(STORAGE.sections, JSON.stringify(state));
    });
  });
}

/* -- Dialoge -------------------------------------------------------------- */

function openDialog(dialog) {
  if (!dialog) return;
  if (typeof dialog.showModal === "function") dialog.showModal();
  else dialog.setAttribute("open", "");
  const focusable = dialog.querySelector(
    "[autofocus], input:not([type=hidden]):not([disabled]), select, textarea, button"
  );
  focusable?.focus({ preventScroll: true });
}

function initDialogs() {
  document.addEventListener("click", (event) => {
    const opener = event.target.closest("[data-dialog-open]");
    if (opener) {
      event.preventDefault();
      const dialog = document.getElementById(opener.dataset.dialogOpen);
      if (dialog) {
        // Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
        Object.entries(opener.dataset).forEach(([key, value]) => {
          if (!key.startsWith("fill")) return;
          const name = key.slice(4);
          const target = dialog.querySelector(`[data-fill-target="${name}"]`);
          if (!target) return;
          if ("value" in target) target.value = value;
          else target.textContent = value;
        });
        openDialog(dialog);
      }
      return;
    }
    const closer = event.target.closest("[data-dialog-close]");
    if (closer) {
      event.preventDefault();
      closer.closest("dialog")?.close();
    }
  });

  // Klick auf den Hintergrund schliesst -- aber nur, wenn wirklich daneben
  // geklickt wurde (nicht bei einem Drag aus dem Dialog heraus).
  $$("dialog").forEach((dialog) => {
    dialog.addEventListener("mousedown", (event) => {
      dialog.dataset.outside = String(event.target === dialog);
    });
    dialog.addEventListener("click", (event) => {
      if (event.target === dialog && dialog.dataset.outside === "true") dialog.close();
    });
  });
}

/* -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- */

function initConfirmations() {
  const dialog = $("#confirm-dialog");
  if (!dialog) return;
  const titleNode = $("[data-confirm-title]", dialog);
  const textNode = $("[data-confirm-text]", dialog);
  const okButton = $("[data-confirm-ok]", dialog);
  let pending = null;

  document.addEventListener(
    "click",
    (event) => {
      const trigger = event.target.closest("[data-confirm]");
      if (!trigger || trigger.dataset.confirmed === "1") return;
      event.preventDefault();
      event.stopPropagation();
      pending = trigger;
      titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
      textNode.textContent = trigger.dataset.confirm;
      okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
      okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
      openDialog(dialog);
    },
    true
  );

  okButton.addEventListener("click", () => {
    dialog.close();
    if (!pending) return;
    const trigger = pending;
    pending = null;
    trigger.dataset.confirmed = "1";
    unsavedGuard.disable();
    if (trigger.form && trigger.type === "submit") trigger.click();
    else if (trigger.tagName === "FORM") trigger.submit();
    else trigger.click();
    delete trigger.dataset.confirmed;
  });
}

/* -- Schutz vor ungespeicherten Aenderungen ------------------------------ */

const unsavedGuard = {
  dirty: false,
  armed: true,
  disable() {
    this.armed = false;
  },
  init() {
    const forms = $$("form:not([data-no-guard])").filter((form) =>
      form.querySelector("input:not([type=hidden]):not([type=search]), select, textarea")
    );
    forms.forEach((form) => {
      const snapshot = new FormData(form);
      form.addEventListener("input", () => {
        this.dirty = !sameForm(snapshot, new FormData(form));
      });
      form.addEventListener("submit", () => {
        this.dirty = false;
        this.armed = false;
      });
    });
    window.addEventListener("beforeunload", (event) => {
      if (!this.dirty || !this.armed) return;
      event.preventDefault();
      event.returnValue = "";
    });
  },
};

function sameForm(a, b) {
  const left = Array.from(a.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
  const right = Array.from(b.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
  return left === right;
}

/* -- Tabellen: Sortieren und Filtern ------------------------------------- */

function cellValue(row, index) {
  const cell = row.cells[index];
  if (!cell) return "";
  return (cell.dataset.sortValue ?? cell.textContent ?? "").trim();
}

function initTables() {
  $$('table[data-behavior="sortable"]').forEach((table) => {
    const body = table.tBodies[0];
    if (!body) return;
    $$("thead th.sortable", table).forEach((header, index) => {
      header.tabIndex = 0;
      const activate = () => {
        const ascending = header.getAttribute("aria-sort") !== "ascending";
        $$("thead th", table).forEach((other) => other.removeAttribute("aria-sort"));
        header.setAttribute("aria-sort", ascending ? "ascending" : "descending");
        const rows = Array.from(body.rows);
        const numeric = rows.every((row) => {
          const value = cellValue(row, index).replace(/[^\d,.\-]/g, "").replace(",", ".");
          return value === "" || !Number.isNaN(Number(value));
        });
        rows.sort((rowA, rowB) => {
          const a = cellValue(rowA, index);
          const b = cellValue(rowB, index);
          const result = numeric
            ? Number(a.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0) -
              Number(b.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0)
            : a.localeCompare(b, "de", { numeric: true, sensitivity: "base" });
          return ascending ? result : -result;
        });
        rows.forEach((row) => body.appendChild(row));
      };
      header.addEventListener("click", activate);
      header.addEventListener("keydown", (event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          activate();
        }
      });
    });
  });

  $$("[data-filter-target]").forEach((input) => {
    const apply = () => {
      const needle = input.value.trim().toLowerCase();
      const target = document.getElementById(input.dataset.filterTarget);
      if (!target) return;
      const rows = target.tagName === "TABLE" ? Array.from(target.tBodies[0]?.rows || []) : $$("[data-filter-item]", target);
      let visible = 0;
      rows.forEach((row) => {
        const haystack = (row.dataset.filterText || row.textContent || "").toLowerCase();
        const match = !needle || haystack.includes(needle);
        row.hidden = !match;
        if (match) visible += 1;
      });
      const counter = input.dataset.filterCount
        ? document.getElementById(input.dataset.filterCount)
        : null;
      if (counter) counter.textContent = String(visible);
      const emptyNode = input.dataset.filterEmpty
        ? document.getElementById(input.dataset.filterEmpty)
        : null;
      if (emptyNode) emptyNode.hidden = visible !== 0;
    };
    input.addEventListener("input", apply);
    if (input.value) apply();
  });
}

/* -- Rechtematrix: Kill-Switch und Zeilenlogik --------------------------- */

function initPermissionMatrix() {
  $$('[data-behavior="permission-matrix"]').forEach((table) => {
    const syncArea = (areaKey) => {
      const master = $(`[data-area-switch="${areaKey}"]`, table);
      if (!master) return;
      $$(`tr[data-area="${areaKey}"]`, table).forEach((row) => {
        row.classList.toggle("is-locked", !master.checked);
        // Nur echte Rechte-Felder: alles ohne name-Attribut ist Darstellung,
        // kein Bedienelement, und darf hier nicht freigeschaltet werden.
        $$('input[type="checkbox"][name]', row).forEach((box) => {
          box.disabled = !master.checked;
        });
      });
    };

    $$("[data-area-switch]", table).forEach((master) => {
      const areaKey = master.dataset.areaSwitch;
      syncArea(areaKey);
      master.addEventListener("change", () => syncArea(areaKey));
    });

    table.addEventListener("change", (event) => {
      const box = event.target;
      if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
      const row = box.closest("tr");
      if (!row) return;
      const viewBox = $('input[data-action="view"]', row);
      if (box.dataset.action === "view") {
        // "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
        if (!box.checked) {
          $$('input[type="checkbox"][name]', row).forEach((other) => {
            if (other !== box) other.checked = false;
          });
        }
      } else if (box.checked && viewBox) {
        viewBox.checked = true; // Jede Aktion setzt "Ansehen" voraus.
      }
      const areaKey = row.dataset.area;
      const master = areaKey ? $(`[data-area-switch="${areaKey}"]`, table) : null;
      if (master && box.checked && !master.checked) {
        master.checked = true;
        master.dispatchEvent(new Event("change"));
      }
    });
  });
}

/* -- Automatische Aktualisierung von Teilbereichen ----------------------- */

function initAutoRefresh() {

  // -- Verzeichnisgruppe suchen ------------------------------------------- #
  //
  // Ein AD-Gruppen-DN wird nicht getippt: er ist lang, enthaelt Kommas und
  // Leerzeichen, und ein Tippfehler faellt erst bei der naechsten Anmeldung
  // auf -- als stille Nicht-Zuordnung, die niemand mit dem Formular in
  // Verbindung bringt.
  $$("[data-behavior='group-picker']").forEach((picker) => {
    const query = picker.querySelector("[data-group-query]");
    const button = picker.querySelector("[data-group-search]");
    const results = picker.querySelector("[data-group-results]");
    const status = picker.querySelector("[data-group-status]");
    const nameField = document.querySelector(picker.dataset.nameTarget);
    const dnField = document.querySelector(picker.dataset.dnTarget);
    if (!query || !button || !results || !nameField || !dnField) return;

    const show = (text) => { if (status) status.textContent = text; };

    const pick = (entry) => {
      nameField.value = entry.name || "";
      dnField.value = entry.dn || "";
      results.hidden = true;
      show(`Uebernommen: ${entry.name || entry.dn}`);
    };

    const search = async () => {
      const term = query.value.trim();
      if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
      button.disabled = true;
      show("Suche laeuft ...");
      results.innerHTML = "";
      try {
        const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
        const data = await api(url);
        const entries = (data && data.results) || [];
        if (!entries.length) {
          results.hidden = true;
          show("Keine Gruppe gefunden. Ist ein Dienstkonto hinterlegt?");
          return;
        }
        entries.forEach((entry) => {
          const row = document.createElement("button");
          row.type = "button";
          row.className = "btn btn--sm btn--ghost";
          row.style.justifyContent = "flex-start";
          row.style.textAlign = "left";
          const name = document.createElement("strong");
          name.textContent = entry.name || "(ohne Namen)";
          const dn = document.createElement("span");
          dn.className = "fs-xs faint break-all";
          dn.style.marginLeft = "8px";
          dn.textContent = entry.dn || "";
          row.append(name, dn);
          row.addEventListener("click", () => pick(entry));
          results.appendChild(row);
        });
        results.hidden = false;
        show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
      } catch (error) {
        results.hidden = true;
        show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
      } finally {
        button.disabled = false;
      }
    };

    button.addEventListener("click", search);
    query.addEventListener("keydown", (event) => {
      if (event.key === "Enter") { event.preventDefault(); search(); }
    });
  });

  // -- Transport bestimmt den Port ----------------------------------------- #
  //
  // 636 fuer LDAPS, 389 sonst. Wer den Transport umstellt und den Port stehen
  // laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- und der
  // Fehler ("Zeitlimit") sagt nichts ueber die Ursache.
  $$("[data-behavior='transport-port']").forEach((select) => {
    const port = document.querySelector(select.dataset.portTarget);
    if (!port) return;
    const standard = { ldaps: "636", starttls: "389", none: "389" };
    select.addEventListener("change", () => {
      const expected = standard[select.value];
      if (!expected) return;
      // Einen abweichenden, absichtlich gesetzten Port nicht ueberschreiben --
      // nur einen, der zum vorherigen Transport gehoerte.
      const known = Object.values(standard);
      if (port.value === "" || known.includes(port.value)) port.value = expected;
    });
  });

  $$("[data-refresh-url]").forEach((region) => {
    // Number(x || 15000) haette "0" zu 15000 gemacht -- eine Null ist falsy.
    // Bei einer Zahl aus einem Attribut ist || die falsche Vorgabe; gemeint
    // war "kein Wert angegeben", nicht "Wert ist 0".
    const raw = region.dataset.refreshInterval;
    const interval = raw === undefined || raw === "" ? 15000 : Number(raw);
    if (!Number.isFinite(interval) || interval <= 0) return;  // ausdruecklich aus
    let timer = 0;
    let stopped = false;

    const tick = async () => {
      if (stopped || document.hidden) return schedule();
      try {
        const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
        // Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
        // URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
        // Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
        if (typeof html === "string" && /<(!doctype|html|body)\b/i.test(html)) {
          console.warn("Aktualisierung geliefert: ganze Seite statt Ausschnitt --", region.id);
          stopped = true;
          return;
        }
        if (typeof html === "string") {
          // Gescrollt wird selten der Bereich selbst, sondern das Element
          // darin (bei Protokollen das <pre class="log-view">). Beim Ersetzen
          // von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
          // deshalb sprang ein laufendes Auftragsprotokoll bei jeder
          // Aktualisierung an den Anfang zurueck.
          const scrollerOf = (el) =>
            el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
          const before = scrollerOf(region);
          const position = before.scrollTop;
          // "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
          // werden, ohne exakt ganz unten stehen zu muessen.
          const following =
            before.scrollHeight - before.scrollTop - before.clientHeight < 48;

          region.innerHTML = html;

          const after = scrollerOf(region);
          after.scrollTop = following ? after.scrollHeight : position;
          region.dispatchEvent(new CustomEvent("tesm:refreshed", { bubbles: true }));
          initRelativeTimes(region);
          initCountdowns(region);
        }
      } catch {
        /* Netzfehler: beim naechsten Intervall erneut versuchen. */
      }
      schedule();
    };

    const schedule = () => {
      window.clearTimeout(timer);
      timer = window.setTimeout(tick, interval);
    };

    document.addEventListener("visibilitychange", () => {
      if (!document.hidden) schedule();
    });
    schedule();
    region.addEventListener("tesm:stop-refresh", () => {
      stopped = true;
      window.clearTimeout(timer);
    });
  });
}

/* -- Zeitangaben ---------------------------------------------------------- */

const RELATIVE = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
const UNITS = [
  ["year", 31536000],
  ["month", 2592000],
  ["day", 86400],
  ["hour", 3600],
  ["minute", 60],
  ["second", 1],
];

function relativeTime(iso) {
  const target = new Date(iso);
  if (Number.isNaN(target.getTime())) return "";
  const seconds = (target.getTime() - Date.now()) / 1000;
  for (const [unit, size] of UNITS) {
    if (Math.abs(seconds) >= size || unit === "second") {
      return RELATIVE.format(Math.round(seconds / size), unit);
    }
  }
  return "";
}

function initRelativeTimes(scope = document) {
  $$("[data-relative-time]", scope).forEach((node) => {
    const iso = node.dataset.relativeTime;
    const text = relativeTime(iso);
    if (text) {
      node.textContent = text;
      if (!node.title) node.title = new Date(iso).toLocaleString("de-DE");
    }
  });
}

function initCountdowns(scope = document) {
  $$("[data-countdown-to]", scope).forEach((node) => {
    const target = new Date(node.dataset.countdownTo).getTime();
    if (Number.isNaN(target)) return;
    const render = () => {
      const remaining = Math.max(0, Math.floor((target - Date.now()) / 1000));
      const hours = Math.floor(remaining / 3600);
      const minutes = Math.floor((remaining % 3600) / 60);
      const seconds = remaining % 60;
      node.textContent =
        hours > 0
          ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
          : `${minutes}:${String(seconds).padStart(2, "0")}`;
      if (remaining === 0) {
        window.clearInterval(handle);
        node.textContent = node.dataset.countdownDone || "0:00";
      }
    };
    render();
    const handle = window.setInterval(render, 1000);
  });
}

/* -- In die Zwischenablage ----------------------------------------------- */

function initClipboard() {
  document.addEventListener("click", async (event) => {
    const button = event.target.closest("[data-copy]");
    if (!button) return;
    event.preventDefault();
    const source = button.dataset.copy.startsWith("#")
      ? document.querySelector(button.dataset.copy)
      : null;
    const text = source ? source.value ?? source.textContent : button.dataset.copy;
    try {
      await navigator.clipboard.writeText((text || "").trim());
      showToast(button.dataset.copyMessage || "In die Zwischenablage kopiert.", "success", {
        timeout: 2500,
      });
    } catch {
      showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
    }
  });
}

/* -- Befehlspalette (Strg+K) --------------------------------------------- */

function initPalette() {
  const dialog = $("#command-palette");
  if (!dialog) return;
  const input = $(".palette__input", dialog);
  const list = $(".palette__list", dialog);
  let entries = [];
  try {
    entries = JSON.parse($("#palette-data")?.textContent || "[]");
  } catch {
    entries = [];
  }
  let active = 0;

  const render = () => {
    const needle = input.value.trim().toLowerCase();
    const matches = entries
      .filter((entry) => !needle || `${entry.label} ${entry.group}`.toLowerCase().includes(needle))
      .slice(0, 40);
    active = 0;
    list.innerHTML = "";
    if (!matches.length) {
      const empty = document.createElement("div");
      empty.className = "palette__item faint";
      empty.textContent = "Keine Treffer";
      list.appendChild(empty);
      return;
    }
    matches.forEach((entry, index) => {
      const item = document.createElement("a");
      item.className = `palette__item${index === 0 ? " is-active" : ""}`;
      item.href = entry.url;
      item.innerHTML = `<span>${escapeHtml(entry.label)}</span>`;
      if (entry.group) {
        const group = document.createElement("span");
        group.className = "palette__group";
        group.textContent = entry.group;
        item.appendChild(group);
      }
      list.appendChild(item);
    });
  };

  const move = (delta) => {
    const items = $$(".palette__item[href]", list);
    if (!items.length) return;
    items[active]?.classList.remove("is-active");
    active = (active + delta + items.length) % items.length;
    items[active].classList.add("is-active");
    items[active].scrollIntoView({ block: "nearest" });
  };

  document.addEventListener("keydown", (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
      event.preventDefault();
      input.value = "";
      render();
      openDialog(dialog);
    }
  });
  $$('[data-behavior="open-palette"]').forEach((button) =>
    button.addEventListener("click", () => {
      input.value = "";
      render();
      openDialog(dialog);
    })
  );
  input.addEventListener("input", render);
  input.addEventListener("keydown", (event) => {
    if (event.key === "ArrowDown") {
      event.preventDefault();
      move(1);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      move(-1);
    } else if (event.key === "Enter") {
      event.preventDefault();
      $$(".palette__item[href]", list)[active]?.click();
    }
  });
}

function escapeHtml(value) {
  const node = document.createElement("span");
  node.textContent = value;
  return node.innerHTML;
}

/* -- Formulare gegen Doppelabsenden -------------------------------------- */

function initSubmitGuards() {
  document.addEventListener("submit", (event) => {
    const form = event.target;
    if (!(form instanceof HTMLFormElement) || form.dataset.allowMultiSubmit) return;
    const buttons = $$('button[type="submit"], input[type="submit"]', form);
    window.setTimeout(() => {
      buttons.forEach((button) => {
        button.disabled = true;
        if (button.dataset.busyLabel) button.textContent = button.dataset.busyLabel;
      });
    }, 0);
    // Falls die Navigation scheitert (Validierungsfehler serverseitig),
    // Buttons nach kurzer Zeit wieder freigeben.
    window.setTimeout(() => buttons.forEach((button) => (button.disabled = false)), 12000);
  });
}

/* -- Kachelfilter (Dashboard) -------------------------------------------- */

function initTileFilters() {
  $$('[data-behavior="tile-filter"]').forEach((group) => {
    const targetId = group.dataset.filterScope;
    const scope = targetId ? document.getElementById(targetId) : document;
    const apply = (value) => {
      $$("[data-tile-section]", scope).forEach((section) => {
        section.hidden = Boolean(value) && section.dataset.tileSection !== value;
      });
      $$("[data-filter-value]", group).forEach((button) => {
        button.setAttribute("aria-pressed", String(button.dataset.filterValue === value));
      });
    };
    group.addEventListener("click", (event) => {
      const button = event.target.closest("[data-filter-value]");
      if (!button) return;
      const value = button.getAttribute("aria-pressed") === "true" ? "" : button.dataset.filterValue;
      apply(value);
    });
  });
}

/* -- Start ---------------------------------------------------------------- */

/**
 * Protokollansichten stehen am Ende, nicht am Anfang.
 *
 * Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
 * unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
 * die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
 * sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
 * Wirklichkeit stand man nur nie an der Stelle, an der er greift.
 *
 * Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
 * (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
 * aber oben beginnen.
 */
function initLogTails(root = document) {
  root.querySelectorAll("[data-tail]").forEach((view) => {
    view.scrollTop = view.scrollHeight;
  });
}

function boot() {
  initTheme();
  initSidebar();
  initFlashes();
  initCollapsibles();
  initDialogs();
  initConfirmations();
  initTables();
  initPermissionMatrix();
  initAutoRefresh();
  initRelativeTimes();
  initCountdowns();
  initClipboard();
  initPalette();
  initSubmitGuards();
  initTileFilters();
  initLogTails();
  unsavedGuard.init();
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
  boot();
}

window.tesm = { showToast, api, openDialog, relativeTime };
