// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

import { hostCore, hostState } from "./host.js";

export {};

/* DOM IDs in the server-owned page are required by this feature; bmEl throws
 * during initialization if that contract is broken. */
interface Bookmark {
  id: string;
  name: string;
  freq_hz: number;
  mode: string;
  bandwidth_hz?: number | null;
  locator?: string | null;
  category?: string | null;
  comment?: string | null;
  decoders?: string[];
  scope?: string;
}

interface BookmarkService {
  readonly overlayList: readonly Bookmark[];
  readonly overlayRevision: number;
  refreshOverlay(): Promise<void>;
  invalidateColors(): void;
  apply(bookmark: Bookmark): void;
  formatFrequency(frequencyHz: number): string;
  fetch(categoryFilter: string): Promise<void>;
  populateScopePicker(): void;
}
interface VirtualChannelService {
  interceptMode(mode: string): Promise<boolean>;
  interceptBandwidth(bandwidthHz: number): Promise<boolean>;
  takeSchedulerControl(): Promise<void>;
}

interface BookmarkBridge {
  trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
  trxUi: {
    confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
    notify?(message: string, options: { kind: "error" }): void;
  };
  syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
}

type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
const bridge = window as unknown as BookmarkBridge;
function bmEl(id: string): BookmarkElement {
  const element = document.getElementById(id);
  if (!element) throw new Error(`Missing bookmark element #${id}`);
  return element as BookmarkElement;
}

/* Decoder checkboxes and decoder toggle buttons are built from the runtime
 * registry, so their elements are legitimately absent for unbuilt decoders. */
