// identity's admin panel logic. Vanilla JS, no framework, no build step — same
// "vendored, dependency-free" choice as every other frontend in this project. See
// admin.html's top comment for why this page is the one that isn't kiosk-shaped.
"use strict";

const params = new URLSearchParams(location.search);
const API = (params.get("api") || "").replace(/\/$/, "");
const TOKEN = params.get("token") || "";

if (!API || !TOKEN) {
  document.body.innerHTML =
    '<p class="error" style="padding:24px">identity not configured — missing ' +
    "?api=&token= in the URL.</p>";
  throw new Error("admin: missing ?api=/&token= query params");
}

// Chore types the checkboxes offer. Kept in sync BY HAND with chores/check.py's
// _CHORE_PROMPTS — there is no endpoint that lists them, because chores/ is a
// oneshot timer job with no HTTP surface at all, and inventing one just so this
// dropdown could be generated would be a lot of moving parts for a list that changes
// about once a year. "litter" is deliberately absent: it can't be assigned to anyone
// (see chores/README.md), so offering it here would be offering a lie.
const CHORE_TYPES = ["trash", "bin_full", "dishes"];

// digest-engine's four sections, in the order they're offered. The keys are its own
// section ids (synth/prompts/<key>.md), which is also exactly what the server stores —
// nothing translates between the two. The labels are the household's words for them:
// "personal" is the mail/messages one, "political" the news one. Kept in sync BY HAND
// with DIGEST_SECTIONS in server.py, same arrangement as CHORE_TYPES above, except the
// server does validate these — an unknown key comes back as a refusal, not a silent
// write.
const DIGEST_SECTIONS = [
  ["network", "Network"],
  ["household", "Household"],
  ["personal", "Social"],
  ["political", "Political / news"],
];

function api(path, options) {
  options = options || {};
  options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {});
  return fetch(`${API}${path}`, options).then((res) =>
    res.json().then((body) => {
      // 409 carries a real, human-readable refusal ("that nickname collides with…"),
      // so it's a result to display rather than an error to throw — same convention
      // as register.js.
      if (!res.ok && res.status !== 409) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`);
      return body;
    })
  );
}

function postJson(path, body) {
  return api(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// Dates are stored as UTC ISO strings; a household admin reading "was Amir home
// Tuesday evening" wants them in their own timezone, which is what toLocaleString
// gives without this page needing to know what that timezone is.
function fmt(iso) {
  if (!iso) return "—";
  const d = new Date(iso);
  return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}

function fmtDate(iso) {
  if (!iso) return "—";
  const d = new Date(iso);
  return isNaN(d) ? iso : d.toLocaleDateString(undefined, { dateStyle: "medium" });
}

function duration(fromIso, toIso) {
  const from = new Date(fromIso);
  const to = toIso ? new Date(toIso) : new Date();
  const mins = Math.round((to - from) / 60000);
  if (isNaN(mins) || mins < 0) return "";
  if (mins < 60) return `${mins}m`;
  const hours = Math.floor(mins / 60);
  return hours < 24 ? `${hours}h ${mins % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`;
}

// Same blob-fetch approach as register.js/dashboard.js — every identity endpoint
// requires an Authorization header, and a plain <img src="..."> has no way to send
// one. See identity/README.md.
// --- Colour + profile picture --------------------------------------------------------
// The palette comes from the server (GET /person-colors) rather than being repeated
// here: those eight values are chosen to render exactly on a colour Pebble's 2-bit-per-
// channel screen, and a second copy in this file would drift the first time somebody
// "improved" one of them. Falls back to whatever the person already has if the fetch
// fails, so the editor still opens.
let personColors = [];

function loadPersonColors() {
  return api("/person-colors")
    .then((data) => {
      personColors = data.colors || [];
    })
    .catch(() => {
      personColors = [];
    });
}

function renderColorSwatches() {
  const row = document.getElementById("edit-colors");
  const current = (editing.color || "").toUpperCase();
  const palette = personColors.length ? personColors : [current].filter(Boolean);
  // A colour set by hand outside the palette still gets a swatch, so it is visible and
  // reselectable rather than silently absent from its own editor.
  const colors = palette.includes(current) || !current ? palette : palette.concat([current]);
  row.innerHTML = colors
    .map(
      (color) =>
        `<button type="button" class="swatch${color.toUpperCase() === current ? " selected" : ""}"
          data-color="${escapeHtml(color)}" style="background:${escapeHtml(color)}"
          title="${escapeHtml(color)}"><span>${escapeHtml(editing.initial || "")}</span></button>`
    )
    .join("");
  row.querySelectorAll(".swatch").forEach((btn) =>
    btn.addEventListener("click", () => {
      editing.color = btn.dataset.color;
      renderColorSwatches();
      const avatar = document.getElementById("edit-avatar");
      if (!editing.has_photo) avatar.style.background = editing.color;
    })
  );
}

document.getElementById("edit-photo-upload").addEventListener("click", () => {
  document.getElementById("edit-photo-file").click();
});

document.getElementById("edit-photo-file").addEventListener("change", (event) => {
  const file = event.target.files && event.target.files[0];
  if (!file || !editing) return;
  setStatus("Uploading…");
  // Raw bytes, not multipart — same shape as the level-image upload and
  // /register/photo. The picture is written immediately rather than waiting for Save,
  // because it is a file on the server, not a field in this form.
  api(`/people/${editing.id}/photo`, { method: "POST", body: file, headers: { "Content-Type": file.type || "image/jpeg" } })
    .then((result) => {
      if (!result.ok) throw new Error(result.message || "Could not upload.");
      return refreshEditing("Picture updated.");
    })
    .catch((err) => setStatus(err.message, true))
    .finally(() => {
      event.target.value = "";
    });
});

document.getElementById("edit-photo-clear").addEventListener("click", () => {
  if (!editing) return;
  setStatus("Removing…");
  postJson(`/people/${editing.id}`, { clear_photo: true })
    .then((result) => {
      if (!result.ok) throw new Error(result.message || "Could not remove.");
      return refreshEditing("Picture removed — showing their initial instead.");
    })
    .catch((err) => setStatus(err.message, true));
});

function loadAvatar(container, personId) {
  fetch(`${API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${TOKEN}` } })
    .then((res) => (res.ok ? res.blob() : Promise.reject()))
    .then((blob) => {
      const img = document.createElement("img");
      img.src = URL.createObjectURL(blob);
      container.replaceChildren(img);
    })
    .catch(() => {});
}

// --- Tabs ------------------------------------------------------------------------
document.querySelectorAll(".tab").forEach((tab) => {
  tab.addEventListener("click", () => {
    document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab));
    document.querySelectorAll(".panel").forEach((p) => {
      p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`);
    });
    if (tab.dataset.panel === "access") loadAccessLog();
    if (tab.dataset.panel === "floorplan") {
      // Loaded on open rather than at startup: it costs an HA round trip for the area
      // suggestions, and most visits to this page never touch the floorplan.
      loadFloorplan().then(loadFloorplanPresence);
      loadAreaSuggestions();
    }
  });
});

