/* kovra Web UI v2 (KOV-29) — client.
 *
 * No framework, no build. Drives the vendored Tabulator grid against the
 * governed `/api`. The server enforces all policy (policy::decide); this client
 * only ever renders what `/api` returns — it cannot show a value the server
 * withholds (I1/I2/I8). Modals are the native <dialog> element (no extra deps).
 */
"use strict";

// The ephemeral session token is delivered in the URL *fragment* (`#session=…`),
// never the query string: a fragment is not sent to the server, not written to
// access logs, and not leaked in a `Referer`. We read it here and forward it as
// the `x-kovra-session` header on every `/api` call.
const SESSION =
  new URLSearchParams(location.hash.replace(/^#/, "")).get("session") || "";
const H = { "x-kovra-session": SESSION };

const $ = (sel) => document.querySelector(sel);
const el = (tag, attrs = {}, html) => {
  const n = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
  if (html !== undefined) n.innerHTML = html;
  return n;
};
const esc = (s) =>
  String(s).replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
  );

/** project name from an origin like `project:foo` (global → null). */
const projectOf = (origin) =>
  origin && origin.startsWith("project:") ? origin.slice(8) : null;

let table = null;

async function api(path, opts = {}) {
  const r = await fetch(path, {
    ...opts,
    headers: { ...H, ...(opts.headers || {}) },
  });
  let body = null;
  try { body = await r.json(); } catch (_) { /* empty body */ }
  return { ok: r.ok, status: r.status, body };
}

/* ── toasts ───────────────────────────────────────────────────────────── */

function toast(msg, kind = "ok") {
  const t = el("div", { class: `toast ${kind}` }, esc(msg));
  $("#toasts").appendChild(t);
  setTimeout(() => t.classList.add("in"), 10);
  setTimeout(() => { t.classList.remove("in"); setTimeout(() => t.remove(), 250); }, 3200);
}

/* ── icons (Tabler) ───────────────────────────────────────────────────── */

const svgIcon = (paths) =>
  `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
const ICON_EYE = svgIcon(
  '<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/>',
);
const ICON_EYE_OFF = svgIcon(
  '<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9 9 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"/><path d="M3 3l18 18"/>',
);
const ICON_COPY = svgIcon(
  '<path d="M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z"/><path d="M4.012 16.737a2 2 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1"/>',
);
const ICON_PASTE = svgIcon(
  '<path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/><path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/><path d="M12 11l0 6"/><path d="M9 14l3 3l3 -3"/>',
);
const ICON_EDIT = svgIcon(
  '<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3z"/><path d="M16 5l3 3"/>',
);
const ICON_TRASH = svgIcon(
  '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>',
);

/* ── password/secret field with a Reveal (eye) toggle ─────────────────── */

/** A masked value field: <input type=password> + Reveal + Copy. Read-only by
 *  default (revealing a stored value in the drawer); pass `editable = true` for
 *  the create/edit/fulfil forms where the human types the value in. */
function secretFieldHtml(id, value, editable = false) {
  // Editable fields (create/edit/fulfil) offer Paste (fill from clipboard);
  // read-only fields (the reveal drawer) offer Copy.
  const action = editable
    ? `<button type="button" class="paste" data-target="${id}" title="Paste">${ICON_PASTE}</button>`
    : `<button type="button" class="copy" data-target="${id}" title="Copy">${ICON_COPY}</button>`;
  return (
    `<div class="secret-field">` +
    `<input id="${id}" type="password" value="${esc(value)}"${editable ? "" : " readonly"} autocomplete="off" spellcheck="false">` +
    `<button type="button" class="eye" data-target="${id}" title="Reveal">${ICON_EYE}</button>` +
    action +
    `</div>`
  );
}

/** Wire the eye/copy buttons inside `root`. */
function wireSecretToggles(root) {
  root.querySelectorAll("button.eye").forEach((b) => {
    b.onclick = () => {
      const inp = root.querySelector(`#${b.dataset.target}`);
      const masked = inp.type === "password";
      inp.type = masked ? "text" : "password";
      b.classList.toggle("on", masked);
      b.title = masked ? "Hide" : "Reveal";
      b.innerHTML = masked ? ICON_EYE_OFF : ICON_EYE;
    };
  });
  root.querySelectorAll("button.copy").forEach((b) => {
    b.onclick = async () => {
      const inp = root.querySelector(`#${b.dataset.target}`);
      try { await navigator.clipboard.writeText(inp.value); toast("copied"); }
      catch (_) { toast("copy failed", "err"); }
    };
  });
  root.querySelectorAll("button.paste").forEach((b) => {
    b.onclick = async () => {
      const inp = root.querySelector(`#${b.dataset.target}`);
      try { inp.value = await navigator.clipboard.readText(); toast("pasted"); }
      catch (_) { inp.focus(); toast("paste failed — use ⌘V in the field", "err"); }
    };
  });
}

/* ── governed reveal dialog ───────────────────────────────────────────── */

function row(k, v) {
  return `<div class="row"><span class="k">${esc(k)}</span> ${v}</div>`;
}

