// the address bar: crumbs at rest, a search field once you click in or press "/". empty, it
// lists every page; typing searches pages, their headings and the configuration settings.

import { focusIn } from "./menukeys";

interface Entry {
  kind: "page" | "heading" | "setting";
  label: string;
  hint: string;
  url: string;
  icon: string;
}

const KINDS: [Entry["kind"], string][] = [["page", "pages"], ["heading", "sections"], ["setting", "settings"]];
const PER_KIND = 8;
/** below this many index hits, full text search (pagefind) fills in */
const FEW = 3;

interface Pagefind {
  search(q: string): Promise<{ results: { data(): Promise<{ url: string; meta: { title?: string }; sub_results: { url: string; title: string }[] }> }[] }>;
}
let pagefind: Promise<Pagefind | null> | undefined;
// built after the site by `pagefind`, so it isn't there in dev
const loadPagefind = () =>
  (pagefind ??= import(/* @vite-ignore */ `${location.origin}/pagefind/pagefind.js`).catch(() => null) as Promise<Pagefind | null>);

async function fullText(q: string, skip: Set<string>): Promise<Entry[]> {
  const pf = await loadPagefind();
  if (!pf) return [];
  const { results } = await pf.search(q);
  const data = await Promise.all(results.slice(0, PER_KIND).map((r) => r.data()));
  return data
    .map((d) => {
      const sub = d.sub_results[0];
      const url = new URL(sub?.url ?? d.url, location.origin);
      return { kind: "heading" as const, label: sub?.title ?? d.meta.title ?? url.pathname, hint: d.meta.title ?? "", url: url.pathname + url.hash, icon: "i-doc" };
    })
    .filter((e) => !skip.has(e.url));
}

const addr = document.querySelector<HTMLElement>("[data-addr]");
if (addr) setup(addr);

function setup(addr: HTMLElement) {
  const input = addr.querySelector<HTMLInputElement>("[data-q]")!;
  const menu = addr.querySelector<HTMLElement>("[data-menu]")!;
  const all = menu.querySelector<HTMLElement>("[data-all]")!;
  const results = menu.querySelector<HTMLElement>("[data-results]")!;
  // sibling menus can be added later (configuration adds its own), so look them up each time
  const sibs = () => [...addr.querySelectorAll<HTMLElement>("[data-sibmenu]")];
  // without js the menus are plain popovers their buttons toggle. from here on this script
  // opens and closes them, so they stop light dismissing on their own
  takeOver(addr);
  let index: Promise<Entry[]> | undefined;
  let active: HTMLElement | null = null;
  let act = -1;

  const load = () => (index ??= fetch("/search.json").then((r) => r.json() as Promise<Entry[]>));
  const links = () => (active ? [...active.querySelectorAll<HTMLAnchorElement>("a")].filter((a) => a.offsetParent) : []);

  function mark(i: number, scroll = true) {
    const ls = links();
    act = ls.length ? Math.max(0, Math.min(ls.length - 1, i)) : -1;
    ls.forEach((a, j) => a.classList.toggle("act", j === act));
    if (scroll && act >= 0) ls[act].scrollIntoView({ block: "nearest" });
  }

  function closeAll() {
    hide(menu);
    sibs().forEach(hide);
    addr.querySelectorAll("[data-sib]").forEach((b) => b.setAttribute("aria-expanded", "false"));
    input.setAttribute("aria-expanded", "false");
    active = null;
    act = -1;
  }

  function openMain() {
    closeAll();
    show(menu, addr);
    input.setAttribute("aria-expanded", "true");
    active = menu;
    void render();
  }

  function typeMode() {
    addr.classList.add("typing");
    input.focus();
    openMain();
  }

  function leaveTyping() {
    input.value = "";
    addr.classList.remove("typing");
    closeAll();
  }

  function openSib(btn: HTMLElement) {
    const m = sibs().find((s) => s.dataset.sibmenu === btn.dataset.sib);
    if (!m) return;
    const wasOpen = m.matches(":popover-open");
    closeAll();
    if (wasOpen) return;
    show(m, addr, btn);
    btn.setAttribute("aria-expanded", "true");
    focusIn(m);
  }

  async function render() {
    const q = input.value.trim().toLowerCase();
    all.hidden = !!q;
    results.hidden = !q;
    if (!q) {
      const ls = links();
      const cur = ls.findIndex((a) => a.getAttribute("aria-current") === "page");
      mark(cur >= 0 ? cur : 0, false);
      // open on the page you're on, with its group's title above it and its sections below
      if (cur >= 0) {
        const head = ls[cur].previousElementSibling?.classList.contains("grp") ? ls[cur].previousElementSibling! : ls[cur];
        menu.scrollTop += head.getBoundingClientRect().top - menu.getBoundingClientRect().top - 3;
      }
      return;
    }
    const entries = await load();
    if (input.value.trim().toLowerCase() !== q) return;
    const score = (e: Entry) => {
      const l = e.label.toLowerCase();
      return l.startsWith(q) ? 0 : l.includes(q) ? 1 : l.replace(/^hydrant_/, "").startsWith(q) ? 0 : 2;
    };
    let html = "";
    const shown = new Set<string>();
    for (const [kind, title] of KINDS) {
      const hits = entries.filter((e) => e.kind === kind && score(e) < 2).sort((a, b) => score(a) - score(b)).slice(0, PER_KIND);
      if (!hits.length) continue;
      hits.forEach((e) => shown.add(e.url));
      html += `<div class="grp">${title}</div>` + hits.map((e) => row(e, q)).join("");
    }
    if (shown.size < FEW) {
      const text = await fullText(q, shown);
      if (input.value.trim().toLowerCase() !== q) return;
      if (text.length) html += `<div class="grp">in the text</div>` + text.map((e) => row(e, q)).join("");
    }
    results.innerHTML = html || `<div class="none">nothing about “${esc(q)}” anywhere in the docs</div>`;
    mark(0);
  }

  addr.addEventListener("click", (e) => {
    const t = e.target as HTMLElement;
    const sib = t.closest<HTMLElement>("[data-sib]");
    if (sib) {
      e.preventDefault();
      openSib(sib);
      return;
    }
    if (t.closest("[data-arrow]")) {
      if (menu.matches(":popover-open")) leaveTyping();
      else typeMode();
      return;
    }
    if (t.closest("[data-type]")) typeMode();
    // a link that stays on this page (a #hash) doesn't unload it, so close the menu it was in
    if (t.closest(".menu a")) setTimeout(leaveTyping);
  });
  input.addEventListener("focus", () => addr.classList.contains("typing") || typeMode());
  input.addEventListener("input", () => void render());
  input.addEventListener("blur", () => {
    // a click on a result lands after blur, so let it through before closing
    setTimeout(() => {
      if (!addr.contains(document.activeElement) && !input.value) leaveTyping();
    }, 150);
  });
  // tabbing out of a sibling menu closes it, like a click elsewhere does
  addr.addEventListener("focusout", (e) => {
    const from = (e.target as HTMLElement).closest<HTMLElement>("[data-sibmenu]");
    const to = e.relatedTarget as HTMLElement | null;
    if (from && to && !from.contains(to) && !to.closest("[data-sib]")) closeAll();
  });
  addr.addEventListener("mousemove", (e) => {
    const a = (e.target as HTMLElement).closest<HTMLAnchorElement>("a");
    if (a && active?.contains(a)) mark(links().indexOf(a), false);
  });
  document.addEventListener("click", (e) => {
    if (!addr.contains(e.target as Node)) input.value ? closeAll() : leaveTyping();
  });
  document.addEventListener("keydown", (e) => {
    const typing = /^(input|textarea|select)$/i.test((e.target as HTMLElement).tagName);
    if (e.key === "/" && !typing) {
      e.preventDefault();
      typeMode();
      return;
    }
    const sib = sibs().find((s) => s.matches(":popover-open"));
    if (sib && e.key === "Escape") {
      closeAll();
      addr.querySelector<HTMLElement>(`[data-sib="${sib.dataset.sibmenu}"]`)?.focus();
      return;
    }
    if (!active) return;
    if (e.key === "Escape") {
      leaveTyping();
      input.blur();
      return;
    }
    // while searching, focus stays in the field, so the highlight in the results is drawn here
    if (e.target !== input) return;
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      mark(act + (e.key === "ArrowDown" ? 1 : -1));
    } else if (e.key === "Enter") {
      const a = links()[act];
      if (a) {
        e.preventDefault();
        location.href = a.href;
        // a section of this page doesn't unload it
        leaveTyping();
      }
    }
  });
}