// --- People list -------------------------------------------------------------------
let people = [];

function personSubtitle(p) {
  const bits = [];
  if (p.nickname) bits.push(`“${p.nickname}”`);
  if (p.currently_home_since) bits.push(`home since ${fmt(p.currently_home_since)}`);
  else bits.push(`last seen ${fmtDate(p.last_visit_at)}${p.last_visit_is_estimated ? " (never recorded)" : ""}`);
  if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`);
  else bits.push("no device");
  if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`);
  if (p.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)");
  if (!p.announce_arrivals) bits.push("not announced");
  // Only worth a line when it isn't the default (everything) — otherwise every person
  // would carry the same four words.
  if (p.digest_sections.length < DIGEST_SECTIONS.length) {
    bits.push(p.digest_sections.length ? `digests: ${p.digest_sections.join(", ")}` : "no digests");
  }
  if (p.chore_exempt) bits.push("chore-exempt");
  if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`);
  return bits.join(" · ");
}

function loadPeople() {
  const el = document.getElementById("people-list");
  return api("/people")
    .then((data) => {
      people = data.people || [];
      if (!people.length) {
        el.innerHTML = '<p class="hint">Nobody registered yet.</p>';
        return;
      }
      el.innerHTML = people
        .map(
          (p) =>
            `<button type="button" class="card as-button" data-person="${p.id}">
               <span class="avatar" data-avatar="${p.id}" style="background:${escapeHtml(
                 p.color || ""
               )}">${escapeHtml(p.initial || "")}</span>
               <span class="card-body">
                 <span class="card-name">${escapeHtml(p.name)}${p.currently_home_since ? ' <span class="dot-home" title="home now"></span>' : ""}</span>
                 <span class="card-meta">${escapeHtml(personSubtitle(p))}</span>
               </span>
             </button>`
        )
        .join("");
      el.querySelectorAll("[data-person]").forEach((btn) => {
        btn.addEventListener("click", () => openEditor(Number(btn.dataset.person)));
      });
      people.forEach((p) => {
        if (p.has_photo) {
          const avatar = el.querySelector(`[data-avatar="${p.id}"]`);
          if (avatar) loadAvatar(avatar, p.id);
        }
      });
      populatePersonSelect();
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load people: ${escapeHtml(err.message)}</p>`;
    });
}

// --- Person editor ------------------------------------------------------------------
const editor = document.getElementById("editor");
const editStatus = document.getElementById("edit-status");
let editing = null;

function setStatus(text, isError) {
  editStatus.textContent = text;
  editStatus.className = isError ? "error" : "hint";
}