function badge(sensitivity) {
  const s = esc(sensitivity || "");
  return `<span class="badge ${s}">${s}</span>`;
}

function renderReveal(j) {
  // Literal value actually returned (low/medium only — the server's I1/I2 gate).
  // Shown in a masked password field with a Reveal (eye) toggle.
  if (j.value !== undefined) {
    return {
      title: "Revealed value",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        row("Sensitivity", badge(j.sensitivity)) +
        `<div class="row"><span class="k">Value</span></div>` +
        secretFieldHtml("reveal-value", j.value),
    };
  }
  if (j.masked) {
    return {
      title: "Masked (critical)",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        (j.sensitivity ? row("Sensitivity", badge(j.sensitivity)) : "") +
        (j.fingerprint ? row("Fingerprint", `<span class="fp">${esc(j.fingerprint)}</span>`) : "") +
        `<p class="note"><span class="lock">🔒 masked in the browser.</span> ${esc(j.note || "")}</p>`,
    };
  }
  if (j.inject_only) {
    return {
      title: "Inject-only",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        row("Sensitivity", badge(j.sensitivity)) +
        `<p class="note"><span class="lock">🔒 never revealed on any surface (I2).</span></p>`,
    };
  }
  if (j.kind === "reference") {
    return {
      title: "Reference",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        row("Pointer", `<code>${esc(j.pointer)}</code>`) +
        row("Status", esc(j.status || "")) +
        `<p class="note">${esc(j.note || "")}</p>`,
    };
  }
  if (j.kind === "keypair" || j.kind === "public-only") {
    return {
      title: j.kind === "keypair" ? "Keypair" : "Public key",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        row("Algorithm", esc(j.algorithm || "")) +
        `<div class="row"><span class="k">Public key</span></div>` +
        `<div class="value">${esc(j.public || "")}</div>` +
        `<p class="note">${esc(j.note || "")}</p>`,
    };
  }
  if (j.kind === "totp") {
    return {
      title: "TOTP enrollment",
      html:
        row("Coordinate", `<code>${esc(j.coordinate)}</code>`) +
        row("Algorithm", esc(j.algorithm || "")) +
        row("Digits", esc(j.digits)) +
        row("Period", `${esc(j.period)}s`) +
        `<p class="note">${esc(j.note || "")}</p>`,
    };
  }
  return {
    title: "Details",
    html:
      row("Coordinate", `<code>${esc(j.coordinate || "")}</code>`) +
      `<p class="note">${esc(j.note || JSON.stringify(j))}</p>`,
  };
}

/** Slide the reveal drawer in (with scrim). */
function openReveal() {
  $("#drawer").classList.add("show");
  $("#scrim").classList.add("show");
}
/** Close the reveal drawer (and scrim). */
function closeReveal() {
  $("#drawer").classList.remove("show");
  $("#scrim").classList.remove("show");
}

async function inspect(d) {
  const q = new URLSearchParams({ coord: d.coordinate });
  const p = projectOf(d.origin);
  if (p) q.set("project", p);
  const { ok, status, body } = await api(`/api/reveal?${q}`);
  const bodyEl = $("#reveal-body");
  if (!ok) {
    $("#reveal-title").textContent = "Error";
    bodyEl.innerHTML = `<p class="note">request failed (${status})</p>`;
  } else {
    const { title, html } = renderReveal(body);
    $("#reveal-title").textContent = title;
    bodyEl.innerHTML = html;
    wireSecretToggles(bodyEl);
  }
  openReveal();
}

/* ── form dialog (create / generate / edit / delete) ──────────────────── */

const SENS = ["low", "medium", "high", "inject-only"];

const field = (label, inner) =>
  `<label class="field"><span class="lbl">${esc(label)}</span>${inner}</label>`;
const sensSelect = (id, sel = "medium") =>
  `<select id="${id}">` +
  SENS.map((s) => `<option value="${s}"${s === sel ? " selected" : ""}>${s}</option>`).join("") +
  `</select>`;

let onSubmit = null; // the active form's submit handler

function openForm(title, bodyHtml, submit, submitLabel = "Save", danger = false) {
  $("#form-title").textContent = title;
  const body = $("#form-body");
  body.innerHTML = bodyHtml;
  wireSecretToggles(body);
  const btn = $("#form-submit");
  btn.textContent = submitLabel;
  btn.classList.toggle("danger", danger);
  btn.disabled = false; // a gated form (e.g. type-to-confirm delete) may re-disable it
  onSubmit = submit;
  $("#form").showModal();
  const first = body.querySelector("input,select,textarea");
  if (first) first.focus();
}