function bmOptionalEl(id: string): BookmarkElement | null {
  return document.getElementById(id) as BookmarkElement | null;
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

// --- Bookmarks Tab ---

/** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general";

/** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix: boolean, scope?: string | null): string {
  const sep = prefix ? "&" : "?";
  return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}

let bmList: Bookmark[] = [];
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
let bmOverlayList: Bookmark[] = [];
let bmOverlayRevision = 0;
let bmFilteredList: Bookmark[] = [];
let bmEditScope: string | null = null;
let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25;
const bmSelected = new Set<string>();

function bmFmtFreq(hz: number): string {
  if (!Number.isFinite(hz) || hz <= 0) return "--";
  if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
  if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
  if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
  return `${hz}\u202fHz`;
}

function bmEsc(str: unknown): string {
  const d = document.createElement("div");
  d.appendChild(document.createTextNode(String(str)));
  return d.innerHTML;
}

function bmCanControl() {
  return !hostState.authEnabled || hostState.authRole === "control";
}

// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() {
  const canCtrl = bmCanControl();
  const addBtn = bmEl("bm-add-btn");
  const selectAllBtn = bmEl("bm-select-all-btn");
  if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
  if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}

/** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() {
  return hostState.lastActiveRigId || "general";
}

async function bmFetchOverlay() {
  const overlayScope = bmListScope();
  try {
    const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    bmOverlayList = await resp.json() as Bookmark[];
  } catch (e) {
    console.error("Failed to fetch overlay bookmarks:", e);
    bmOverlayList = [];
  }
  bmOverlayRevision++;
  if (typeof bridge.syncBookmarkMapLocators === "function") {
    bridge.syncBookmarkMapLocators(bmOverlayList);
  }
  hostCore.scheduleSpectrumDraw();
}

async function bmFetch(categoryFilter: string): Promise<void> {
  let url = "/bookmarks";
  let hasQuery = false;
  if (categoryFilter && categoryFilter !== "") {
    url += "?category=" + encodeURIComponent(categoryFilter);
    hasQuery = true;
  }
  url += bmScopeParam(hasQuery);
  const overlayPromise = bmFetchOverlay();
  try {
    const resp = await fetch(url);
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    bmList = await resp.json() as Bookmark[];
  } catch (e) {
    console.error("Failed to fetch bookmarks:", e);
    bmList = [];
  }
  bmSelected.clear();
  bmUpdateSelectionUi();
  bmSyncAccess();
  bmApplyFilters();
  void bmRefreshCategoryFilter(categoryFilter);
  await overlayPromise;
}

function bmApplyFilters() {
  const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
  const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
  let filtered = modeFilter
    ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
    : bmList;
  filtered = text
    ? filtered.filter((bm) =>
        (bm.name || "").toLowerCase().includes(text) ||
        (bm.locator || "").toLowerCase().includes(text) ||
        (bm.category || "").toLowerCase().includes(text) ||
        (bm.comment || "").toLowerCase().includes(text)
      )
    : filtered;
  bmFilteredList = filtered;
  bmCurrentPage = 1;
  bmRender(filtered);
}

async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
  const sel = bmEl("bm-category-filter");
  const modeSel = bmEl("bm-mode-filter");
  if (!sel && !modeSel) return;
  try {
    const resp = await fetch("/bookmarks" + bmScopeParam(false));
    if (!resp.ok) return;
    const all = await resp.json() as Bookmark[];
    if (sel) {
      const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
      while (sel.options.length > 1) sel.remove(1);
      cats.forEach((cat) => {
        const opt = document.createElement("option");
        opt.value = cat;
        opt.textContent = cat;
        sel.add(opt);
      });
      if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
    }
    if (modeSel) {
      const keepMode = modeSel.value;
      const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
      while (modeSel.options.length > 1) modeSel.remove(1);
      modes.forEach((mode) => {
        const opt = document.createElement("option");
        opt.value = mode;
        opt.textContent = mode;
        modeSel.add(opt);
      });
      if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
    }
  } catch {}
}

function bmRender(list: Bookmark[]): void {
  const tbody = bmEl("bm-tbody");
  const emptyEl = bmEl("bm-empty");
  const paginatorEl = bmEl("bm-paginator");
  const pageSummaryEl = bmEl("bm-page-summary");
  const pageIndicatorEl = bmEl("bm-page-indicator");
  const prevBtn = bmEl("bm-page-prev");
  const nextBtn = bmEl("bm-page-next");
  if (!tbody) return;
  tbody.innerHTML = "";

  if (list.length === 0) {
    if (emptyEl) emptyEl.style.display = "";
    if (paginatorEl) paginatorEl.style.display = "none";
    return;
  }
  if (emptyEl) emptyEl.style.display = "none";

  const canControl = bmCanControl();
  const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
  const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
  bmCurrentPage = page;
  const startIndex = (page - 1) * BM_PAGE_SIZE;
  const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
  const pageItems = list.slice(startIndex, endIndex);

  const showScope = bmScope !== "general";
  pageItems.forEach((bm) => {
    const tr = document.createElement("tr");
    tr.dataset.bmId = bm.id;
    const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
    const locatorCell = bm.locator || "--";
    const catCell = bm.category || "Uncategorised";
    const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
    const commentCell = bm.comment || "";
    const checked = bmSelected.has(bm.id) ? " checked" : "";
    const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
    tr.innerHTML =
      `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
      `<td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td>` +
      `<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
      `<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
      `<td class="bm-col-bw">${bwCell}</td>` +
      `<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
      `<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
      `<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
      `<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
      `<td class="bm-col-act">` +
        `<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
        (canControl
          ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
            `<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>`
          : "") +
      `</td>`;
    tbody.appendChild(tr);
  });
  bmSyncSelectAllCheckbox();

  if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
  if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
  if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
  if (prevBtn) prevBtn.disabled = page <= 1;
  if (nextBtn) nextBtn.disabled = page >= totalPages;
}

function bmChangePage(delta: number): void {
  const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
  const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
  if (nextPage === bmCurrentPage) return;
  bmCurrentPage = nextPage;
  bmRender(bmFilteredList);
}

// Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders(): string[] {
  return hostState.decoderRegistry
    .filter(d => d.bookmark_selectable)
    .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
    .map(d => d.id);
}

// Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders: readonly string[]): void {
  const set = new Set(decoders || []);
  hostState.decoderRegistry
    .filter(d => d.bookmark_selectable)
    .forEach(d => {
      const el = bmOptionalEl("bm-dec-" + d.id);
      if (el) el.checked = set.has(d.id);
    });
}

// Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() {
  const container = bmEl("bm-decoder-checkboxes");
  if (!container) return;
  container.innerHTML = "";
  hostState.decoderRegistry
    .filter(d => d.bookmark_selectable)
    .forEach(d => {
      const label = document.createElement("label");
      label.className = "bm-decoder-check";
      label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
      container.appendChild(label);
    });
}

function bmOpenForm(bm: Bookmark | null): void {
  const wrap = bmEl("bm-form-wrap");
  if (!wrap) return;
  bmEditScope = bm ? (bm.scope || bmScope) : null;

  // Rebuild decoder checkboxes from registry (handles race where registry
  // loaded after initial build).
  bmBuildDecoderCheckboxes();

  bmEl("bm-id").value = bm ? bm.id : "";
  bmEl("bm-name").value = bm ? bm.name : "";
  bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
  bmEl("bm-mode").value = bm ? bm.mode : "";
  bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
  bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
  bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
  bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
  bmWriteDecoders(bm?.decoders ?? []);
  bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";

  wrap.style.display = "flex";
  bmEl("bm-name").focus();
}

function bmCloseForm() {
  const wrap = bmEl("bm-form-wrap");
  if (wrap) wrap.style.display = "none";
}

function bmPrefillFromStatus() {
  // Read live rig state from the host contract (updated by the SSE stream).
  const freqHz = hostState.lastFreqHz;
  if (freqHz != null && Number.isFinite(freqHz)) {
    bmEl("bm-freq").value = String(Math.round(freqHz));
  }
  if (hostState.lastModeName) {
    bmEl("bm-mode").value = hostState.lastModeName;
  }
  if (hostState.currentBandwidthHz > 0) {
    bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
  }
  // Prefill decoder checkboxes from current toggle button state.
  const activeDecoders = hostState.decoderRegistry
    .filter(d => d.bookmark_selectable && d.activation === "toggle")
    .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
    .map(d => d.id);
  bmWriteDecoders(activeDecoders);
}

async function bmSave(e: Event): Promise<void> {
  e.preventDefault();
  const id = bmEl("bm-id").value;
  const name = bmEl("bm-name").value.trim();
  const freqStr = bmEl("bm-freq").value;
  const freq_hz = parseInt(freqStr, 10);
  const mode = bmEl("bm-mode").value.trim();
  const bwStr = bmEl("bm-bw").value;
  const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
  const locator = bmEl("bm-locator").value.trim().toUpperCase();
  const category = bmEl("bm-category-input").value.trim();
  const comment = bmEl("bm-comment").value.trim();
  const decoders = bmReadDecoders();

  const formError = bmEl("bm-form-error");
  if (formError) formError.textContent = "";
  if (!name || !Number.isFinite(freq_hz) || !mode) {
    if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
    const invalid = !name ? bmEl("bm-name")
      : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
    invalid?.focus();
    return;
  }

  const body = {
    name,
    freq_hz,
    mode,
    bandwidth_hz,
    locator: locator || null,
    category,
    comment,
    decoders,
  };

  try {
    let resp;
    if (id) {
      resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
    } else {
      resp = await fetch("/bookmarks" + bmScopeParam(false), {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
    }
    if (!resp.ok) {
      const text = await resp.text();
      if (resp.status === 409) {
        throw new Error("A bookmark for that frequency already exists.");
      }
      throw new Error(text || `HTTP ${resp.status}`);
    }
    bmCloseForm();
    await bmFetch(bmEl("bm-category-filter").value);
  } catch (err) {
    console.error("Failed to save bookmark:", err);
    if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
    bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
  }
}

async function bmDelete(id: string): Promise<void> {
  if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
  const bm = bmList.find((b) => b.id === id);
  const scope = bm ? bm.scope : undefined;
  try {
    const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
      method: "DELETE",
    });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    await bmFetch(bmEl("bm-category-filter").value);
  } catch (err) {
    console.error("Failed to delete bookmark:", err);
    bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
  }
}

function bmApply(bm: Bookmark): void {
  try {
    // --- Optimistic UI updates (instant, before any network round-trips) ---
    const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
    if (modeEl) {
      modeEl.value = (bm.mode || "").toUpperCase();
      hostCore.syncModePicker();
    }
    if (bm.bandwidth_hz) {
      hostState.currentBandwidthHz = bm.bandwidth_hz;
      hostCore.syncBandwidthInput(bm.bandwidth_hz);
    }
    // Set optimistic guard before applying so SSE cannot snap back.
    hostCore.armOptimisticFrequency(bm.freq_hz);
    // Force display so the BW overlay is repositioned even when freq is unchanged.
    hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
    if (hostState.lastSpectrumData) {
      hostCore.scheduleSpectrumDraw();
    }

    // Take scheduler control up front, then apply mode before bandwidth so a
    // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
    const tunePromise = (async () => {
      await bridge.trx.modules.vchan?.takeSchedulerControl();

      const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
      if (!onVirtual) {
        await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
      }

      if (bm.bandwidth_hz) {
        const bwHandledByVchan =
          await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
        if (!bwHandledByVchan) {
          await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
        }
      }

      // setRigFrequency redirects to the channel API when a virtual channel is
      // active.  It repeats the optimistic update applied above, which is a
      // no-op because the value is unchanged.
      hostCore.setRigFrequency(bm.freq_hz);
    })();
    // Decoder toggles — fire-and-forget.
    //  - Decoders incompatible with the new mode are always turned off
    //    (even when the bookmark has no explicit decoder selection).
    //  - For compatible decoders, if the bookmark specifies a set, the
    //    toggles are driven to match that set; otherwise they're left
    //    alone.
    const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
    const modeUp = (bm.mode || "").toUpperCase();
    const allToggleDecoders = hostState.decoderRegistry.filter(d =>
      d.activation === "toggle"
    );
    const decoderPromise = allToggleDecoders.length ? (async () => {
      let statusUrl = "/status";
      const rigId = hostState.lastActiveRigId;
      if (rigId) {
        statusUrl += "?remote=" + encodeURIComponent(rigId);
      }
      const statusResp = await fetch(statusUrl);
      if (!statusResp.ok) return;
      const st = await statusResp.json() as Record<string, unknown>;
      const toggles: Promise<unknown>[] = [];
      for (const d of allToggleDecoders) {
        const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
        const currentlyOn = !!st[statusKey];
        const compatible = Array.isArray(d.active_modes)
          && d.active_modes.includes(modeUp);
        let wanted;
        if (!compatible) {
          // Always disable decoders that don't apply to the new mode.
          wanted = false;
        } else if (hasDecoders) {
          wanted = bm.decoders?.includes(d.id) ?? false;
        } else {
          // Mode-compatible and no bookmark selection: leave as-is.
          wanted = currentlyOn;
        }
        if (wanted !== currentlyOn) {
          toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
        }
      }
      if (toggles.length) await Promise.all(toggles);
    })() : Promise.resolve();
    // Don't await — let the network calls settle in the background.
    // Errors are logged but don't block the UI.
    void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
      console.error("Bookmark apply background error:", error);
    });
  } catch (err) {
    console.error("Failed to apply bookmark:", err);
  }
}

bridge.trx.modules.bookmarks = {
  get overlayList() { return bmOverlayList; },
  get overlayRevision() { return bmOverlayRevision; },
  refreshOverlay: bmFetchOverlay,
  invalidateColors() { bmOverlayRevision += 1; },
  apply: bmApply,
  formatFrequency: bmFmtFreq,
  fetch: bmFetch,
  populateScopePicker: bmPopulateScopePicker,
};

function bmUpdateSelectionUi() {
  const count = bmSelected.size;
  const canCtrl = bmCanControl();
  const visible = count > 0 && canCtrl;
  const btn = bmEl("bm-del-selected-btn");
  const countEl = bmEl("bm-del-selected-count");
  if (btn) btn.style.display = visible ? "" : "none";
  if (countEl) countEl.textContent = String(count);
  const moveWrap = bmEl("bm-move-selected-wrap");
  const moveCountEl = bmEl("bm-move-selected-count");
  if (moveWrap) moveWrap.style.display = visible ? "" : "none";
  if (moveCountEl) moveCountEl.textContent = String(count);
  if (visible) bmPopulateMoveTarget();
  const selectAllBtn = bmEl("bm-select-all-btn");
  if (selectAllBtn && bmCanControl()) {
    const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
    selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
  }
}

/** Populate the move-target dropdown with all scopes except the current one. */
function bmPopulateMoveTarget() {
  const sel = bmEl("bm-move-target");
  if (!sel) return;
  const rigIds = hostState.lastRigIds;
  const displayNames = hostState.lastRigDisplayNames;
  const prev = sel.value;
  sel.innerHTML = "";
  if (bmScope !== "general") {
    const opt = document.createElement("option");
    opt.value = "general";
    opt.textContent = "General";
    sel.appendChild(opt);
  }
  rigIds.forEach((id) => {
    if (id === bmScope) return;
    const opt = document.createElement("option");
    opt.value = id;
    opt.textContent = displayNames[id] || id;
    sel.appendChild(opt);
  });
  if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
    sel.value = prev;
  }
}