function openEditor(personId) {
  editing = people.find((p) => p.id === personId);
  if (!editing) return;

  document.getElementById("edit-title").textContent = editing.name;
  document.getElementById("edit-name").value = editing.name;
  document.getElementById("edit-nickname").value = editing.nickname || "";
  document.getElementById("edit-note").value = editing.note || "";
  document.getElementById("edit-chore-exempt").checked = editing.chore_exempt;
  document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || "";
  document.getElementById("edit-notify-on-arrival").checked = editing.notify_on_arrival;
  document.getElementById("edit-announce-arrivals").checked = editing.announce_arrivals;
  document.getElementById("edit-notify-topic").value = editing.notify_topic || "";
  // Says out loud when a ticked box still can't deliver, rather than leaving someone
  // to cross-reference a checkbox against an env file to work out why nothing arrives.
  document.getElementById("notify-hint").innerHTML = editing.notify_deliverable
    ? "Pushes go out via the household ntfy server."
    : '<span class="error">No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.</span>';

  const avatar = document.getElementById("edit-avatar");
  avatar.replaceChildren(document.createTextNode(editing.initial || "👤"));
  avatar.style.background = editing.color || "";
  if (editing.has_photo) loadAvatar(avatar, editing.id);

  renderColorSwatches();

  document.getElementById("edit-digest-sections").innerHTML = DIGEST_SECTIONS.map(
    ([key, label]) =>
      `<label class="chip"><input type="checkbox" data-digest="${key}"${
        editing.digest_sections.includes(key) ? " checked" : ""
      }> ${escapeHtml(label)}</label>`
  ).join("");

  document.getElementById("edit-chore-types").innerHTML = CHORE_TYPES.map(
    (type) =>
      `<label class="chip"><input type="checkbox" data-chore="${type}"${
        editing.chore_assignments.includes(type) ? " checked" : ""
      }> ${escapeHtml(type)}</label>`
  ).join("");

  renderIdentifiers();
  renderGrants();
  setStatus("");
  editor.showModal();
}

function renderIdentifiers() {
  const el = document.getElementById("edit-identifiers");
  if (!editing.identifiers.length) {
    el.innerHTML = '<p class="hint">No devices — presence has to be set by hand.</p>';
    return;
  }
  el.innerHTML = editing.identifiers
    .map(
      (i) =>
        `<div class="card compact">
           <span class="card-body">
             <span class="card-name mono">${escapeHtml(i.ha_entity_id)}</span>
             <span class="card-meta">added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")}</span>
           </span>
           <button type="button" class="btn danger small" data-drop-identifier="${i.id}">Revoke</button>
         </div>`
    )
    .join("");
  el.querySelectorAll("[data-drop-identifier]").forEach((btn) => {
    btn.addEventListener("click", () => {
      api(`/people/${editing.id}/identifiers/${btn.dataset.dropIdentifier}`, { method: "DELETE" })
        .then(() => refreshEditing("Device revoked."))
        .catch((err) => setStatus(err.message, true));
    });
  });
}

function renderGrants() {
  const el = document.getElementById("edit-grants");
  if (!editing.device_grants.length) {
    el.innerHTML = '<p class="hint">No device rights.</p>';
    return;
  }
  const now = new Date().toISOString();
  el.innerHTML = editing.device_grants
    .map((g) => {
      const expired = g.expires_at && g.expires_at <= now;
      return `<div class="card compact${expired ? " expired" : ""}">
           <span class="card-body">
             <span class="card-name mono">${escapeHtml(g.ha_entity_id)}</span>
             <span class="card-meta">${escapeHtml(g.permission)}${
        g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry"
      }${g.note ? ` · ${escapeHtml(g.note)}` : ""}</span>
           </span>
           <button type="button" class="btn danger small" data-drop-grant="${g.id}">Revoke</button>
         </div>`;
    })
    .join("");
  el.querySelectorAll("[data-drop-grant]").forEach((btn) => {
    btn.addEventListener("click", () => {
      api(`/people/${editing.id}/device-grants/${btn.dataset.dropGrant}`, { method: "DELETE" })
        .then(() => refreshEditing("Right revoked."))
        .catch((err) => setStatus(err.message, true));
    });
  });
}

// Re-reads /people and re-points the open dialog at the fresh record, so a sub-action
// (revoking a device, adding a grant) doesn't leave the dialog showing stale data or
// force the admin to close and reopen it.
function refreshEditing(message) {
  const id = editing.id;
  return loadPeople().then(() => {
    editing = people.find((p) => p.id === id);
    if (!editing) {
      editor.close();
      return;
    }
    renderIdentifiers();
    renderGrants();
    if (message) setStatus(message);
  });
}

document.getElementById("edit-close").addEventListener("click", () => editor.close());

document.getElementById("edit-save").addEventListener("click", () => {
  const chores = Array.from(document.querySelectorAll("[data-chore]:checked")).map((c) => c.dataset.chore);
  setStatus("Saving…");
  // Two calls because they're two different resources, not one form: the person's own
  // fields, and their chore assignments. Sequenced rather than parallel so a rejected
  // rename (a nickname collision, say) surfaces its message instead of racing the
  // assignment call's own status text.
  postJson(`/people/${editing.id}`, {
    name: document.getElementById("edit-name").value.trim(),
    nickname: document.getElementById("edit-nickname").value.trim(),
    note: document.getElementById("edit-note").value.trim(),
    chore_exempt: document.getElementById("edit-chore-exempt").checked,
    chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(),
    notify_on_arrival: document.getElementById("edit-notify-on-arrival").checked,
    announce_arrivals: document.getElementById("edit-announce-arrivals").checked,
    notify_topic: document.getElementById("edit-notify-topic").value.trim(),
    // Whatever swatch is currently selected — the picker mutates `editing.color` and
    // saves with the rest of the dialog, since it is one more column on `people`.
    color: editing.color || "",
    // Sent with the rest of the person's own fields rather than as a third call: unlike
    // chore assignments (their own table), this is one column on `people`, so it saves
    // or is refused together with everything else in the dialog.
    digest_sections: Array.from(document.querySelectorAll("[data-digest]:checked")).map(
      (c) => c.dataset.digest
    ),
  })
    .then((result) => {
      if (!result.ok) throw new Error(result.message || "Could not save.");
      return postJson(`/people/${editing.id}/chore-assignments`, { chore_types: chores });
    })
    .then(() => refreshEditing("Saved."))
    .then(() => {
      document.getElementById("edit-title").textContent = editing ? editing.name : "";
    })
    .catch((err) => setStatus(err.message, true));
});