function openCreate() {
  const html =
    field("Coordinate", `<input id="f-coord" placeholder="dev/db/password" autocomplete="off">`) +
    `<div class="field"><span class="lbl">Kind</span><div class="radios">` +
    `<label><input type="radio" name="kind" value="literal" checked> Literal value</label>` +
    `<label><input type="radio" name="kind" value="reference"> Reference</label>` +
    `<label><input type="radio" name="kind" value="generate"> Generate</label>` +
    `</div></div>` +
    `<div id="f-literal">${field("Value", secretFieldHtml("f-value", "", true))}</div>` +
    `<div id="f-reference" hidden>${field("Pointer", `<input id="f-pointer" placeholder="azure-kv://vault/name" autocomplete="off">`)}</div>` +
    `<div id="f-generate" hidden>${field("Length", `<input id="f-length" type="number" value="32" min="1" max="256">`)}</div>` +
    field("Sensitivity", sensSelect("f-sens")) +
    field("Description", `<input id="f-desc" autocomplete="off">`) +
    `<label class="field check"><input id="f-reveal" type="checkbox"> revealable over MCP (non-prod, non-high only)</label>`;

  openForm("New secret", html, async () => {
    const coord = $("#f-coord").value.trim();
    if (!coord) { toast("coordinate is required", "err"); return false; }
    const kind = $("#form-body").querySelector("input[name=kind]:checked").value;
    const sensitivity = $("#f-sens").value;
    const description = $("#f-desc").value.trim() || undefined;
    if (kind === "generate") {
      const length = parseInt($("#f-length").value, 10) || 32;
      return submitJson("POST", "/api/generate", { coord, length, sensitivity, description }, "generated");
    }
    const revealable = $("#f-reveal").checked;
    const payload = { coord, sensitivity, description, revealable };
    if (kind === "reference") payload.reference = $("#f-pointer").value.trim();
    else payload.value = $("#f-value").value;
    return submitJson("POST", "/api/secret", payload, "created");
  });

  // kind toggles which extra field shows.
  const body = $("#form-body");
  body.querySelectorAll("input[name=kind]").forEach((r) => {
    r.onchange = () => {
      const k = body.querySelector("input[name=kind]:checked").value;
      $("#f-literal").hidden = k !== "literal";
      $("#f-reference").hidden = k !== "reference";
      $("#f-generate").hidden = k !== "generate";
    };
  });
}

function openEdit(d) {
  const isRef = d.mode === "reference";
  const isLiteral = d.mode === "literal";
  const html =
    `<p class="note">Editing <code>${esc(d.coordinate)}</code></p>` +
    field("Sensitivity", sensSelect("e-sens", d.sensitivity)) +
    field("Description", `<input id="e-desc" autocomplete="off" value="${esc(d.description || "")}">`) +
    (isRef ? field("Pointer", `<input id="e-pointer" autocomplete="off" value="${esc(d.pointer || "")}">`) : "") +
    (isLiteral ? `<div class="field"><span class="lbl">New value <span class="muted">(optional)</span></span>${secretFieldHtml("e-value", "", true)}</div>` : "") +
    `<label class="field check"><input id="e-reveal" type="checkbox"${d.revealable ? " checked" : ""}> revealable over MCP</label>`;

  openForm(`Edit ${d.coordinate}`, html, async () => {
    const p = projectOf(d.origin);
    const meta = {
      coord: d.coordinate,
      project: p || undefined,
      sensitivity: $("#e-sens").value,
      description: $("#e-desc").value.trim() || undefined,
      revealable: $("#e-reveal").checked,
    };
    if (isRef) meta.reference = $("#e-pointer").value.trim();
    const r1 = await api("/api/secret", {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(meta),
    });
    if (!r1.ok) { toast(r1.body?.error || `edit failed (${r1.status})`, "err"); return false; }
    // Optional value replacement for literals.
    const newVal = isLiteral ? $("#e-value").value : "";
    if (newVal) {
      const r2 = await api("/api/secret", {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ coord: d.coordinate, project: p || undefined, value: newVal }),
      });
      if (!r2.ok) { toast(r2.body?.error || `value update failed (${r2.status})`, "err"); return false; }
    }
    toast("saved");
    return true;
  });
}