async function bmMoveSelected() {
  const ids = Array.from(bmSelected);
  if (ids.length === 0) return;
  const target = bmEl("bm-move-target")?.value;
  if (!target) return;
  const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
  if (!await bridge.trxUi.confirm({
    title: "Move selected bookmarks?",
    message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
    confirmLabel: "Move",
    danger: false,
  })) return;
  try {
    // Group selected IDs by their owning scope (skip if already in target).
    const byScope: Record<string, string[]> = {};
    for (const id of ids) {
      const bm = bmList.find((b) => b.id === id);
      const scope = bm?.scope || bmScope;
      if (scope === target) continue;
      (byScope[scope] ||= []).push(id);
    }
    await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
      fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ ids: scopeIds, to: target }),
      }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
    ));
    bmSelected.clear();
    bmUpdateSelectionUi();
    await bmFetch(bmEl("bm-category-filter").value);
  } catch (err) {
    console.error("Failed to move bookmarks:", err);
    bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
  }
}

function bmSyncSelectAllCheckbox() {
  const selectAll = bmEl("bm-select-all");
  if (!selectAll) return;
  const checkboxes = document.querySelectorAll<HTMLInputElement>(".bm-row-sel");
  if (checkboxes.length === 0) {
    selectAll.checked = false;
    selectAll.indeterminate = false;
    return;
  }
  const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
  selectAll.checked = checkedCount === checkboxes.length;
  selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}