// Deliberately sends to whatever is SAVED, not what's typed in the box — a test that
// silently used unsaved input would prove a topic works and then leave a different one
// stored. Save first, then test.
document.getElementById("edit-test-notify").addEventListener("click", () => {
  setStatus("Sending test…");
  postJson(`/people/${editing.id}/test-notification`, {})
    .then((result) => setStatus(result.message, !result.ok))
    .catch((err) => setStatus(err.message, true));
});

document.getElementById("edit-add-identifier").addEventListener("click", () => {
  const input = document.getElementById("edit-new-identifier");
  const entityId = input.value.trim();
  if (!entityId) return;
  postJson(`/people/${editing.id}/identifiers`, { entity_id: entityId })
    .then((result) => {
      if (!result.ok) throw new Error(result.message);
      input.value = "";
      return refreshEditing("Device added.");
    })
    .catch((err) => setStatus(err.message, true));
});

document.getElementById("grant-add").addEventListener("click", () => {
  const entity = document.getElementById("grant-entity");
  const permission = document.getElementById("grant-permission");
  const expires = document.getElementById("grant-expires");
  if (!entity.value.trim()) return;
  postJson(`/people/${editing.id}/device-grants`, {
    entity_id: entity.value.trim(),
    permission: permission.value.trim() || "operate",
    // A date input gives "2026-08-01"; the grant expires at the START of that day, so
    // "until 1 Aug" means the last usable day is 31 Jul. Sent explicitly as UTC
    // midnight rather than left for the server to interpret a bare date.
    expires_at: expires.value ? `${expires.value}T00:00:00Z` : null,
  })
    .then((result) => {
      if (!result.ok) throw new Error(result.message);
      entity.value = "";
      permission.value = "";
      expires.value = "";
      return refreshEditing("Right granted.");
    })
    .catch((err) => setStatus(err.message, true));
});

document.getElementById("edit-delete").addEventListener("click", () => {
  if (!confirm(`Delete ${editing.name} completely? Their devices, rights and visit history go too.`)) return;
  api(`/people/${editing.id}`, { method: "DELETE" })
    .then(() => {
      editor.close();
      loadPeople();
    })
    .catch((err) => setStatus(err.message, true));
});

// --- Prune ---------------------------------------------------------------------------
// The two-step shape here is deliberate and matches the backend: /prune/candidates
// SELECTS, POST /people/prune DELETES the exact ids that came back and stayed ticked.
// The filter never gets re-run at delete time, so someone who walks in the door
// between "Select all" and "Delete selected" can't be swept up by a filter that
// silently re-evaluated. See prune_people()'s docstring in server.py.
const pruneList = document.getElementById("prune-list");
const pruneDelete = document.getElementById("prune-delete");
const pruneStatus = document.getElementById("prune-status");

function selectedPruneIds() {
  return Array.from(pruneList.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value));
}

function syncPruneButton() {
  const n = selectedPruneIds().length;
  pruneDelete.disabled = n === 0;
  pruneDelete.textContent = n ? `Delete ${n} selected` : "Delete selected";
}

document.getElementById("prune-select").addEventListener("click", () => {
  const date = document.getElementById("prune-date").value;
  if (!date) {
    document.getElementById("prune-summary").textContent = "Pick a date first.";
    return;
  }
  pruneStatus.textContent = "";
  api(`/prune/candidates?last_visit_before=${encodeURIComponent(date)}`)
    .then((data) => {
      const summary = document.getElementById("prune-summary");
      if (!data.count) {
        summary.textContent = `Nobody has a last visit before ${fmtDate(date)}.`;
        pruneList.innerHTML = "";
        syncPruneButton();
        return;
      }
      summary.textContent = `${data.count} match${data.count === 1 ? "" : "es"} — untick anyone you want to keep.`;
      pruneList.innerHTML = data.candidates
        .map(
          (p) =>
            `<label class="card compact">
               <input type="checkbox" value="${p.id}" checked>
               <span class="card-body">
                 <span class="card-name">${escapeHtml(p.name)}${p.nickname ? ` “${escapeHtml(p.nickname)}”` : ""}</span>
                 <span class="card-meta">last visit ${escapeHtml(fmtDate(p.last_visit_at))}${
              p.last_visit_is_estimated ? " (never actually recorded — registered then)" : ""
            } · ${p.visit_count} visit${p.visit_count === 1 ? "" : "s"}</span>
               </span>
             </label>`
        )
        .join("");
      pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton));
      syncPruneButton();
    })
    .catch((err) => {
      document.getElementById("prune-summary").innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
    });
});