function openDelete(d) {
  // The delete gate mirrors the reveal tier (KOV-30): critical secrets (high /
  // inject-only) are confirmed at the broker (Touch ID / `kovra approve`) — the
  // server enforces it. Non-critical secrets (low / medium) are not biometric-
  // gated; we guard them here with a type-the-name confirmation against
  // accidental deletion.
  const critical = d.sensitivity === "high" || d.sensitivity === "inject-only";
  const doDelete = async () => {
    const p = projectOf(d.origin);
    const q = new URLSearchParams({ coord: d.coordinate });
    if (p) q.set("project", p);
    const { ok, status, body } = await api(`/api/secret?${q}`, { method: "DELETE" });
    if (!ok) { toast(body?.error || `delete failed (${status})`, "err"); return false; }
    toast("deleted");
    return true;
  };

  if (critical) {
    const html =
      `<p>Delete <code>${esc(d.coordinate)}</code> (<strong>${esc(d.sensitivity)}</strong>)? This cannot be undone.</p>` +
      `<p class="note">You'll be asked to approve on your device (Touch ID), or via <code>kovra approve</code> in a terminal.</p>`;
    openForm("Delete secret", html, doDelete, "Delete", true);
    return;
  }

  const html =
    `<p>Delete <code>${esc(d.coordinate)}</code>? This cannot be undone.</p>` +
    // The coordinate is shown read-only with a Copy button (reusing the secret-
    // field affordance) so the confirmation can be copy-pasted, not retyped.
    `<label class="field"><span class="lbl">Coordinate</span>` +
    `<div class="secret-field">` +
    `<input id="del-name" type="text" value="${esc(d.coordinate)}" readonly autocomplete="off" spellcheck="false">` +
    `<button type="button" class="copy" data-target="del-name" title="Copy">${ICON_COPY}</button>` +
    `</div></label>` +
    `<label class="field"><span class="lbl">Type or paste it to confirm</span>` +
    `<input id="del-confirm" autocomplete="off" placeholder="${esc(d.coordinate)}"></label>`;
  openForm("Delete secret", html, async () => {
    if ($("#del-confirm").value.trim() !== d.coordinate) {
      toast("name does not match", "err");
      return false;
    }
    return doDelete();
  }, "Delete", true);

  // Selecting the read-only coordinate (click or double-click) selects it whole,
  // so it can be copied with Ctrl-C and pasted (Ctrl-V) into the field below —
  // in addition to the Copy button.
  const name = $("#del-name");
  name.onfocus = () => name.select();
  name.onclick = () => name.select();

  // Keep the danger button disabled until the confirmation matches exactly.
  const inp = $("#del-confirm");
  const btn = $("#form-submit");
  btn.disabled = true;
  inp.oninput = () => { btn.disabled = inp.value.trim() !== d.coordinate; };
  inp.focus(); // the read-only coordinate is first in the DOM; focus the input to fill
}

