/* mgf-common docs explorer — client logic.
 *
 * Stateless single-page app. Hash-based routing (#/path/to/doc.md) so
 * URLs are shareable and the back button works.
 *
 * Public deps loaded from CDN in index.html: marked.js (markdown),
 * Prism.js (syntax highlighting). Both cached after first load.
 */

(() => {
  // ----- State -----
  let docsIndex = null;       // {groups: {category: [{path,title,...}]}, total}
  let pathIndex = new Map();  // path -> {title, category}
  let flatDocs = [];          // all docs in sidebar order; for j/k navigation
  let activePath = null;
  let searchTimer = null;
  let searchHighlight = -1;
  let gPrefix = false;        // true after a `g` keypress; resets after 1.2s

  const LS_THEME = 'mgf-docs:theme';
  const LS_COLLAPSED = 'mgf-docs:collapsed-groups';
  const LS_SIDEBAR = 'mgf-docs:sidebar';

  const sidebarEl = document.getElementById('sidebar');
  const contentEl = document.getElementById('content');
  const tocEl = document.getElementById('toc');
  const searchInput = document.getElementById('search-input');
  const searchResultsEl = document.getElementById('search-results');
  const themeToggleBtn = document.getElementById('theme-toggle');
  const helpBtn = document.getElementById('help-btn');
  const helpModal = document.getElementById('help-modal');
  const prismLight = document.getElementById('prism-light');
  const prismDark = document.getElementById('prism-dark');
  const sidebarToggleBtn = document.getElementById('sidebar-toggle');
  const printBtn = document.getElementById('print-btn');

  // ----- Theme management -----

  function currentTheme() {
    return document.documentElement.dataset.theme || 'light';
  }

  function applyTheme(theme) {
    document.documentElement.dataset.theme = theme;
    // Swap prism stylesheets so highlighted code matches the page theme.
    if (prismLight) prismLight.disabled = (theme === 'dark');
    if (prismDark) prismDark.disabled = (theme !== 'dark');
  }

  function toggleTheme() {
    const next = currentTheme() === 'dark' ? 'light' : 'dark';
    applyTheme(next);
    localStorage.setItem(LS_THEME, next);
  }

  // Apply stored theme's prism variant on initial load (the inline pre-paint
  // script in index.html only set the data-theme attribute).
  applyTheme(currentTheme());
  themeToggleBtn.addEventListener('click', toggleTheme);

  // ----- Sidebar collapse (whole-sidebar, not per-group) -----

  function isSidebarCollapsed() {
    return document.documentElement.dataset.sidebar === 'collapsed';
  }

  function toggleSidebar() {
    const next = isSidebarCollapsed() ? 'expanded' : 'collapsed';
    if (next === 'collapsed') {
      document.documentElement.dataset.sidebar = 'collapsed';
      localStorage.setItem(LS_SIDEBAR, 'collapsed');
    } else {
      delete document.documentElement.dataset.sidebar;
      localStorage.removeItem(LS_SIDEBAR);
    }
  }

  sidebarToggleBtn.addEventListener('click', toggleSidebar);

  // ----- Print -----

  printBtn.addEventListener('click', () => window.print());

  // ----- Collapsible sidebar groups -----

  function loadCollapsedGroups() {
    try { return new Set(JSON.parse(localStorage.getItem(LS_COLLAPSED) || '[]')); }
    catch { return new Set(); }
  }

  function saveCollapsedGroups(set) {
    localStorage.setItem(LS_COLLAPSED, JSON.stringify(Array.from(set)));
  }

  // ----- Markdown rendering -----

  function makeRenderer(currentDocPath) {
    const renderer = new marked.Renderer();
    const baseDir = currentDocPath.includes('/')
      ? currentDocPath.substring(0, currentDocPath.lastIndexOf('/'))
      : '';

    renderer.link = (href, title, text) => {
      let h = href, ti = title, tx = text;
      if (typeof href === 'object' && href !== null) {
        h = href.href; ti = href.title; tx = href.text || href.tokens?.map(t => t.text).join('') || '';
      }
      h = String(h || '');
      const titleAttr = ti ? ` title="${escapeHtml(ti)}"` : '';
      const safeText = typeof tx === 'string' ? tx : escapeHtml(String(tx));

      if (/^(https?:|mailto:)/.test(h)) {
        return `<a href="${escapeHtml(h)}" target="_blank" rel="noopener"${titleAttr}>${safeText}</a>`;
      }
      if (h.startsWith('#') && !h.startsWith('#/')) {
        return `<a href="${escapeHtml(h)}"${titleAttr}>${safeText}</a>`;
      }
      if (h.endsWith('.md') || h.includes('.md#')) {
        const resolved = resolvePath(baseDir, h);
        return `<a href="#/${escapeHtml(resolved)}"${titleAttr} data-doc-link>${safeText}</a>`;
      }
      return `<a href="${escapeHtml(h)}"${titleAttr} target="_blank" rel="noopener">${safeText}</a>`;
    };

    renderer.heading = (text, level, raw) => {
      let t = text, l = level, r = raw;
      if (typeof text === 'object' && text !== null) {
        t = text.text || (text.tokens?.map(tok => tok.text || tok.raw).join('') || '');
        l = text.depth || level;
        r = text.raw || t;
      }
      const id = slugify(typeof r === 'string' ? r : String(t));
      // Heading anchor link — visible on hover. Click → copy URL.
      const anchor = `<a class="heading-anchor" href="#${id}" data-anchor="${id}" title="Copy link to this section">#</a>`;
      return `<h${l} id="${id}">${anchor}${t}</h${l}>`;
    };

    return renderer;
  }

  function resolvePath(baseDir, href) {
    let frag = '';
    const hashIdx = href.indexOf('#');
    if (hashIdx >= 0) {
      frag = href.substring(hashIdx);
      href = href.substring(0, hashIdx);
    }
    if (href.startsWith('/')) {
      return href.replace(/^\/+/, '') + frag;
    }
    const parts = (baseDir ? baseDir.split('/') : []).concat(href.split('/'));
    const stack = [];
    for (const p of parts) {
      if (p === '' || p === '.') continue;
      if (p === '..') stack.pop();
      else stack.push(p);
    }
    return stack.join('/') + frag;
  }

  function slugify(s) {
    return String(s).toLowerCase()
      .replace(/[^\w\s-]/g, '').trim()
      .replace(/\s+/g, '-').replace(/-+/g, '-');
  }

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

  function fmtDate(unixSeconds) {
    if (!unixSeconds) return '';
    const d = new Date(unixSeconds * 1000);
    const yyyy = d.getFullYear();
    const mm = String(d.getMonth() + 1).padStart(2, '0');
    const dd = String(d.getDate()).padStart(2, '0');
    return `${yyyy}-${mm}-${dd}`;
  }

  function fmtRelative(unixSeconds) {
    if (!unixSeconds) return '';
    const diff = Date.now() / 1000 - unixSeconds;
    if (diff < 60) return 'just now';
    if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
    return fmtDate(unixSeconds);
  }

  // ----- Sidebar -----

  function commonDirPrefix(paths) {
    if (paths.length === 0) return '';
    if (paths.length === 1) {
      const idx = paths[0].lastIndexOf('/');
      return idx === -1 ? '' : paths[0].slice(0, idx + 1);
    }
    const split = paths.map(p => p.split('/'));
    const minLen = Math.min(...split.map(s => s.length));
    const common = [];
    // Stop at minLen - 1 because the final component is always the filename;
    // a "common prefix" is only the directory chain shared above it.
    for (let i = 0; i < minLen - 1; i++) {
      const part = split[0][i];
      if (split.every(s => s[i] === part)) common.push(part);
      else break;
    }
    return common.length ? common.join('/') + '/' : '';
  }

  async function loadIndex() {
    const res = await fetch('/api/index');
    docsIndex = await res.json();
    pathIndex = new Map();
    flatDocs = [];
    for (const [cat, docs] of Object.entries(docsIndex.groups)) {
      for (const d of docs) {
        pathIndex.set(d.path, { ...d, category: cat });
        flatDocs.push({ ...d, category: cat });
      }
    }
    renderSidebar();
  }

  function renderSidebar() {
    const collapsed = loadCollapsedGroups();
    sidebarEl.innerHTML = '';
    for (const [category, docs] of Object.entries(docsIndex.groups)) {
      const group = document.createElement('div');
      group.className = 'sidebar-group';
      if (collapsed.has(category)) group.classList.add('collapsed');
      group.dataset.category = category;

      const header = document.createElement('div');
      header.className = 'sidebar-group-title';
      header.innerHTML = `
        <span class="sidebar-group-caret">▼</span>
        <span>${escapeHtml(category)}</span>
        <span class="sidebar-group-count">${docs.length}</span>
      `;
      header.addEventListener('click', () => {
        group.classList.toggle('collapsed');
        const set = loadCollapsedGroups();
        if (group.classList.contains('collapsed')) set.add(category);
        else set.delete(category);
        saveCollapsedGroups(set);
      });
      group.appendChild(header);

      // Compute the longest common dirname across docs in this group. When
      // every doc in the group lives under the same directory, show that
      // directory once under the group title and strip it from each link
      // — drops the redundant "docs/recipes/" prefix from every row.
      const prefix = commonDirPrefix(docs.map(d => d.path));
      if (prefix) {
        const pathHint = document.createElement('div');
        pathHint.className = 'sidebar-group-path';
        pathHint.textContent = prefix;
        group.appendChild(pathHint);
      }

      const items = document.createElement('div');
      items.className = 'sidebar-group-items';
      for (const d of docs) {
        const a = document.createElement('a');
        a.className = 'sidebar-link';
        a.href = `#/${d.path}`;
        a.dataset.path = d.path;
        const t = document.createElement('span');
        t.textContent = d.title;
        a.appendChild(t);
        // Per-link path: only show when it adds info beyond the group prefix.
        // After stripping the prefix, paths that are bare filenames are
        // already implied by the group hint; only nested subpaths
        // (e.g. "web/README.md" inside docs/standards/) get a path line.
        const rel = prefix && d.path.startsWith(prefix)
          ? d.path.slice(prefix.length)
          : d.path;
        if (rel.includes('/')) {
          const p = document.createElement('span');
          p.className = 'sidebar-link-path';
          p.textContent = rel;
          a.appendChild(p);
        }
        items.appendChild(a);
      }
      group.appendChild(items);
      sidebarEl.appendChild(group);
    }
    highlightSidebar();
  }

  function highlightSidebar() {
    sidebarEl.querySelectorAll('.sidebar-link').forEach(a => {
      const isActive = a.dataset.path === activePath;
      a.classList.toggle('active', isActive);
      if (isActive) {
        // Make sure the parent group is expanded so the active link shows.
        const group = a.closest('.sidebar-group');
        if (group?.classList.contains('collapsed')) {
          group.classList.remove('collapsed');
          const set = loadCollapsedGroups();
          set.delete(group.dataset.category);
          saveCollapsedGroups(set);
        }
        const r = a.getBoundingClientRect();
        const sr = sidebarEl.getBoundingClientRect();
        if (r.top < sr.top || r.bottom > sr.bottom) {
          a.scrollIntoView({ block: 'center' });
        }
      }
    });
  }

  // ----- Doc rendering -----

  async function loadDoc(path) {
    activePath = path;
    highlightSidebar();
    contentEl.innerHTML = '<p style="color:var(--text-muted)">Loading…</p>';
    tocEl.innerHTML = '';

    const res = await fetch(`/api/doc?path=${encodeURIComponent(path)}`);
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      contentEl.innerHTML = `<h1>Not found</h1><p>${escapeHtml(err.error || `Could not load ${path}`)}</p>`;
      return;
    }
    const data = await res.json();
    const renderer = makeRenderer(data.path);
    marked.use({ renderer, gfm: true, breaks: false });

    data.reading_time = estimateReadingTime(data.content);
    const html = marked.parse(data.content);
    contentEl.innerHTML = renderDocMeta(data) + html + renderNavFooter(data.path);

    addCopyButtons();
    wireHeadingAnchors();
    wireNavFooter();
    Prism.highlightAllUnder(contentEl);
    buildToc();
    document.title = `${data.title} — mgf-common docs`;

    // Scroll to fragment if present in the URL.
    const frag = window.location.hash.split('#').slice(2).join('#');
    if (frag) {
      const el = document.getElementById(frag);
      if (el) {
        setTimeout(() => el.scrollIntoView({ behavior: 'auto', block: 'start' }), 50);
      }
    } else {
      window.scrollTo(0, 0);
    }
  }

  function renderDocMeta(data) {
    const fm = data.frontmatter || {};
    const cat = data.category || 'Other';

    // Status pill (only if frontmatter.status is set). The status value
    // becomes both visible text AND a CSS class suffix; sanitise the
    // class side so a weird status value can't break selectors.
    let statusHtml = '';
    if (fm.status) {
      const label = String(fm.status);
      const key = label.toLowerCase().replace(/[^a-z0-9]+/g, '-');
      statusHtml = `<span class="doc-meta-status doc-meta-status-${escapeHtml(key)}" title="Document status">${escapeHtml(label)}</span>`;
    }

    // Reading time.
    const reading = data.reading_time
      ? `<span class="reading-time" title="At ~200 words per minute">${data.reading_time} min read</span>`
      : '';

    // Edited date — prefer the curated `last_updated` from frontmatter
    // (the author's intent for "when did this doc become its current
    // version") over the file mtime (which reflects every cosmetic git
    // touch). Falls back to mtime if frontmatter is missing or the
    // value isn't a parseable date.
    let editedText = '';
    if (fm.last_updated) {
      const ts = parseFrontmatterDate(fm.last_updated);
      editedText = ts ? `edited ${fmtRelative(ts)}` : `edited ${escapeHtml(String(fm.last_updated))}`;
    } else if (data.mtime) {
      editedText = `edited ${fmtRelative(data.mtime)}`;
    }

    // Tag chips. Cap at 6 visible to keep the strip tidy; if there's
    // overflow, append "+N more" as a non-clickable hint.
    let tagsHtml = '';
    if (Array.isArray(fm.tags) && fm.tags.length > 0) {
      const max = 6;
      const visible = fm.tags.slice(0, max).map(t =>
        `<span class="doc-meta-tag">${escapeHtml(String(t))}</span>`
      ).join('');
      const overflow = fm.tags.length > max
        ? `<span class="doc-meta-tag doc-meta-tag-overflow">+${fm.tags.length - max} more</span>`
        : '';
      tagsHtml = `<span class="doc-meta-tags">${visible}${overflow}</span>`;
    }

    return `
      <div class="doc-meta">
        <span class="doc-meta-badge">${escapeHtml(cat)}</span>
        ${statusHtml}
        ${reading}
        ${editedText ? `<span class="doc-meta-sep">·</span><span>${editedText}</span>` : ''}
        ${tagsHtml}
        <span class="doc-meta-path">${escapeHtml(data.path)}</span>
      </div>
    `;
  }

  // ----- Frontmatter date parsing -----
  //
  // Accept "YYYY-MM-DD" and full ISO strings. Returns Unix seconds (so
  // it composes with fmtRelative which expects seconds) or null when
  // the value isn't a valid date.

  function parseFrontmatterDate(s) {
    if (!s) return null;
    const d = new Date(String(s));
    const ms = d.getTime();
    if (Number.isNaN(ms)) return null;
    return Math.floor(ms / 1000);
  }

  // ----- Reading-time estimate -----
  //
  // Strips markdown markup (code blocks, inline code, link URLs, syntax
  // characters) to get an approximate word count, then divides by a 200
  // wpm reading speed. Code-heavy docs over-read at this rate; prose-
  // heavy docs are about right. Returns a positive integer minute count.

  function estimateReadingTime(md) {
    if (!md) return 0;
    const stripped = md
      .replace(/```[\s\S]*?```/g, ' ')        // fenced code blocks
      .replace(/`[^`\n]*`/g, ' ')              // inline code
      .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → keep text
      .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')   // images
      .replace(/[#*_>~|`-]+/g, ' ')            // markdown markup chars
      .replace(/<[^>]+>/g, ' ')                // raw HTML
      .replace(/&[a-z]+;/g, ' ');              // HTML entities
    const words = stripped.split(/\s+/).filter(w => w.length > 0).length;
    return Math.max(1, Math.ceil(words / 200));
  }

  function renderNavFooter(currentPath) {
    const cat = pathIndex.get(currentPath)?.category;
    if (!cat) return '';
    const inCat = (docsIndex.groups[cat] || []);
    const idx = inCat.findIndex(d => d.path === currentPath);
    if (idx === -1) return '';
    const prev = idx > 0 ? inCat[idx - 1] : null;
    const next = idx < inCat.length - 1 ? inCat[idx + 1] : null;
    if (!prev && !next) return '';
    const prevHtml = prev
      ? `<a class="nav-footer-link prev" href="#/${prev.path}" data-doc-link>
           <div class="nav-footer-label">← Previous</div>
           <div class="nav-footer-title">${escapeHtml(prev.title)}</div>
         </a>`
      : `<div class="nav-footer-link disabled"></div>`;
    const nextHtml = next
      ? `<a class="nav-footer-link next" href="#/${next.path}" data-doc-link>
           <div class="nav-footer-label">Next →</div>
           <div class="nav-footer-title">${escapeHtml(next.title)}</div>
         </a>`
      : `<div class="nav-footer-link disabled"></div>`;
    return `<nav class="nav-footer">${prevHtml}${nextHtml}</nav>`;
  }

  function addCopyButtons() {
    contentEl.querySelectorAll('pre').forEach(pre => {
      if (pre.querySelector('.copy-btn')) return;
      const btn = document.createElement('button');
      btn.className = 'copy-btn';
      btn.type = 'button';
      btn.textContent = 'Copy';
      btn.addEventListener('click', () => {
        const code = pre.querySelector('code')?.textContent ?? pre.textContent;
        navigator.clipboard.writeText(code).then(() => {
          btn.textContent = 'Copied';
          btn.classList.add('copied');
          setTimeout(() => {
            btn.textContent = 'Copy';
            btn.classList.remove('copied');
          }, 1500);
        });
      });
      pre.appendChild(btn);
    });
  }

  function wireHeadingAnchors() {
    contentEl.querySelectorAll('.heading-anchor').forEach(a => {
      a.addEventListener('click', e => {
        e.preventDefault();
        const id = a.dataset.anchor;
        const url = `${location.origin}${location.pathname}#/${activePath}#${id}`;
        navigator.clipboard.writeText(url).then(() => {
          const orig = a.textContent;
          a.textContent = '✓';
          setTimeout(() => { a.textContent = orig; }, 1200);
        });
        // Also update URL bar to the anchor for non-clipboard fallback.
        history.replaceState(null, '', `#/${activePath}#${id}`);
        document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    });
  }

  function wireNavFooter() {
    // (no-op; the footer's [data-doc-link] anchors already navigate via hash)
  }

  function buildToc() {
    const headings = contentEl.querySelectorAll('h2, h3');
    if (headings.length < 2) {
      tocEl.innerHTML = '';
      return;
    }
    let html = '<div class="toc-title">On this page</div><ul>';
    headings.forEach(h => {
      const cls = h.tagName === 'H3' ? 'toc-h3' : '';
      // Strip the leading "#" anchor link from displayed TOC text.
      const text = (h.textContent || '').replace(/^#\s*/, '');
      html += `<li><a class="${cls}" href="#${h.id}">${escapeHtml(text)}</a></li>`;
    });
    html += '</ul>';
    tocEl.innerHTML = html;

    const tocLinks = tocEl.querySelectorAll('a');
    const headingArr = Array.from(headings);
    function onScroll() {
      const top = window.scrollY + 80;
      let active = headingArr[0];
      for (const h of headingArr) {
        if (h.offsetTop <= top) active = h;
      }
      tocLinks.forEach(a => a.classList.toggle(
        'active',
        a.getAttribute('href') === `#${active.id}`,
      ));
    }
    window.removeEventListener('scroll', window._tocSpy);
    window._tocSpy = onScroll;
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
  }

  // ----- Welcome page -----

  async function renderWelcome() {
    activePath = null;
    highlightSidebar();
    document.title = 'mgf-common docs';
    const res = await fetch('/api/welcome');
    const data = await res.json();

    const recentHtml = data.recent.map(r => `
      <li>
        <span><a href="#/${r.path}" data-doc-link>${escapeHtml(r.title)}</a><span class="recent-path">${escapeHtml(r.path)}</span></span>
        <span class="recent-time">${fmtRelative(r.mtime)}</span>
      </li>
    `).join('');

    let changelogHtml = '';
    if (data.changelog) {
      const cl = data.changelog;
      const dateLine = cl.date ? ` — ${cl.date}` : '';
      const md = marked.parse(cl.content);
      changelogHtml = `
        <div class="welcome-section">
          <h2>Latest release: ${cl.version}${dateLine}</h2>
          <div class="changelog-card markdown-body">${md}</div>
          <p style="margin-top:12px;font-size:13px;"><a href="#/CHANGELOG.md" data-doc-link>Open the full changelog →</a></p>
        </div>
      `;
    }

    contentEl.innerHTML = `
      <div class="welcome">
        <h1>mgf-common — docs explorer</h1>
        <p>
          ${data.total_docs} markdown files in this repository, browseable
          from the sidebar or via search (<kbd>Ctrl</kbd>+<kbd>K</kbd>).
        </p>

        <div class="welcome-section">
          <h2>Where to start, by audience</h2>
          <ul class="welcome-paths">
            <li><strong>Library users</strong>
              <a href="#/STARTHERE.md" data-doc-link>STARTHERE</a> →
              <a href="#/docs/public/tutorial.md" data-doc-link>Tutorial</a> →
              <a href="#/PUBLIC_API.md" data-doc-link>Public API</a></li>
            <li><strong>Library developers</strong>
              <a href="#/docs/internal/ARCHITECTURE.md" data-doc-link>Architecture</a> →
              <a href="#/docs/internal/CI_STARTHERE.md" data-doc-link>CI runbook</a> →
              <a href="#/CONTRIBUTING.md" data-doc-link>Contributing</a></li>
            <li><strong>Engineering managers</strong>
              <a href="#/docs/standards/README.md" data-doc-link>Standards index</a> →
              <a href="#/CHANGELOG.md" data-doc-link>Changelog</a> →
              <a href="#/FEEDBACK.md" data-doc-link>Open feedback</a></li>
          </ul>
        </div>

        ${changelogHtml}

        <div class="welcome-section">
          <h2>Recently modified</h2>
          <ul class="recent-list">${recentHtml}</ul>
        </div>

        <div class="welcome-section">
          <h2>Tips</h2>
          <p style="font-size:14px;color:var(--text-muted);">
            Press <kbd>?</kbd> to see all keyboard shortcuts.
            Click <kbd>#</kbd> next to any heading to copy a sharable link.
            Use <kbd>[</kbd> / <kbd>]</kbd> to walk linearly through a category.
          </p>
        </div>
      </div>
    `;
    tocEl.innerHTML = '';
    window.scrollTo(0, 0);
  }

  // ----- Search -----

  function debounce(fn, ms) {
    return (...args) => {
      clearTimeout(searchTimer);
      searchTimer = setTimeout(() => fn(...args), ms);
    };
  }

  const runSearch = debounce(async (query) => {
    if (!query.trim()) {
      searchResultsEl.hidden = true;
      searchResultsEl.innerHTML = '';
      return;
    }
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await res.json();
    renderSearchResults(query, data.results);
  }, 150);

  function renderSearchResults(query, results) {
    searchResultsEl.hidden = false;
    searchHighlight = -1;
    if (results.length === 0) {
      searchResultsEl.innerHTML = '<div class="search-empty">No matches</div>';
      return;
    }
    const lq = query.toLowerCase();
    let html = '';
    results.forEach((r, idx) => {
      const snippetHtml = (r.snippets || [r.snippet || '']).map(s =>
        `<div class="search-snippet">${highlightInText(s, lq)}</div>`
      ).join('');
      const countText = r.match_count > 1 ? `${r.match_count} matches` : '1 match';
      html += `<div class="search-doc" data-idx="${idx}" data-path="${escapeHtml(r.path)}">
        <div class="search-doc-header">
          <div>
            <div class="search-doc-title">${escapeHtml(r.title)}</div>
            <div class="search-doc-path">${escapeHtml(r.category)} · ${escapeHtml(r.path)}</div>
          </div>
          <span class="search-doc-count">${countText}</span>
        </div>
        <div class="search-snippets">${snippetHtml}</div>
      </div>`;
    });
    searchResultsEl.innerHTML = html;
    searchResultsEl.querySelectorAll('.search-doc').forEach(el => {
      const go = () => {
        const path = el.dataset.path;
        window.location.hash = `#/${path}`;
        searchInput.value = '';
        searchResultsEl.hidden = true;
      };
      el.querySelector('.search-doc-header').addEventListener('click', go);
    });
  }

  function highlightInText(text, lowerQuery) {
    const lower = text.toLowerCase();
    let result = '';
    let i = 0;
    while (i < text.length) {
      const idx = lower.indexOf(lowerQuery, i);
      if (idx === -1) {
        result += escapeHtml(text.substring(i));
        break;
      }
      result += escapeHtml(text.substring(i, idx));
      result += '<mark>' + escapeHtml(text.substring(idx, idx + lowerQuery.length)) + '</mark>';
      i = idx + lowerQuery.length;
    }
    return result;
  }

  searchInput.addEventListener('input', e => runSearch(e.target.value));

  searchInput.addEventListener('keydown', e => {
    const items = searchResultsEl.querySelectorAll('.search-doc');
    if (e.key === 'Escape') {
      searchInput.value = '';
      searchResultsEl.hidden = true;
      searchInput.blur();
      return;
    }
    if (e.key === 'ArrowDown' && items.length) {
      e.preventDefault();
      searchHighlight = Math.min(searchHighlight + 1, items.length - 1);
      updateActiveResult(items);
    } else if (e.key === 'ArrowUp' && items.length) {
      e.preventDefault();
      searchHighlight = Math.max(searchHighlight - 1, 0);
      updateActiveResult(items);
    } else if (e.key === 'Enter' && searchHighlight >= 0 && items[searchHighlight]) {
      e.preventDefault();
      items[searchHighlight].querySelector('.search-doc-header').click();
    }
  });

  function updateActiveResult(items) {
    items.forEach((el, idx) => {
      const header = el.querySelector('.search-doc-header');
      header.classList.toggle('active', idx === searchHighlight);
      if (idx === searchHighlight) header.scrollIntoView({ block: 'nearest' });
    });
  }

  document.addEventListener('click', e => {
    if (!searchResultsEl.contains(e.target) && e.target !== searchInput) {
      searchResultsEl.hidden = true;
    }
  });

  // ----- Help modal -----

  function openHelp() { helpModal.hidden = false; }
  function closeHelp() { helpModal.hidden = true; }
  helpBtn.addEventListener('click', openHelp);
  // Single delegated listener so clicks on the × button (or its text node)
  // and clicks on the dim backdrop both close, while clicks INSIDE the
  // panel (selecting text, clicking a kbd element) do not.
  helpModal.addEventListener('click', e => {
    if (e.target.closest('.modal-close')) { closeHelp(); return; }
    if (e.target.classList.contains('modal-backdrop')) { closeHelp(); return; }
  });

  // ----- Keyboard shortcuts -----

  function isTyping() {
    const t = document.activeElement;
    return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
  }

  function navigateFlat(delta) {
    if (flatDocs.length === 0) return;
    const idx = activePath ? flatDocs.findIndex(d => d.path === activePath) : -1;
    let next = idx + delta;
    if (next < 0) next = 0;
    if (next >= flatDocs.length) next = flatDocs.length - 1;
    if (idx !== next) window.location.hash = `#/${flatDocs[next].path}`;
  }

  function navigateInCategory(delta) {
    if (!activePath) return;
    const cat = pathIndex.get(activePath)?.category;
    if (!cat) return;
    const inCat = docsIndex.groups[cat] || [];
    const idx = inCat.findIndex(d => d.path === activePath);
    if (idx === -1) return;
    const next = idx + delta;
    if (next >= 0 && next < inCat.length) {
      window.location.hash = `#/${inCat[next].path}`;
    }
  }

  document.addEventListener('keydown', e => {
    // Ctrl+K / Cmd+K — search (always active, even in inputs)
    if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
      e.preventDefault();
      searchInput.focus();
      searchInput.select();
      return;
    }

    // Escape closes the help modal regardless.
    if (e.key === 'Escape' && !helpModal.hidden) {
      closeHelp();
      return;
    }

    if (isTyping()) return;

    // `g` prefix for two-key shortcuts — `g h`, `g s`.
    if (gPrefix) {
      gPrefix = false;
      if (e.key === 'h') {
        e.preventDefault();
        window.location.hash = '#/';
        renderWelcome();
        return;
      }
      if (e.key === 's') {
        e.preventDefault();
        searchInput.focus();
        searchInput.select();
        return;
      }
      return;
    }

    if (e.key === 'g') {
      gPrefix = true;
      setTimeout(() => { gPrefix = false; }, 1200);
      return;
    }

    if (e.key === '?') { e.preventDefault(); openHelp(); return; }
    if (e.key === 't') { e.preventDefault(); toggleTheme(); return; }
    if (e.key === 's') { e.preventDefault(); toggleSidebar(); return; }
    if (e.key === 'j' || e.key === 'ArrowDown') {
      // Don't hijack scroll if user is mid-page; but if no doc loaded, skip.
      if (e.key === 'j') { e.preventDefault(); navigateFlat(+1); }
      return;
    }
    if (e.key === 'k' || e.key === 'ArrowUp') {
      if (e.key === 'k') { e.preventDefault(); navigateFlat(-1); }
      return;
    }
    if (e.key === '[') { e.preventDefault(); navigateInCategory(-1); return; }
    if (e.key === ']') { e.preventDefault(); navigateInCategory(+1); return; }
  });

  // ----- Routing -----

  function onHashChange() {
    const hash = window.location.hash;
    if (!hash || hash === '#' || hash === '#/') {
      renderWelcome();
      return;
    }
    if (hash.startsWith('#/')) {
      const path = hash.substring(2).split('#')[0];
      if (path && path !== activePath) {
        loadDoc(path);
      } else if (path === activePath) {
        const frag = hash.split('#').slice(2).join('#');
        if (frag) {
          const el = document.getElementById(frag);
          if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
      }
    }
  }

  window.addEventListener('hashchange', onHashChange);

  // ----- Boot -----

  loadIndex().then(() => {
    if (window.location.hash.startsWith('#/') && window.location.hash !== '#/') {
      onHashChange();
    } else {
      renderWelcome();
    }
  });
})();