pruneDelete.addEventListener("click", () => {
  const ids = selectedPruneIds();
  if (!ids.length) return;
  const names = ids.map((id) => (people.find((p) => p.id === id) || {}).name || id);
  if (!confirm(`Delete ${ids.length} record(s)?\n\n${names.join("\n")}\n\nThis cannot be undone.`)) return;
  pruneStatus.textContent = "Deleting…";
  postJson("/people/prune", { person_ids: ids })
    .then((result) => {
      pruneStatus.textContent = `Deleted ${result.deleted.length} record(s).`;
      pruneList.innerHTML = "";
      document.getElementById("prune-summary").textContent = "";
      syncPruneButton();
      loadPeople();
    })
    .catch((err) => {
      pruneStatus.innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`;
    });
});

// --- History --------------------------------------------------------------------------
function populatePersonSelect() {
  const select = document.getElementById("history-person");
  const current = select.value;
  select.innerHTML =
    '<option value="">Everyone</option>' +
    people.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
  select.value = current;
}

function loadHistory() {
  const personId = document.getElementById("history-person").value;
  const sinceDate = document.getElementById("history-since").value;
  const since = sinceDate ? `${sinceDate}T00:00:00Z` : "";
  const query = since ? `?since=${encodeURIComponent(since)}` : "";
  const visitPath = personId ? `/people/${personId}/visits${query}` : `/visits${query}`;

  const el = document.getElementById("visit-list");
  el.innerHTML = '<p class="hint">Loading…</p>';
  api(visitPath)
    .then((data) => {
      const visits = data.visits || [];
      if (!visits.length) {
        el.innerHTML = '<p class="hint">No visits recorded in that range.</p>';
        return;
      }
      el.innerHTML = visits
        .map(
          (v) =>
            `<div class="card compact">
               <span class="card-body">
                 <span class="card-name">${escapeHtml(v.name)}</span>
                 <span class="card-meta">${escapeHtml(fmt(v.arrived_at))} → ${
              v.ongoing ? "still home" : escapeHtml(fmt(v.departed_at))
            } · ${escapeHtml(duration(v.arrived_at, v.departed_at))} · via ${escapeHtml(v.source)}${
              v.close_reason === "timed_out" ? " · departure never observed" : ""
            }</span>
               </span>
             </div>`
        )
        .join("");
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load visits: ${escapeHtml(err.message)}</p>`;
    });

  const copEl = document.getElementById("copresence-list");
  const copQuery = new URLSearchParams();
  if (personId) copQuery.set("person_id", personId);
  if (since) copQuery.set("since", since);
  api(`/co-presence?${copQuery.toString()}`)
    .then((data) => {
      const overlaps = data.overlaps || [];
      if (!overlaps.length) {
        copEl.innerHTML = '<p class="hint">Nobody overlapped in that range.</p>';
        return;
      }
      copEl.innerHTML = overlaps
        .map(
          (o) =>
            `<div class="card compact">
               <span class="card-body">
                 <span class="card-name">${escapeHtml(o.people.map((p) => p.name).join(" + "))}</span>
                 <span class="card-meta">${escapeHtml(fmt(o.from))} → ${
              o.ongoing ? "now" : escapeHtml(fmt(o.until))
            } · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))}</span>
               </span>
             </div>`
        )
        .join("");
    })
    .catch((err) => {
      copEl.innerHTML = `<p class="error">Could not load co-presence: ${escapeHtml(err.message)}</p>`;
    });
}

document.getElementById("history-load").addEventListener("click", loadHistory);

// --- Access log ------------------------------------------------------------------------
function loadAccessLog() {
  const el = document.getElementById("access-list");
  api("/device-access/events?limit=200")
    .then((data) => {
      const events = data.events || [];
      if (!events.length) {
        el.innerHTML = '<p class="hint">Nothing has asked yet.</p>';
        return;
      }
      el.innerHTML = events
        .map(
          (e) =>
            `<div class="card compact ${e.allowed ? "allowed" : "denied"}">
               <span class="card-body">
                 <span class="card-name">${escapeHtml(e.name || `person ${e.person_id}`)} → <span class="mono">${escapeHtml(e.ha_entity_id)}</span></span>
                 <span class="card-meta">${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${
              e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : ""
            }</span>
               </span>
               <span class="verdict">${e.allowed ? "allowed" : "denied"}</span>
             </div>`
        )
        .join("");
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load access log: ${escapeHtml(err.message)}</p>`;
    });
}

// --- Floorplan editor ------------------------------------------------------------------
// Rooms are polygons in NORMALISED 0..1 coordinates (see the schema comment in
// server.py): the plan has to render on a laptop now and possibly a wall panel later,
// and pixel coordinates would be correct on exactly one of them. Everything below
// converts to the SVG's fixed 1000x700 viewBox only at draw time.
const FP = { W: 1000, H: 700 };
let fpLevels = [];
let fpLevelId = null;
let fpSelected = null; // the room being edited (a COPY — see selectRoom)
let fpDraft = null; // points of a room currently being drawn
let fpLive = null; // latest /floorplan/presence payload

const svg = document.getElementById("fp-canvas");
const fpStatus = document.getElementById("fp-status");

function fpSetStatus(text, isError) {
  fpStatus.textContent = text || "";
  fpStatus.className = isError ? "error" : "hint";
}

function toNorm(evt) {
  // Uses the SVG's own coordinate space rather than clientX/clientY arithmetic, so the
  // mapping stays correct however the element is scaled, scrolled, or letterboxed by
  // preserveAspectRatio.
  const pt = svg.createSVGPoint();
  pt.x = evt.clientX;
  pt.y = evt.clientY;
  const local = pt.matrixTransform(svg.getScreenCTM().inverse());
  return [Math.min(1, Math.max(0, local.x / FP.W)), Math.min(1, Math.max(0, local.y / FP.H))];
}

function svgEl(tag, attrs) {
  const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
  Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
  return el;
}

function pointsAttr(points) {
  return points.map(([x, y]) => `${x * FP.W},${y * FP.H}`).join(" ");
}

function currentLevel() {
  return fpLevels.find((l) => l.id === fpLevelId) || null;
}

function renderFloorplan() {
  const level = currentLevel();
  const roomsG = document.getElementById("fp-rooms");
  const handlesG = document.getElementById("fp-handles");
  const draftG = document.getElementById("fp-draft");
  roomsG.replaceChildren();
  handlesG.replaceChildren();
  draftG.replaceChildren();

  const bg = document.getElementById("fp-bg");
  if (level && level.has_image) {
    // Blob-fetched like every other image here — the endpoint is token-gated and an
    // <image href> has no way to send an Authorization header.
    fetch(`${API}/floorplan/levels/${level.id}/image`, { headers: { Authorization: `Bearer ${TOKEN}` } })
      .then((res) => (res.ok ? res.blob() : Promise.reject()))
      .then((blob) => bg.setAttribute("href", URL.createObjectURL(blob)))
      .catch(() => bg.removeAttribute("href"));
  } else {
    bg.removeAttribute("href");
  }

  if (!level) return;

  const liveRooms = {};
  if (fpLive) {
    (fpLive.levels || []).forEach((l) => (l.rooms || []).forEach((r) => (liveRooms[r.id] = r)));
  }
  const showLive = document.getElementById("fp-live").checked;

  level.rooms.forEach((room) => {
    const occupants = ((liveRooms[room.id] || {}).occupants) || [];
    const occupied = showLive && occupants.length > 0;
    const selected = fpSelected && fpSelected.id === room.id;
    roomsG.appendChild(
      svgEl("polygon", {
        points: pointsAttr(room.points),
        class: `fp-room${occupied ? " occupied" : ""}${selected ? " selected" : ""}`,
        fill: room.color || "#6ea8fe",
        "data-room": room.id,
      })
    );

    // Label at the average of the vertices. A true centroid would still sit outside an
    // L-shaped room, so the extra maths buys nothing a human wouldn't just drag anyway.
    const cx = (room.points.reduce((s, p) => s + p[0], 0) / room.points.length) * FP.W;
    const cy = (room.points.reduce((s, p) => s + p[1], 0) / room.points.length) * FP.H;
    const label = svgEl("text", { x: cx, y: cy, class: "fp-label", "text-anchor": "middle" });
    label.textContent = room.name;
    roomsG.appendChild(label);

    if (occupied) {
      const who = svgEl("text", { x: cx, y: cy + 22, class: "fp-occupants", "text-anchor": "middle" });
      who.textContent = occupants.map((o) => o.name).join(", ");
      roomsG.appendChild(who);
    }
    if (!room.ha_area_id) {
      // A drawn room with no HA area can never light up, and looking at a plan where
      // one room never reacts is a confusing way to discover that.
      const warn = svgEl("text", { x: cx, y: cy + 40, class: "fp-warn", "text-anchor": "middle" });
      warn.textContent = "no HA area";
      roomsG.appendChild(warn);
    }
  });

  roomsG.querySelectorAll("[data-room]").forEach((poly) => {
    poly.addEventListener("click", (e) => {
      if (fpDraft) return; // don't hijack clicks meant for the polygon being drawn
      e.stopPropagation();
      selectRoom(Number(poly.dataset.room));
    });
  });

  // Vertex handles for the selected room — dragging one is how a wall gets nudged.
  if (fpSelected) {
    fpSelected.points.forEach((point, index) => {
      const handle = svgEl("circle", {
        cx: point[0] * FP.W,
        cy: point[1] * FP.H,
        r: 8,
        class: "fp-handle",
      });
      handle.addEventListener("pointerdown", (e) => {
        e.stopPropagation();
        handle.setPointerCapture(e.pointerId);
        const move = (ev) => {
          fpSelected.points[index] = toNorm(ev);
          renderFloorplan();
        };
        handle.addEventListener("pointermove", move);
        handle.addEventListener(
          "pointerup",
          () => {
            handle.removeEventListener("pointermove", move);
            fpSetStatus("Moved a corner — Save room to keep it.");
          },
          { once: true }
        );
      });
      handlesG.appendChild(handle);
    });
  }

  if (fpDraft && fpDraft.length) {
    draftG.appendChild(svgEl("polyline", { points: pointsAttr(fpDraft), class: "fp-draft-line" }));
    fpDraft.forEach((p) =>
      draftG.appendChild(svgEl("circle", { cx: p[0] * FP.W, cy: p[1] * FP.H, r: 6, class: "fp-draft-point" }))
    );
  }
}

function renderRoomList() {
  const el = document.getElementById("fp-room-list");
  const level = currentLevel();
  if (!level || !level.rooms.length) {
    el.innerHTML = '<p class="hint">No rooms on this level yet — draw one.</p>';
    return;
  }
  el.innerHTML = level.rooms
    .map(
      (r) =>
        `<button type="button" class="card compact as-button" data-pick="${r.id}">
           <span class="fp-swatch" style="background:${escapeHtml(r.color || "#6ea8fe")}"></span>
           <span class="card-body">
             <span class="card-name">${escapeHtml(r.name)}</span>
             <span class="card-meta">${r.ha_area_id ? escapeHtml(r.ha_area_id) : "not mapped to an HA area"}</span>
           </span>
         </button>`
    )
    .join("");
  el.querySelectorAll("[data-pick]").forEach((b) =>
    b.addEventListener("click", () => selectRoom(Number(b.dataset.pick)))
  );
}

function selectRoom(roomId) {
  const level = currentLevel();
  const room = level && level.rooms.find((r) => r.id === roomId);
  if (!room) return;
  // Edited on a deep copy: dragging handles mutates points as you go, and abandoning
  // an edit has to leave the stored room untouched.
  fpSelected = JSON.parse(JSON.stringify(room));
  document.getElementById("fp-room-editor").hidden = false;
  document.getElementById("fp-room-name").value = room.name;
  document.getElementById("fp-room-area").value = room.ha_area_id || "";
  document.getElementById("fp-room-color").value = room.color || "#6ea8fe";
  document.getElementById("fp-editor-title").textContent = room.name;
  fpSetStatus("");
  renderFloorplan();
}

function clearSelection() {
  fpSelected = null;
  document.getElementById("fp-room-editor").hidden = true;
  document.getElementById("fp-editor-title").textContent = "Rooms";
  renderFloorplan();
}

function loadFloorplan(keepSelection) {
  return api("/floorplan")
    .then((data) => {
      fpLevels = data.levels || [];
      if (!fpLevels.some((l) => l.id === fpLevelId)) fpLevelId = fpLevels.length ? fpLevels[0].id : null;
      const select = document.getElementById("fp-level");
      select.innerHTML = fpLevels.map((l) => `<option value="${l.id}">${escapeHtml(l.name)}</option>`).join("");
      if (fpLevelId) select.value = fpLevelId;
      if (!keepSelection) clearSelection();
      renderRoomList();
      renderFloorplan();
    })
    .catch((err) => fpSetStatus(err.message, true));
}

function loadFloorplanPresence() {
  if (!document.getElementById("fp-live").checked) {
    fpLive = null;
    document.getElementById("fp-unplaced").textContent = "";
    renderFloorplan();
    return Promise.resolve();
  }
  return api("/floorplan/presence")
    .then((data) => {
      fpLive = data;
      const bits = [];
      if ((data.unplaced || []).length) {
        bits.push(
          "Home but not on the plan: " +
            data.unplaced
              .map((p) => escapeHtml(p.name) + (p.reported_area ? ` (area “${escapeHtml(p.reported_area)}”)` : ""))
              .join(", ")
        );
      }
      if ((data.unmapped_areas || []).length) {
        bits.push(
          "Areas HA is reporting that no room claims: <b>" +
            data.unmapped_areas.map(escapeHtml).join(", ") +
            "</b> — draw them, or map an existing room to them."
        );
      }
      document.getElementById("fp-unplaced").innerHTML = bits.join("<br>");
      renderFloorplan();
    })
    .catch(() => {});
}

function loadAreaSuggestions() {
  api("/floorplan/areas")
    .then((data) => {
      document.getElementById("fp-areas").innerHTML = (data.areas || [])
        .map((a) => `<option value="${escapeHtml(a)}">`)
        .join("");
      const hint = document.getElementById("fp-area-hint");
      if (data.error === "ha_unreachable") {
        hint.innerHTML = "<span class=\"error\">Home Assistant unreachable — type the area id by hand.</span>";
      } else if (!(data.areas || []).length) {
        hint.textContent =
          `No areas reported yet on the '${data.attribute || "area_id"}' attribute. That's the field ` +
          "AREA_ATTRIBUTE reads, and it's an unconfirmed guess until Bermuda is actually running — " +
          "see identity/README.md.";
      } else {
        hint.textContent = `Areas HA is reporting right now on '${data.attribute}'.`;
      }
    })
    .catch(() => {});
}