async function bmDeleteSelected() {
  const ids = Array.from(bmSelected);
  if (ids.length === 0) return;
  if (!await bridge.trxUi.confirm({
    title: "Delete selected bookmarks?",
    message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
    confirmLabel: "Delete",
  })) return;
  try {
    // Group selected IDs by their owning scope.
    const byScope: Record<string, string[]> = {};
    for (const id of ids) {
      const bm = bmList.find((b) => b.id === id);
      const scope = bm?.scope || bmScope;
      (byScope[scope] ||= []).push(id);
    }
    await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
      fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ ids: scopeIds }),
      }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
    ));
    bmSelected.clear();
    bmUpdateSelectionUi();
    await bmFetch(bmEl("bm-category-filter").value);
  } catch (err) {
    console.error("Failed to delete bookmarks:", err);
    bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
  }
}

/** Populate the scope picker with "General" + one option per rig. */
function bmPopulateScopePicker() {
  const picker = bmEl("bm-scope-picker");
  if (!picker) return;
  const rigIds = hostState.lastRigIds;
  const displayNames = hostState.lastRigDisplayNames;
  // Preserve current selection if still valid.
  const prev = picker.value;
  while (picker.options.length > 1) picker.remove(1);
  rigIds.forEach((id) => {
    const opt = document.createElement("option");
    opt.value = id;
    opt.textContent = displayNames[id] || id;
    picker.appendChild(opt);
  });
  if (prev && (prev === "general" || rigIds.includes(prev))) {
    picker.value = prev;
  } else {
    picker.value = "general";
  }
  bmScope = picker.value;
}