/** every menu in the address bar becomes a manual popover, its buttons no longer toggle it */
export function takeOver(root: HTMLElement) {
  root.querySelectorAll<HTMLElement>("[popover]").forEach((p) => (p.popover = "manual"));
  root.querySelectorAll("[popovertarget]").forEach((b) => b.removeAttribute("popovertarget"));
}

const anchoring = CSS.supports("anchor-name: --a");

/** opens a menu under the field, at its button's left edge (or the field's own) */
export function show(menu: HTMLElement, field: HTMLElement, btn?: HTMLElement) {
  if (!menu.matches(":popover-open")) menu.showPopover();
  // css anchors place it where they're supported, elsewhere it goes by hand
  if (anchoring) return;
  const f = field.getBoundingClientRect();
  const left = btn ? btn.getBoundingClientRect().left : f.left;
  Object.assign(menu.style, { margin: "0", top: `${f.bottom}px`, left: `${left}px`, right: btn ? "auto" : `${innerWidth - f.right}px`, bottom: "auto" });
}

function hide(menu: HTMLElement) {
  if (menu.matches(":popover-open")) menu.hidePopover();
}

function esc(s: string) {
  return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
}

function highlight(label: string, q: string) {
  const i = label.toLowerCase().indexOf(q);
  if (i < 0) return esc(label);
  return esc(label.slice(0, i)) + "<mark>" + esc(label.slice(i, i + q.length)) + "</mark>" + esc(label.slice(i + q.length));
}

function row(e: Entry, q: string) {
  const here = e.url === location.pathname ? ' aria-current="page"' : "";
  return `<a href="${esc(e.url)}"${here}><svg class="icon" width="16" height="16" aria-hidden="true"><use href="#${e.icon}"/></svg><span class="lbl">${highlight(e.label, q)}</span><span class="hint">${esc(e.hint)}</span></a>`;
}

export {};