// --- drawing -----------------------------------------------------------------------
document.getElementById("fp-draw").addEventListener("click", () => {
  if (!fpLevelId) return fpSetStatus("Add a level first.", true);
  clearSelection();
  fpDraft = [];
  document.getElementById("fp-finish").disabled = false;
  document.getElementById("fp-cancel").disabled = false;
  fpSetStatus("Click to place corners. Finish (or double-click) to close the room.");
  renderFloorplan();
});

svg.addEventListener("click", (evt) => {
  if (!fpDraft) return;
  fpDraft.push(toNorm(evt));
  fpSetStatus(`${fpDraft.length} corner${fpDraft.length === 1 ? "" : "s"} — 3 needed to finish.`);
  renderFloorplan();
});

svg.addEventListener("dblclick", () => {
  if (fpDraft && fpDraft.length >= 3) finishDraft();
});

function cancelDraft() {
  fpDraft = null;
  document.getElementById("fp-finish").disabled = true;
  document.getElementById("fp-cancel").disabled = true;
  fpSetStatus("");
  renderFloorplan();
}

function finishDraft() {
  if (!fpDraft || fpDraft.length < 3) return fpSetStatus("A room needs at least 3 corners.", true);
  const name = prompt("Room name?");
  if (!name) return;
  postJson("/floorplan/rooms", { level_id: fpLevelId, name, points: fpDraft, color: "#6ea8fe" })
    .then((result) => {
      if (!result.ok) throw new Error(result.message);
      cancelDraft();
      return loadFloorplan().then(() => selectRoom(result.room_id));
    })
    .catch((err) => fpSetStatus(err.message, true));
}