// --- Event wiring ---
(function initBookmarks() {
  // Set initial button visibility (auth may already be resolved by the time
  // scripts run if auth is disabled; otherwise bmFetch() will sync it).
  bmSyncAccess();

  // Build decoder checkboxes from registry.  The registry is fetched async
  // so we rebuild once it arrives to ensure checkboxes are present.
  bmBuildDecoderCheckboxes();
  hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);

  // Scope picker
  bmPopulateScopePicker();
  const scopePicker = bmEl("bm-scope-picker");
  if (scopePicker) {
    scopePicker.addEventListener("change", (e) => {
      bmScope = (e.currentTarget as HTMLSelectElement).value;
      void bmFetch(bmEl("bm-category-filter").value || "");
    });
  }

  // Refresh list and sync access when the Bookmarks tab is activated
  document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
    const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
    if (!btn) return;
    void bmFetch(bmEl("bm-category-filter").value);
  });

  // Add Bookmark button — open form and prefill from current rig state
  bmEl("bm-add-btn").addEventListener("click", () => {
    bmOpenForm(null);
    bmPrefillFromStatus();
  });

  // Category filter dropdown
  bmEl("bm-category-filter").addEventListener("change", (e) => {
    void bmFetch((e.currentTarget as HTMLSelectElement).value);
  });

  // Mode filter dropdown (client-side, no re-fetch)
  bmEl("bm-mode-filter").addEventListener("change", () => {
    bmApplyFilters();
  });

  // Text search filter (client-side, no re-fetch)
  bmEl("bm-text-filter").addEventListener("input", () => {
    bmApplyFilters();
  });

  bmEl("bm-page-prev").addEventListener("click", () => {
    bmChangePage(-1);
  });

  bmEl("bm-page-next").addEventListener("click", () => {
    bmChangePage(1);
  });

  // Form submit
  bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });

  // Form cancel
  bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);

  const formWrap = bmEl("bm-form-wrap");
  if (formWrap) {
    formWrap.addEventListener("click", (e) => {
      if (e.target === formWrap) bmCloseForm();
    });
  }

  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
      bmCloseForm();
    }
  });

  // Select-all checkbox
  bmEl("bm-select-all").addEventListener("change", (e) => {
    const checked = (e.currentTarget as HTMLInputElement).checked;
    document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
      cb.checked = checked;
      const id = cb.dataset.bmId;
      if (!id) return;
      if (checked) bmSelected.add(id);
      else bmSelected.delete(id);
    });
    bmUpdateSelectionUi();
  });

  // Select All (across all pages) button
  bmEl("bm-select-all-btn").addEventListener("click", () => {
    const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
    if (allSelected) {
      bmSelected.clear();
    } else {
      bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
    }
    // Sync visible page checkboxes
    document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
      cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
    });
    bmSyncSelectAllCheckbox();
    bmUpdateSelectionUi();
  });

  // Delete Selected button
  bmEl("bm-del-selected-btn").addEventListener("click", () => {
    void bmDeleteSelected();
  });

  // Move Selected button
  bmEl("bm-move-selected-btn").addEventListener("click", () => {
    void bmMoveSelected();
  });

  // Table action buttons and row checkboxes (event delegation)
  bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
    if (!(e.target instanceof Element)) return;
    const checkbox = e.target.closest<HTMLInputElement>(".bm-row-sel");
    if (checkbox) {
      const id = checkbox.dataset.bmId;
      if (!id) return;
      if (checkbox.checked) bmSelected.add(id);
      else bmSelected.delete(id);
      bmSyncSelectAllCheckbox();
      bmUpdateSelectionUi();
      return;
    }

    const tuneBtn = e.target.closest<HTMLElement>(".bm-tune-btn");
    const editBtn = e.target.closest<HTMLElement>(".bm-edit-btn");
    const delBtn = e.target.closest<HTMLElement>(".bm-del-btn");

    if (tuneBtn) {
      const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
      if (bm) bmApply(bm);
    } else if (editBtn) {
      const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
      if (bm) bmOpenForm(bm);
    } else if (delBtn) {
      const id = delBtn.dataset.bmId;
      if (id) await bmDelete(id);
    }
  })(); });

  // Pre-load bookmarks so spectrum markers are visible immediately.
  void bmFetch("");
})();