/** POST/PUT a JSON body; on success toast + signal the form to close + reload. */
async function submitJson(method, path, payload, okWord) {
  const { ok, status, body } = await api(path, {
    method,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!ok) { toast(body?.error || `${okWord} failed (${status})`, "err"); return false; }
  toast(okWord);
  return true;
}

/* ── grid (flat table ↔ coordinate tree) ──────────────────────────────── */

let page = "home"; // "home" | "secrets"
let view = "table"; // "table" | "tree"
let currentSecrets = [];
let searchTerm = "";          // from the topbar search box
const colFilters = {};        // per-column popup filters: field → value
let projectFilter = null;     // sidebar scope: null = all (Secrets); else an origin like "project:foo"

const isNode = (d) => !!(d && d._node);

function coordCell(cell) {
  const d = cell.getData();
  if (isNode(d)) {
    return `<strong>${esc(d._label)}</strong> <span class="muted">(${d._count})</span>`;
  }
  const shadow = d.shadows_global
    ? ' <span class="muted" title="shadows a global coordinate">*shadows global</span>'
    : "";
  // The env Tree groups leaves under their environment node, so each leaf shows
  // the remainder (component/key); the flat Table shows the full coordinate.
  const label = view === "tree" ? esc(`${d.component}/${d.key}`) : esc(d.coordinate);
  return `<code>${label}</code>${shadow}`;
}
function modeCell(cell) {
  const d = cell.getData();
  if (isNode(d)) return "";
  const ptr = d.pointer ? ` <span class="muted">→ ${esc(d.pointer)}</span>` : "";
  return `<span class="mode-pill">${esc(d.mode || "")}</span>${ptr}`;
}
function fpCell(cell) {
  const d = cell.getData();
  if (isNode(d)) return "";
  return d.fingerprint ? `<span class="fp">${esc(d.fingerprint)}</span>` : "";
}
function sensCell(cell) {
  const d = cell.getData();
  return isNode(d) ? "" : badge(cell.getValue());
}
function actionCell(cell) {
  if (isNode(cell.getData())) return "";
  return (
    `<button class="row-act" data-act="inspect" title="Inspect / reveal">${ICON_EYE}</button>` +
    `<button class="row-act" data-act="edit" title="Edit">${ICON_EDIT}</button>` +
    `<button class="row-act danger" data-act="del" title="Delete">${ICON_TRASH}</button>`
  );
}
function onAction(e, cell) {
  // The click may land on the icon's <svg>/<path>, so resolve the button.
  const act = e.target?.closest?.("button[data-act]")?.dataset?.act;
  if (!act) return; // a click on the node toggle / empty cell
  const d = cell.getData();
  if (act === "inspect") inspect(d);
  else if (act === "edit") openEdit(d);
  else if (act === "del") openDelete(d);
}

/** Group the flat secrets list into a single level: one node per environment,
 *  with its secrets (keys) directly underneath. */
function toTree(secrets) {
  const envs = new Map();
  for (const s of secrets) {
    if (!envs.has(s.environment)) envs.set(s.environment, []);
    envs.get(s.environment).push(s);
  }
  const out = [];
  for (const [env, leaves] of envs) {
    out.push({ _node: true, _label: env, _count: leaves.length, coordinate: env, _children: leaves });
  }
  return out;
}

/** project name from an origin (`project:foo` → `foo`, `global` → `global`). */
const originLabel = (origin) =>
  origin && origin.startsWith("project:") ? origin.slice(8) : "global";

const originOf = (s) => s.origin || "global";

/** Distinct origins in inventory order (global first, as the API lists it). */
function distinctOrigins(secrets) {
  const seen = new Set();
  const out = [];
  for (const s of secrets) {
    const o = originOf(s);
    if (!seen.has(o)) { seen.add(o); out.push(o); }
  }
  return out;
}

/** The secrets visible on the Secrets page: those of the selected scope
 *  (`global` or `project:x`). There is no "all scopes" view — navigation is by
 *  scope — so an unset/stale filter falls back to `global`. */
function visibleSecrets() {
  const origins = distinctOrigins(currentSecrets);
  if (!projectFilter || !origins.includes(projectFilter)) {
    projectFilter = origins.includes("global") ? "global" : origins[0] || "global";
  }
  return currentSecrets.filter((s) => originOf(s) === projectFilter);
}

/** Populate the sidebar "Projects" group: a `(global)` entry for the secrets
 *  that live in no project, then one item per project vault, each with its
 *  secret count. (The top-level "Secrets" entry stays = every scope at once.)
 *  Keeps the active highlight in sync with `projectFilter`. */
function renderSidebarProjects() {
  const list = $("#proj-list");
  const origins = distinctOrigins(currentSecrets); // global first, then projects
  list.innerHTML = "";
  for (const o of origins) {
    const n = currentSecrets.filter((s) => originOf(s) === o).length;
    const label = o === "global" ? "(global)" : originLabel(o);
    const item = el(
      "button",
      { class: `navitem${o === projectFilter ? " on" : ""}`, "data-origin": o },
      `<span class="nl">${esc(label)}</span><span class="nc">${n}</span>`,
    );
    item.onclick = () => selectProject(o);
    list.appendChild(item);
  }
  syncNav();
}

/** Expand/collapse the sidebar "Projects" group. */
function setProjectsExpanded(open) {
  $("#proj-list").hidden = !open;
  const t = $("#proj-toggle");
  t.classList.toggle("open", open);
  t.setAttribute("aria-expanded", String(open));
}

/** Switch the sidebar scope: `null` = all secrets, else a `project:x` origin.
 *  Resets the search + column filters (as switching views did), re-renders the
 *  grid scoped to it, and updates the highlights. */
function selectProject(origin) {
  projectFilter = origin;
  $("#search").value = "";
  searchTerm = "";
  Object.keys(colFilters).forEach((k) => delete colFilters[k]);
  if (origin) setProjectsExpanded(true);
  renderSidebarProjects();
  render();
  setPage("secrets"); // picking a scope lands on the Secrets page (persists + syncs)
}

/* ── page routing (Home ↔ Secrets) ────────────────────────────────────── */

/** Highlight the sidebar entry matching the current page + project scope. */
function syncNav() {
  $("#nav-home").classList.toggle("on", page === "home");
  document.querySelectorAll("#proj-list .navitem").forEach((it) => {
    it.classList.toggle("on", page === "secrets" && it.dataset.origin === projectFilter);
  });
}

/** Switch between the Home overview and the Secrets inventory. Persists so a
 *  browser refresh reopens the same page; redraws the grid when showing Secrets
 *  (it may have been built while its section was hidden, i.e. zero-height). */
function setPage(p) {
  page = p;
  $("#page-home").hidden = p !== "home";
  $("#page-secrets").hidden = p !== "secrets";
  syncNav();
  persistScope();
  if (p === "home") renderHome();
  else if (table) table.redraw(true);
}

/* ── Home overview (metrics + pending intakes + recent secrets) ────────── */

/** Render the Home page from the loaded inventory + the pending intakes. */
function renderHome() {
  const all = currentSecrets;
  const count = (pred) => all.filter(pred).length;
  $("#stat-total").textContent = all.length;
  $("#stat-high").textContent = count((s) => s.sensitivity === "high");
  $("#stat-inject").textContent = count((s) => s.sensitivity === "inject-only");
  $("#stat-ref").textContent = count((s) => s.mode === "reference");
  $("#home-sub").textContent = `${all.length} secret${all.length === 1 ? "" : "s"} · overview`;
  renderRecent();
  renderIntakes();
}

/** The 8 most-recently-updated secrets, flat (no tree). Click → inspect drawer. */
function renderRecent() {
  const recent = [...currentSecrets]
    .sort((a, b) => String(b.updated || "").localeCompare(String(a.updated || "")))
    .slice(0, 8);
  const box = $("#recent");
  if (!recent.length) { box.innerHTML = `<div class="empty">no secrets yet</div>`; return; }
  box.innerHTML =
    `<table class="mini"><tbody>` +
    recent
      .map(
        (s) =>
          `<tr data-coord="${esc(s.coordinate)}" data-origin="${esc(s.origin || "global")}">` +
          `<td class="proj muted">${esc(s.origin === "global" || !s.origin ? "global" : originLabel(s.origin))}</td>` +
          `<td class="c"><code>${esc(s.coordinate)}</code></td>` +
          `<td>${badge(s.sensitivity)}</td>` +
          `<td><span class="mode-pill">${esc(s.mode || "")}</span></td>` +
          `</tr>`,
      )
      .join("") +
    `</tbody></table>`;
  box.querySelectorAll("tr").forEach((tr) => {
    tr.onclick = () => {
      const d = currentSecrets.find(
        (x) => x.coordinate === tr.dataset.coord && (x.origin || "global") === tr.dataset.origin,
      );
      if (d) inspect(d);
    };
  });
}

/** Fetch + render the pending intakes, each with Fulfil / Dismiss. */
async function renderIntakes() {
  const list = $("#intake-list");
  const counter = $("#intake-count");
  const { ok, body } = await api("/api/intakes");
  if (!ok) { list.innerHTML = `<div class="empty">could not load intakes</div>`; counter.textContent = "0"; return; }
  const intakes = body.intakes || [];
  counter.textContent = String(intakes.length);
  if (!intakes.length) { list.innerHTML = `<div class="empty">no pending requests</div>`; return; }
  list.innerHTML = intakes.map(intakeRowHtml).join("");
  list.querySelectorAll("[data-fulfill]").forEach((b) => {
    b.onclick = () => openFulfill(intakes.find((x) => x.id === b.dataset.fulfill));
  });
  list.querySelectorAll("[data-dismiss]").forEach((b) => {
    b.onclick = () => dismissIntake(b.dataset.dismiss);
  });
}

/** One pending-intake card. The requester note is untrusted → escaped + fenced. */
function intakeRowHtml(i) {
  const proc = i.requesting_process ? ` <span class="muted">· ${esc(i.requesting_process)}</span>` : "";
  const note = i.description ? `<div class="fenced sm">${esc(i.description)}</div>` : "";
  return (
    `<div class="intake">` +
    `<div class="ii">` +
    `<div class="ic"><code>${esc(i.coordinate)}</code> ${badge(i.sensitivity)}</div>` +
    `<div class="im muted">${esc(i.environment)}${proc}</div>` +
    note +
    `</div>` +
    `<div class="ia">` +
    `<button class="btn sm primary" data-fulfill="${esc(i.id)}">Fulfil</button>` +
    `<button class="btn sm" data-dismiss="${esc(i.id)}">Dismiss</button>` +
    `</div>` +
    `</div>`
  );
}

/** Open the fulfil dialog for an intake: the human types the value here (never
 *  the agent), picks the destination vault, and seals it. High → the server
 *  gates it at the broker (Touch ID / `kovra approve`). */
function openFulfill(i) {
  if (!i) return;
  const projects = distinctOrigins(currentSecrets).filter((o) => o.startsWith("project:"));
  const projOpts =
    `<option value="">(global)</option>` +
    projects.map((o) => `<option value="${esc(originLabel(o))}">${esc(originLabel(o))}</option>`).join("");
  const high = i.sensitivity === "high";
  const html =
    `<p class="note">Sealing <code>${esc(i.coordinate)}</code> as <strong>${esc(i.sensitivity)}</strong>${i.environment === "prod" ? " (prod is born high)" : ""}.</p>` +
    (i.description ? field("Requester note (untrusted)", `<div class="fenced">${esc(i.description)}</div>`) : "") +
    `<div class="field"><span class="lbl">Value</span>${secretFieldHtml("ff-value", "", true)}</div>` +
    `<label class="field"><span class="lbl">Project vault</span><select id="ff-proj">${projOpts}</select></label>` +
    (high ? `<p class="note">High — you'll approve on your device (Touch ID), or via <code>kovra approve</code>.</p>` : "");
  openForm(`Fulfil ${i.coordinate}`, html, async () => {
    const value = $("#ff-value").value;
    if (!value) { toast("value is required", "err"); return false; }
    const project = $("#ff-proj").value || undefined;
    return submitJson("POST", "/api/intakes/fulfill", { id: i.id, value, project }, "fulfilled");
  }, "Fulfil");
}

/** Dismiss (drop) a pending intake without fulfilling it. */
async function dismissIntake(id) {
  const { ok, status, body } = await api(`/api/intakes?id=${encodeURIComponent(id)}`, { method: "DELETE" });
  if (!ok) { toast(body?.error || `dismiss failed (${status})`, "err"); return; }
  toast("dismissed");
  load();
}

/** Persist the current scope (view + selected project) so a browser refresh
 *  lands back on the same page. Theme is persisted separately. */
function persistScope() {
  try {
    localStorage.setItem("kovra-page", page);
    localStorage.setItem("kovra-view", view);
    if (projectFilter) localStorage.setItem("kovra-project", projectFilter);
    else localStorage.removeItem("kovra-project");
  } catch (_) { /* private mode */ }
}

/** Restore the persisted scope before the first load, so a refresh reopens the
 *  same view + project (and expands the Projects group when one is selected). A
 *  project that no longer exists is dropped later by `visibleSecrets()`. */
function restoreState() {
  try {
    const pg = localStorage.getItem("kovra-page");
    if (pg === "home" || pg === "secrets") page = pg;
    const v = localStorage.getItem("kovra-view");
    if (v === "table" || v === "tree") view = v;
    const p = localStorage.getItem("kovra-project");
    if (p) projectFilter = p;
  } catch (_) { /* private mode */ }
  $("#view-table").classList.toggle("on", view === "table");
  $("#view-tree").classList.toggle("on", view === "tree");
  // Projects is the inventory nav now → open by default so the scopes are visible.
  setProjectsExpanded(true);
  // Page visibility + nav highlight are applied by setPage() at the end of load().
}

/* ── Excel-style column filters (funnel icon → popup) ─────────────────── */

const FUNNEL_ICON =
  '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18l-7 8v5l-4 2v-7L3 5Z"/></svg>';

// Tree expand/collapse toggles — Tabler chevrons (chevron-right when collapsed,
// chevron-down when expanded). These replace Tabulator's default +/- control box
// (an unreadable bordered square in dark); the chevron alone marks a node.
const TREE_EXPAND =
  '<svg class="tree-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6l-6 6"/></svg>';
const TREE_COLLAPSE =
  '<svg class="tree-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6l6 -6"/></svg>';

/** A row passes when it matches the global search AND every active column filter. */
function rowMatches(data) {
  if (isNode(data)) return true;
  if (searchTerm) {
    const hit = ["coordinate", "origin", "environment", "mode", "pointer"].some(
      (f) => String(data[f] || "").toLowerCase().includes(searchTerm),
    );
    if (!hit) return false;
  }
  for (const [field, val] of Object.entries(colFilters)) {
    if (!val) continue;
    if (field === "sensitivity") {
      if (String(data.sensitivity || "") !== val) return false;
    } else if (!String(data[field] || "").toLowerCase().includes(val.toLowerCase())) {
      return false;
    }
  }
  return true;
}

/** Recompute the single combined filter (search + all column popups). */
function applyFilters() {
  if (!table || view !== "table") return;
  const active = searchTerm || Object.values(colFilters).some(Boolean);
  if (active) table.setFilter(rowMatches);
  else table.clearFilter(true);
}

/** Highlight the funnel of every column that currently has an active filter. */
function markActiveFilterCols() {
  if (!table) return;
  for (const c of table.getColumns()) {
    const f = c.getField();
    c.getElement().classList.toggle("has-filter", !!(f && colFilters[f]));
  }
}

/** Build the popup body for a column filter (text input or sensitivity select). */
function filterPopup(field, kind) {
  return (e, column, onRendered) => {
    const wrap = el("div", { class: "col-filter-popup" });
    wrap.appendChild(el("div", { class: "cfp-label" }, `Filter ${esc(column.getDefinition().title)}`));
    const input =
      kind === "sens"
        ? el("select", { class: "cfp-input" },
            `<option value="">all</option>` + SENS.map((s) => `<option value="${s}">${s}</option>`).join(""))
        : el("input", { class: "cfp-input", type: "text", placeholder: "contains…", autocomplete: "off" });
    input.value = colFilters[field] || "";
    const onChange = () => {
      const v = (input.value || "").trim();
      if (v) colFilters[field] = v;
      else delete colFilters[field];
      column.getElement().classList.toggle("has-filter", !!v);
      applyFilters();
    };
    input.addEventListener("input", onChange);
    input.addEventListener("change", onChange);
    wrap.appendChild(input);
    const clear = el("button", { type: "button", class: "cfp-clear" }, "Clear");
    clear.addEventListener("click", () => { input.value = ""; onChange(); input.focus(); });
    wrap.appendChild(clear);
    onRendered(() => input.focus());
    return wrap;
  };
}

function columns() {
  // Column filters are popups in the flat table; the tree view has none.
  const pf = (field, kind) =>
    view === "table" ? { headerPopup: filterPopup(field, kind), headerPopupIcon: FUNNEL_ICON } : {};
  // minWidths fit "title + sort arrow + funnel button" so headers never truncate.
  return [
    { title: "Coordinate", field: "coordinate", formatter: coordCell, widthGrow: 3, minWidth: 220, ...pf("coordinate", "text") },
    { title: "Env", field: "environment", width: 120, minWidth: 120, ...pf("environment", "text") },
    { title: "Sensitivity", field: "sensitivity", width: 175, minWidth: 175, formatter: sensCell, ...pf("sensitivity", "sens") },
    { title: "Mode", field: "mode", formatter: modeCell, widthGrow: 2, minWidth: 150, ...pf("mode", "text") },
    { title: "Fingerprint", field: "fingerprint", width: 150, minWidth: 130, formatter: fpCell },
    { title: "", field: "_act", width: 210, minWidth: 210, hozAlign: "right", headerSort: false, formatter: actionCell, cellClick: onAction },
  ];
}

/** Double-clicking a secret row opens its inspect/reveal drawer (not on tree
 *  nodes, and not when the click lands on a row-action button). */
function onRowDblClick(e, row) {
  if (e.target?.closest && e.target.closest("button.row-act")) return;
  const d = row.getData();
  if (!isNode(d)) inspect(d);
}

function render() {
  if (table) { table.destroy(); table = null; }
  const common = {
    layout: "fitColumns", height: "100%", movableColumns: true, placeholder: "no secrets",
    columns: columns(), rowDblClick: onRowDblClick,
    // Reapply persisted filters after the grid (re)builds — e.g. after a refresh.
    tableBuilt() { applyFilters(); markActiveFilterCols(); },
  };
  // The sidebar scope (all vs a single project) decides which secrets render;
  // both the flat Table and the env Tree show that scoped set.
  const data = visibleSecrets();
  // Stat strip + subtitle reflect the active sidebar scope (a project, or all).
  updateStats(data);

  if (view === "tree") {
    table = new Tabulator("#grid", {
      ...common,
      data: toTree(data),
      dataTree: true,
      dataTreeStartExpanded: true,
      dataTreeElementColumn: "coordinate",
      dataTreeExpandElement: TREE_EXPAND,
      dataTreeCollapseElement: TREE_COLLAPSE,
      dataTreeBranchElement: false,
    });
  } else {
    table = new Tabulator("#grid", {
      ...common,
      data,
      pagination: true,
      paginationSize: 25,
      paginationSizeSelector: [10, 25, 50, 100],
      initialSort: [{ column: "coordinate", dir: "asc" }],
    });
  }
}

function setView(v) {
  if (view === v) return;
  view = v;
  $("#view-table").classList.toggle("on", v === "table");
  $("#view-tree").classList.toggle("on", v === "tree");
  // Switching views resets the active filters (search + column popups).
  $("#search").value = "";
  searchTerm = "";
  Object.keys(colFilters).forEach((k) => delete colFilters[k]);
  persistScope();
  render();
}

/** Global search across the human-relevant fields (flat table only). */
function applySearch(term) {
  searchTerm = (term || "").trim().toLowerCase();
  applyFilters();
}

/** Recompute the header subtitle count + the stats strip from the inventory. */
/** The Secrets page title + subtitle, reflecting the active scope. */
function updateStats(data) {
  const n = data.length;
  const scope = projectFilter === "global" ? "global" : projectFilter ? originLabel(projectFilter) : "";
  $("#status").textContent = scope ? `${n} secret${n === 1 ? "" : "s"} in ${scope}` : `${n} secrets`;
  const title = $("#secrets-title");
  if (title) {
    title.textContent =
      projectFilter === "global" ? "Global" : projectFilter ? originLabel(projectFilter) : "Secrets";
  }
}

async function load() {
  // Spin the refresh icon while loading (with a small floor so a fast local
  // fetch still reads as a deliberate refresh).
  const btn = $("#refresh");
  btn.classList.add("spinning");
  const minSpin = new Promise((r) => setTimeout(r, 500));
  try {
    const { ok, status: code, body } = await api("/api/secrets");
    if (!ok) { $("#status").textContent = `auth error (${code})`; return; }
    currentSecrets = body.secrets || [];
    // render() first: it validates `projectFilter` (clears a stale one) so the
    // sidebar highlight that follows is correct after a browser refresh.
    render();
    renderSidebarProjects();
    setPage(page); // apply the restored/active page (renders Home or redraws the grid)
  } finally {
    await minSpin;
    btn.classList.remove("spinning");
  }
}

/* ── theme (persisted) ────────────────────────────────────────────────── */

function applyTheme(t) {
  document.documentElement.dataset.theme = t;
  try { localStorage.setItem("kovra-theme", t); } catch (_) { /* private mode */ }
}
function toggleTheme() {
  applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
}

document.addEventListener("DOMContentLoaded", () => {
  try {
    const saved = localStorage.getItem("kovra-theme");
    if (saved) applyTheme(saved);
  } catch (_) { /* private mode */ }
  restoreState();

  $("#search").addEventListener("input", (e) => applySearch(e.target.value));
  $("#refresh").addEventListener("click", load);
  $("#theme").addEventListener("click", toggleTheme);
  $("#new").addEventListener("click", openCreate);
  $("#view-table").addEventListener("click", () => setView("table"));
  $("#view-tree").addEventListener("click", () => setView("tree"));
  $("#nav-home").addEventListener("click", (e) => { e.preventDefault(); setPage("home"); });
  $("#proj-toggle").addEventListener("click", () => setProjectsExpanded($("#proj-list").hidden));
  $("#home-new").addEventListener("click", openCreate);
  $("#reveal-close").addEventListener("click", closeReveal);
  $("#scrim").addEventListener("click", closeReveal);
  $("#form-cancel").addEventListener("click", () => $("#form").close());
  $("#form-cancel-2").addEventListener("click", () => $("#form").close());
  $("#form-el").addEventListener("submit", async (e) => {
    e.preventDefault();
    if (!onSubmit) return;
    const ok = await onSubmit();
    if (ok) { $("#form").close(); load(); }
  });
  load();
});