document.getElementById("fp-finish").addEventListener("click", finishDraft);
document.getElementById("fp-cancel").addEventListener("click", cancelDraft);

// --- room editor -------------------------------------------------------------------
document.getElementById("fp-room-save").addEventListener("click", () => {
  if (!fpSelected) return;
  postJson("/floorplan/rooms", {
    id: fpSelected.id,
    level_id: fpLevelId,
    name: document.getElementById("fp-room-name").value.trim(),
    ha_area_id: document.getElementById("fp-room-area").value.trim(),
    color: document.getElementById("fp-room-color").value,
    points: fpSelected.points,
  })
    .then((result) => {
      if (!result.ok) throw new Error(result.message);
      fpSetStatus("Saved.");
      return loadFloorplan(true).then(loadFloorplanPresence);
    })
    .catch((err) => fpSetStatus(err.message, true));
});

document.getElementById("fp-room-delete").addEventListener("click", () => {
  if (!fpSelected || !confirm(`Delete the room “${fpSelected.name}”?`)) return;
  api(`/floorplan/rooms/${fpSelected.id}`, { method: "DELETE" })
    .then(() => {
      clearSelection();
      return loadFloorplan();
    })
    .catch((err) => fpSetStatus(err.message, true));
});

// --- levels ------------------------------------------------------------------------
document.getElementById("fp-level").addEventListener("change", (e) => {
  fpLevelId = Number(e.target.value);
  clearSelection();
  renderRoomList();
  loadFloorplanPresence();
});

document.getElementById("fp-add-level").addEventListener("click", () => {
  const name = prompt("Level name? (e.g. Ground floor)");
  if (!name) return;
  postJson("/floorplan/levels", { name, sort_order: fpLevels.length })
    .then((result) => {
      if (!result.ok) throw new Error(result.message);
      fpLevelId = result.level_id;
      return loadFloorplan();
    })
    .catch((err) => fpSetStatus(err.message, true));
});

document.getElementById("fp-rename-level").addEventListener("click", () => {
  const level = currentLevel();
  if (!level) return;
  const name = prompt("Level name?", level.name);
  if (!name) return;
  postJson("/floorplan/levels", { id: level.id, name, sort_order: level.sort_order })
    .then(() => loadFloorplan(true))
    .catch((err) => fpSetStatus(err.message, true));
});

document.getElementById("fp-delete-level").addEventListener("click", () => {
  const level = currentLevel();
  if (!level) return;
  if (!confirm(`Delete “${level.name}” and its ${level.rooms.length} room(s)?`)) return;
  api(`/floorplan/levels/${level.id}`, { method: "DELETE" })
    .then(() => {
      fpLevelId = null;
      return loadFloorplan();
    })
    .catch((err) => fpSetStatus(err.message, true));
});

document.getElementById("fp-image").addEventListener("change", (e) => {
  const file = e.target.files[0];
  if (!file || !fpLevelId) return;
  fpSetStatus("Uploading background…");
  // Raw bytes, same shape as the registration-photo endpoint.
  fetch(`${API}/floorplan/levels/${fpLevelId}/image`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/octet-stream" },
    body: file,
  })
    .then((res) => res.json())
    .then((result) => {
      if (!result.ok) throw new Error(result.message || "Upload failed");
      fpSetStatus("Background saved.");
      e.target.value = "";
      return loadFloorplan(true);
    })
    .catch((err) => fpSetStatus(err.message, true));
});

document.getElementById("fp-live").addEventListener("change", loadFloorplanPresence);

// The palette first, so the first editor opened already has its swatches — it is one
// small request and it never has to be repeated.
loadPersonColors().then(loadPeople);
