(() => {
  const API_BASES = ['https://dewemoji.com/v1', 'http://127.0.0.1:8000/v1'];
  const SEARCH_PATHS = ['/extension/search', '/emojis', '/search'];
  const FRONTEND_ID = 'apk-main-v1';
  const PAGE_LIMIT = 50;
  const STORE_FALLBACK_KEY = 'dewemoji_apk_companion_state_v2';
  const TOAST_MS = 2200;
  const LONG_PRESS_MS = 560;
  const OVERLAY_MODE = (() => {
    try {
      return new URLSearchParams(window.location.search).get('mode') === 'overlay';
    } catch (_) {
      return false;
    }
  })();
  const TONE_CHAR_BY_INDEX = {
    1: '\u{1F3FB}',
    2: '\u{1F3FC}',
    3: '\u{1F3FD}',
    4: '\u{1F3FE}',
    5: '\u{1F3FF}',
  };
  const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]);
  const MODIFIABLE_BASES = new Set([
    0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476,
    0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926,
  ]);
  const NOT_TONEABLE_BASES = new Set([
    0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4,
    0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6,
    0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2,
  ]);
  const NON_TONEABLE_NAME_PATTERNS = [
    /\bspeaking head\b/i,
    /\bbust in silhouette\b/i,
    /\bbusts in silhouette\b/i,
    /\bfootprints?\b/i,
    /\bfingerprint\b/i,
    /\bfamily\b/i,
    /\bpeople hugging\b/i,
    /\bpeople with bunny ears\b/i,
    /\bmen with bunny ears\b/i,
    /\bwomen with bunny ears\b/i,
    /\bpeople wrestling\b/i,
    /\bpeople fencing\b/i,
    /\bperson fencing\b/i,
    /\bmen wrestling\b/i,
    /\bwomen wrestling\b/i,
    /\bmerman\b/i,
    /\bmermaid\b/i,
    /\bdeaf man\b/i,
    /\bdeaf woman\b/i,
    /\bperson running facing right\b/i,
    /\bperson walking facing right\b/i,
    /\bperson kneeling facing right\b/i,
  ];
  const ROLE_TONEABLE_NAME_PATTERNS = [
    /\btechnologist\b/i,
    /\bscientist\b/i,
    /\boffice worker\b/i,
    /\bfactory worker\b/i,
    /\bmechanic\b/i,
    /\bcook\b/i,
    /\bfarmer\b/i,
    /\bjudge\b/i,
    /\bteacher\b/i,
    /\bstudent\b/i,
    /\bhealth worker\b/i,
    /\bsinger\b/i,
    /\bastronaut\b/i,
    /\bfirefighter\b/i,
    /\bfacepalming\b/i,
    /\bdancing\b/i,
    /\bdetective\b/i,
    /\bfencing\b/i,
    /\bbouncing ball\b/i,
    /\blifting weights\b/i,
    /\bwrestling\b/i,
  ];
  const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']);
  const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [
    /\bmen with bunny ears\b/i,
    /\bwomen with bunny ears\b/i,
    /\bperson fencing\b/i,
    /\bmen wrestling\b/i,
    /\bwomen wrestling\b/i,
  ];
  const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([
    'men-with-bunny-ears',
    'women-with-bunny-ears',
    'person-fencing',
    'men-wrestling',
    'women-wrestling',
  ]);
  const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([
    'men-with-bunny-ears',
    'women-with-bunny-ears',
    'person-fencing',
    'men-wrestling',
    'women-wrestling',
  ]);
  const IS_ANDROID_RENDERER = (() => {
    try {
      if (/Android/i.test(navigator.userAgent || '')) return true;
      const platform = window.Capacitor?.getPlatform?.();
      return platform === 'android';
    } catch (_) {
      return /Android/i.test(navigator.userAgent || '');
    }
  })();

  const state = {
    apiBase: API_BASES[0],
    authApiKey: '',
    authTier: 'guest',
    authEmail: '',
    authName: '',
    bubbleEnabled: false,
    theme: '',
    showEmojiNames: false,
    toneLock: false,
    preferredToneIndex: 0,
    categoriesMap: {},
    page: 1,
    total: 0,
    hasMore: false,
    loading: false,
    requestSeq: 0,
    searchDebounce: null,
    bubbleStatus: {
      platform: 'web',
      pluginAvailable: false,
      overlayGranted: false,
      notificationsEnabled: false,
      running: false,
    },
  };

  const els = {
    root: document.documentElement,
    body: document.body,
    q: document.getElementById('q'),
    clear: document.getElementById('clear'),
    theme: document.getElementById('theme'),
    settings: document.getElementById('settings'),
    cat: document.getElementById('cat'),
    sub: document.getElementById('sub'),
    list: document.getElementById('list'),
    autoloadSentinel: document.getElementById('autoload-sentinel'),
    more: document.getElementById('more'),
    count: document.getElementById('count'),
    ver: document.getElementById('ver'),
    toast: document.getElementById('toast'),
    statusTag: document.getElementById('dewemoji-status'),

    sheetBackdrop: document.getElementById('sheet-backdrop'),
    settingsSheet: document.getElementById('settings-sheet'),
    sheetClose: document.getElementById('sheet-close'),
    tabs: Array.from(document.querySelectorAll('#settings-sheet .tab')),
    panes: Array.from(document.querySelectorAll('#settings-sheet .tabpane')),
    tabGeneral: document.getElementById('tab-general'),
    tabAccount: document.getElementById('tab-account'),
    tabBubble: document.getElementById('tab-bubble'),

    refresh: document.getElementById('refresh'),
    apiStatus: document.getElementById('api-status'),
    sessionSummary: document.getElementById('session-summary'),
    showEmojiNamesToggle: document.getElementById('show-emoji-names-toggle'),
    toneLockToggle: document.getElementById('tone-lock-toggle'),
    preferredSkinToneRadios: Array.from(document.querySelectorAll('input[name="preferredSkinTone"]')),

    accountConnectForm: document.getElementById('account-connect-form'),
    accountConnected: document.getElementById('account-connected'),
    accountEmail: document.getElementById('account-email'),
    accountPassword: document.getElementById('account-password'),
    accountLogin: document.getElementById('account-login'),
    accountLogout: document.getElementById('account-logout'),
    accountStatus: document.getElementById('account-status'),
    accountGreeting: document.getElementById('account-greeting'),

    bubblePlatformStatus: document.getElementById('bubble-platform-status'),
    bubbleOverlayStatus: document.getElementById('bubble-overlay-status'),
    bubbleNotifyStatus: document.getElementById('bubble-notify-status'),
    bubbleRunningStatus: document.getElementById('bubble-running-status'),
    bubbleEnableBtn: document.getElementById('bubble-enable-btn'),
    bubbleDisableBtn: document.getElementById('bubble-disable-btn'),
    bubbleOverlaySettingsBtn: document.getElementById('bubble-overlay-settings-btn'),
    bubbleNotifySettingsBtn: document.getElementById('bubble-notify-settings-btn'),
    bubbleHelp: document.getElementById('bubble-help'),
  };

  let toastTimer = null;
  let autoObserver = null;
  let autoLoadBusy = false;
  let autoLoadRaf = 0;
  let toneRowEl = null;
  let toneRowCard = null;
  let toneOutsideHandler = null;
  let overlayHeightReportRaf = 0;
  let overlayHeightObserver = null;

  function getCapacitorPlugin() {
    try {
      const cap = window.Capacitor;
      if (!cap) return null;
      if (cap.Plugins && cap.Plugins.DewemojiOverlay) return cap.Plugins.DewemojiOverlay;
      if (typeof cap.registerPlugin === 'function') return cap.registerPlugin('DewemojiOverlay');
    } catch (err) {
      console.warn('Failed to init plugin', err);
    }
    return null;
  }

  const overlayPlugin = getCapacitorPlugin();

  function platform() {
    try {
      return window.Capacitor?.getPlatform?.() || 'web';
    } catch (_) {
      return 'web';
    }
  }

  function isAndroid() {
    return platform() === 'android';
  }

  function orderedApiBases() {
    const first = (state.apiBase || '').replace(/\/+$/, '');
    const seen = new Set();
    return [first, ...API_BASES]
      .map((v) => String(v || '').trim().replace(/\/+$/, ''))
      .filter((v) => {
        if (!v || seen.has(v)) return false;
        seen.add(v);
        return true;
      });
  }

  function showToast(message) {
    if (!els.toast) return;
    els.toast.textContent = String(message || '');
    els.toast.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => {
      els.toast.classList.remove('show');
      scheduleOverlayPanelHeightReport();
    }, TOAST_MS);
    scheduleOverlayPanelHeightReport();
  }

  function hasOverlayPanelHeightBridge() {
    return OVERLAY_MODE && typeof window.DewemojiOverlayHost?.setContentHeight === 'function';
  }

  function computeOverlayContentHeightCssPx() {
    const body = document.body;
    let flowBottom = 0;
    if (body?.children?.length) {
      Array.from(body.children).forEach((el) => {
        if (!(el instanceof HTMLElement)) return;
        const cs = window.getComputedStyle(el);
        if (cs.display === 'none' || cs.visibility === 'hidden') return;
        if (cs.position === 'fixed') return;
        const top = Number(el.offsetTop || 0);
        const height = Number(el.offsetHeight || 0) || Math.ceil(el.getBoundingClientRect().height || 0);
        const marginBottom = parseFloat(cs.marginBottom || '0') || 0;
        flowBottom = Math.max(flowBottom, Math.ceil(top + height + marginBottom));
      });
    }
    const heights = [flowBottom];
    if (els.toast && els.toast.classList.contains('show')) {
      const r = els.toast.getBoundingClientRect();
      heights.push(Math.ceil(r.bottom + window.scrollY + 8));
    }
    return Math.max(0, ...heights.map((v) => Number(v) || 0));
  }

  function reportOverlayPanelHeightNow() {
    if (!hasOverlayPanelHeightBridge()) return;
    try {
      const cssPx = Math.ceil(computeOverlayContentHeightCssPx());
      if (cssPx > 0) window.DewemojiOverlayHost.setContentHeight(cssPx);
    } catch (err) {
      console.debug('overlay height report failed', err);
    }
  }

  function scheduleOverlayPanelHeightReport() {
    if (!hasOverlayPanelHeightBridge()) return;
    if (overlayHeightReportRaf) cancelAnimationFrame(overlayHeightReportRaf);
    overlayHeightReportRaf = requestAnimationFrame(() => {
      overlayHeightReportRaf = 0;
      reportOverlayPanelHeightNow();
    });
  }

  function setupOverlayModeBehavior() {
    if (!OVERLAY_MODE) return;
    els.root?.classList.add('overlay-mode');
    els.body?.classList.add('overlay-mode');
    if (els.theme) {
      els.theme.hidden = true;
      els.theme.setAttribute('aria-hidden', 'true');
      els.theme.tabIndex = -1;
    }
    if (els.settings) {
      els.settings.textContent = '↗';
      els.settings.title = 'Open full app';
      els.settings.setAttribute('aria-label', 'Open full app');
    }
    if (els.settingsSheet) {
      els.settingsSheet.hidden = true;
      els.settingsSheet.classList.remove('show');
    }
    if (els.sheetBackdrop) {
      els.sheetBackdrop.hidden = true;
      els.sheetBackdrop.classList.remove('show');
    }
    if (!hasOverlayPanelHeightBridge() || !els.body) return;
    if (!overlayHeightObserver) {
      overlayHeightObserver = new MutationObserver(() => scheduleOverlayPanelHeightReport());
      overlayHeightObserver.observe(els.body, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true,
      });
    }
    window.addEventListener('resize', scheduleOverlayPanelHeightReport);
    window.addEventListener('load', scheduleOverlayPanelHeightReport, { once: true });
    setTimeout(scheduleOverlayPanelHeightReport, 0);
    setTimeout(scheduleOverlayPanelHeightReport, 120);
  }

  async function copyTextToClipboard(text, {
    successMessage = 'Copied.',
    failMessage = 'Copy failed. Try again.',
  } = {}) {
    const value = String(text || '');
    if (!value) return false;
    try {
      if (typeof window.DewemojiOverlayHost?.copyText === 'function') {
        const ok = !!window.DewemojiOverlayHost.copyText(value);
        if (!ok) throw new Error('overlay_copy_failed');
      } else if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(value);
      } else {
        const ta = document.createElement('textarea');
        ta.value = value;
        ta.style.position = 'fixed';
        ta.style.left = '-9999px';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
      }
      showToast(successMessage);
      return true;
    } catch (_) {
      showToast(failMessage);
      return false;
    }
  }

  function setApiStatus(message) {
    if (els.apiStatus) els.apiStatus.textContent = String(message || '');
  }

  function setVersionBadge() {
    if (!els.ver) return;
    if (OVERLAY_MODE) {
      els.ver.textContent = 'Quick · Bubble';
      return;
    }
    const p = platform();
    els.ver.textContent = p === 'android' ? 'APK · Android' : 'APK · Preview';
  }

  function tierLabel(tier) {
    if (tier === 'personal' || tier === 'pro') return 'Personal';
    if (tier === 'free') return 'Free';
    return 'Guest';
  }

  function setStatusTag() {
    if (!els.statusTag) return;
    const tier = state.authApiKey ? state.authTier : 'guest';
    const label = tierLabel(tier);
    els.statusTag.textContent = label;
    els.statusTag.classList.remove('pro', 'free');
    if (label === 'Personal') els.statusTag.classList.add('pro');
    else els.statusTag.classList.add('free');
  }

  function applyTheme(theme) {
    const next = theme === 'light' ? 'light' : 'dark';
    state.theme = next;
    const nextClass = next === 'light' ? 'theme-light' : 'theme-dark';
    [els.root, els.body].forEach((node) => {
      if (!node?.classList) return;
      node.classList.remove('theme-light', 'theme-dark');
      node.classList.add(nextClass);
    });
    if (els.theme) els.theme.textContent = next === 'light' ? '🌞' : '🌙';
  }

  function applyEmojiNameVisibility(showNames) {
    state.showEmojiNames = !!showNames;
    els.body.classList.toggle('emoji-names-hidden', !state.showEmojiNames);
    if (els.showEmojiNamesToggle) {
      els.showEmojiNamesToggle.checked = state.showEmojiNames;
    }
  }

  function normalizePreferredToneIndex(value) {
    const num = Number(value);
    if (!Number.isFinite(num)) return 0;
    return Math.max(0, Math.min(5, Math.trunc(num)));
  }

  function stripSkinTone(emojiChar) {
    return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
  }

  function containsZWJ(s) {
    return /\u200D/.test(String(s || ''));
  }

  function countHumans(s) {
    let n = 0;
    for (const ch of Array.from(String(s || ''))) {
      const cp = ch.codePointAt(0);
      if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n += 1;
    }
    return n;
  }

  function canApplyToneTo(s) {
    if (!s) return false;
    try {
      const cp0 = String(s).codePointAt(0);
      if (NOT_TONEABLE_BASES.has(cp0)) return false;
    } catch (_) {}
    if (!containsZWJ(s)) return true;
    return countHumans(s) <= 2;
  }

  function isNameNonToneable(item) {
    const name = String(item?.name || '').toLowerCase();
    return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name));
  }

  function isRoleToneable(item) {
    const name = String(item?.name || '').toLowerCase();
    return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name));
  }

  // Site/extension parity: only treat a subset of "variants" as valid skin-tone
  // variants. Some API rows include variants that are not safe to tone-lock in UI.
  function isToneAllowedForItem(item) {
    if (!item?.supports_skin_tone) return false;

    const category = String(item?.category || '').toLowerCase();
    if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false;
    if (isNameNonToneable(item)) return false;

    if (IS_ANDROID_RENDERER) {
      const name = String(item?.name || '').toLowerCase();
      const slug = String(item?.slug || '').toLowerCase();
      const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug);
      if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false;
      if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(name))) return false;
    }

    const base = stripSkinTone(item?.emoji_base || item?.emoji || '');
    if (!base || !canApplyToneTo(base)) return false;

    const name = String(item?.name || '').toLowerCase();
    const looksGenderedRole =
      category === 'people & body' &&
      (name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name));
    if (looksGenderedRole && !isRoleToneable(item)) return false;

    return true;
  }

  function toCodePoints(str) {
    const out = [];
    for (const ch of String(str || '')) out.push(ch.codePointAt(0));
    return out;
  }

  function fromCodePoints(arr) {
    try {
      return String.fromCodePoint(...arr);
    } catch (_) {
      return '';
    }
  }

  function toCodePointHexSequence(str) {
    const cps = toCodePoints(str);
    return cps.map((cp) => cp.toString(16)).join('-');
  }

  function twemojiCodePoint(str) {
    try {
      const cp = window.twemoji?.convert?.toCodePoint?.(String(str || ''));
      if (cp) return cp;
    } catch (_) {}
    return toCodePointHexSequence(str);
  }

  function twemojiSvgUrlFromCodepoints(cp) {
    return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
  }

  function twemojiCdnjsSvgUrlFromCodepoints(cp) {
    return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${cp}.svg`;
  }

  // Use image fallback for toned emoji glyphs in APK/preview to avoid color-rectangle
  // artifacts on platforms/fonts that don't compose skin-tone sequences correctly.
  function shouldUseToneImageFallback(_item, emojiStr) {
    const glyph = String(emojiStr || '');
    if (!glyph) return false;
    return /[\u{1F3FB}-\u{1F3FF}]/u.test(glyph) || /\u200D/u.test(glyph);
  }

  function renderEmojiGlyph(container, emojiStr, { item = null } = {}) {
    if (!container) return;
    const glyph = String(emojiStr || '');
    container.__dewemojiRenderedGlyph = glyph;
    if (!glyph) {
      container.__dewemojiRenderStatus = 'empty';
      container.textContent = '';
      return;
    }

    if (!shouldUseToneImageFallback(item, glyph)) {
      container.__dewemojiRenderStatus = 'text-direct';
      container.__dewemojiFallbackCp = '';
      container.textContent = glyph;
      return;
    }

    const cp = twemojiCodePoint(glyph);
    if (!cp) {
      container.__dewemojiRenderStatus = 'text-no-codepoint';
      container.textContent = glyph;
      return;
    }

    // Render text immediately so the UI feels instant; swap to SVG once loaded.
    const requestKey = `${glyph}|${cp}`;
    container.__dewemojiFallbackKey = requestKey;
    container.__dewemojiFallbackCp = cp;
    container.__dewemojiRenderStatus = 'image-loading';
    container.textContent = glyph;

    const img = new Image();
    img.alt = glyph;
    img.draggable = false;
    img.decoding = 'async';
    img.loading = 'lazy';
    img.className = 'emoji-fallback-img';
    img.style.width = '1em';
    img.style.height = '1em';
    img.style.objectFit = 'contain';
    img.style.display = 'block';
    img.onload = () => {
      if (!container.isConnected) return;
      if (container.__dewemojiFallbackKey !== requestKey) return;
      container.__dewemojiRenderStatus = img.__dewemojiFallbackStage === 'secondary' ? 'image-secondary' : 'image-primary';
      container.replaceChildren(img);
    };
    // Twemoji first, then a second Twemoji CDN mirror, then plain text fallback.
    img.__dewemojiFallbackStage = 'primary';
    img.onerror = () => {
      img.onerror = () => {
        if (container.__dewemojiFallbackKey === requestKey) {
          container.__dewemojiRenderStatus = 'image-failed';
          container.textContent = glyph;
        }
      };
      img.__dewemojiFallbackStage = 'secondary';
      img.src = twemojiCdnjsSvgUrlFromCodepoints(cp);
    };
    img.src = twemojiSvgUrlFromCodepoints(cp);
  }

  function applyToneAfterFirstCodePoint(emojiChar, toneChar) {
    if (!emojiChar || !toneChar) return emojiChar;
    const toneCp = String(toneChar).codePointAt(0);
    if (!TONE_CPS.has(toneCp)) return emojiChar;

    const cps = toCodePoints(emojiChar);
    if (!cps.length) return emojiChar;
    if (cps.some((cp) => TONE_CPS.has(cp))) return emojiChar;

    const VS16 = 0xFE0F;
    let pos = 1;
    let rightStart = pos;
    if (cps[pos] === VS16) {
      rightStart = pos + 1;
    }
    const left = cps.slice(0, pos);
    left.push(toneCp);
    return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar;
  }

  // Ported from the website tone renderer. It avoids Android/WebView cases where
  // VS16 before the modifier renders a separate tone swatch square.
  function applyToneSmart(emojiChar, toneChar) {
    if (!emojiChar || !toneChar) return emojiChar;
    const toneCp = String(toneChar).codePointAt(0);
    if (!TONE_CPS.has(toneCp)) return emojiChar;

    const cps = toCodePoints(emojiChar);
    for (const cp of cps) {
      if (TONE_CPS.has(cp)) return emojiChar;
    }

    const VS16 = 0xFE0F;
    const ZWJ = 0x200D;

    const idxs = [];
    for (let i = 0; i < cps.length; i += 1) {
      if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i);
    }

    if (idxs.length === 0) {
      if (cps.includes(ZWJ)) return emojiChar;
      let pos = 1;
      let rightStart = pos;
      if (cps[1] === VS16) {
        rightStart = 2;
      }
      const left = cps.slice(0, pos);
      left.push(toneCp);
      return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar;
    }

    if (idxs.length === 2) {
      const out = cps.slice();
      const insertAfter = (baseIdx) => {
        let pos = baseIdx + 1;
        if (out[pos] === VS16) out.splice(pos, 1);
        out.splice(pos, 0, toneCp);
      };
      insertAfter(idxs[1]);
      insertAfter(idxs[0]);
      return fromCodePoints(out) || emojiChar;
    }

    let pos = idxs[0] + 1;
    let rightStart = pos;
    if (cps[pos] === VS16) {
      rightStart = pos + 1;
    }
    const left = cps.slice(0, pos);
    left.push(toneCp);
    return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar;
  }

  function applyToneSettings(toneLock, preferredToneIndex) {
    state.toneLock = !!toneLock;
    state.preferredToneIndex = normalizePreferredToneIndex(preferredToneIndex);
    if (state.toneLock) {
      closeToneRow();
    }
    if (els.toneLockToggle) els.toneLockToggle.checked = state.toneLock;
    if (els.preferredSkinToneRadios?.length) {
      for (const radio of els.preferredSkinToneRadios) {
        radio.checked = Number(radio.value) === state.preferredToneIndex;
      }
    }
  }

  function hasToneVariants(item) {
    if (!isToneAllowedForItem(item)) return false;
    return !!(item && Array.isArray(item.variants) && item.variants.length > 0);
  }

  function toneGlyphForIndex(item, toneIndex) {
    const idx = normalizePreferredToneIndex(toneIndex);
    const base = stripSkinTone(item?.emoji_base || item?.emoji || '');
    if (!hasToneVariants(item)) return String(base || item?.emoji || item?.emoji_base || '');
    if (idx <= 0) {
      return String(base || item?.emoji || item?.emoji_base || item?.variants?.[0] || '');
    }

    const toneChar = TONE_CHAR_BY_INDEX[idx];
    if (base && toneChar) {
      const smart = applyToneSmart(base, toneChar);
      // Prefer the website renderer result when it produced a toned sequence.
      if (smart && smart !== base) return String(smart);

      // Generic fallback for gender/ZWJ person sequences not covered by the
      // narrow MODIFIABLE_BASES set copied from the site.
      const generic = applyToneAfterFirstCodePoint(base, toneChar);
      if (generic && generic !== base) return String(generic);
    }

    return String(item?.variants?.[idx - 1] || base || item?.emoji || item?.emoji_base || '');
  }

  function glyphForCardDisplay(item) {
    if (state.toneLock && hasToneVariants(item)) {
      return toneGlyphForIndex(item, state.preferredToneIndex);
    }
    return String(item?.emoji || item?.emoji_base || '');
  }

  function glyphForTap(item) {
    return glyphForCardDisplay(item);
  }

  function refreshRenderedCardGlyphs() {
    if (!els.list) return;
    els.list.querySelectorAll('.card').forEach((card) => {
      const item = card.__dewemojiItem;
      const emo = card.__emoEl;
      if (!item || !emo) return;
      renderEmojiGlyph(emo, glyphForCardDisplay(item), { item });
    });
    if (toneRowEl && toneRowCard?.__dewemojiItem) {
      openToneRowFor(toneRowCard, toneRowCard.__dewemojiItem, { preserveIfSame: true });
    }
  }

  function preferredTheme() {
    const media = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)');
    return media && media.matches ? 'light' : 'dark';
  }

  function setActiveTab(tabName) {
    els.tabs.forEach((btn) => {
      const active = btn.dataset.tab === tabName;
      btn.classList.toggle('active', active);
      btn.setAttribute('aria-selected', active ? 'true' : 'false');
    });
    els.panes.forEach((pane) => {
      const active = pane.id === `tab-${tabName}`;
      pane.classList.toggle('active', active);
    });
  }

  function openSheet(tabName = 'general') {
    if (OVERLAY_MODE) {
      openFullAppFromOverlay();
      return;
    }
    if (!els.settingsSheet || !els.sheetBackdrop) return;
    closeToneRow();
    setActiveTab(tabName);
    els.settingsSheet.hidden = false;
    els.sheetBackdrop.hidden = false;
    requestAnimationFrame(() => {
      els.settingsSheet.classList.add('show');
      els.sheetBackdrop.classList.add('show');
    });
  }

  function closeSheet() {
    if (!els.settingsSheet || !els.sheetBackdrop) return;
    els.settingsSheet.classList.remove('show');
    els.sheetBackdrop.classList.remove('show');
    setTimeout(() => {
      els.settingsSheet.hidden = true;
      els.sheetBackdrop.hidden = true;
    }, 180);
  }

  async function callPlugin(method, args = {}) {
    if (!overlayPlugin || typeof overlayPlugin[method] !== 'function') {
      throw new Error('plugin_unavailable');
    }
    return overlayPlugin[method](args);
  }

  function openFullAppFromOverlay() {
    if (!OVERLAY_MODE) return;
    try {
      if (typeof window.DewemojiOverlayHost?.openFullApp === 'function') {
        window.DewemojiOverlayHost.openFullApp();
        return;
      }
    } catch (err) {
      console.warn('overlay open full app failed', err);
    }
    showToast('Use notification to open the full app for settings.');
  }

  async function loadPersistedState() {
    try {
      const res = await callPlugin('getAppState');
      return (res && res.state && typeof res.state === 'object') ? res.state : {};
    } catch (_) {
      try {
        return JSON.parse(localStorage.getItem(STORE_FALLBACK_KEY) || '{}');
      } catch (_) {
        return {};
      }
    }
  }

  async function savePersistedStatePatch(patch = {}) {
    const payload = {
      authApiKey: state.authApiKey || '',
      authTier: state.authTier || 'guest',
      authEmail: state.authEmail || '',
      authName: state.authName || '',
      bubbleEnabled: !!state.bubbleEnabled,
      theme: state.theme || 'dark',
      showEmojiNames: !!state.showEmojiNames,
      toneLock: !!state.toneLock,
      preferredToneIndex: normalizePreferredToneIndex(state.preferredToneIndex),
      ...patch,
    };

    try {
      await callPlugin('setAppState', { state: payload });
    } catch (_) {
      try {
        localStorage.setItem(STORE_FALLBACK_KEY, JSON.stringify(payload));
      } catch (_) {}
    }
  }

  function updateSessionSummary() {
    if (!els.sessionSummary) return;
    const parts = [];
    if (state.authApiKey) {
      parts.push(`Connected as ${state.authEmail || 'user'} (${tierLabel(state.authTier)})`);
    } else {
      parts.push('Guest mode. Public keywords only.');
    }
    parts.push(state.bubbleStatus.running ? 'Bubble is running.' : 'Bubble is stopped.');
    parts.push(state.showEmojiNames ? 'Emoji names are visible.' : 'Emoji names are hidden for larger emoji.');
    parts.push(state.toneLock ? `Tone lock is on (preferred tone ${state.preferredToneIndex || 'default'}).` : 'Tone lock is off (tap copies default tone).');
    parts.push('Tap an emoji to copy. Paste manually in your other app.');
    els.sessionSummary.textContent = parts.join(' ');
  }

  function updateAccountUI() {
    const connected = !!state.authApiKey;
    if (els.accountConnectForm) els.accountConnectForm.style.display = connected ? 'none' : '';
    if (els.accountConnected) els.accountConnected.style.display = connected ? '' : 'none';

    if (!connected) {
      if (els.accountStatus) els.accountStatus.textContent = 'Not connected. Public keywords only.';
      if (els.accountEmail && state.authEmail) els.accountEmail.value = state.authEmail;
      return;
    }

    const label = tierLabel(state.authTier);
    const displayName = (state.authName || (state.authEmail || 'User').split('@')[0] || 'User').trim();
    if (els.accountStatus) els.accountStatus.textContent = `Connected as ${state.authEmail || 'user'} (${label})`;
    if (els.accountGreeting) els.accountGreeting.textContent = `Hi, ${displayName}`;
  }

  function setBubbleValue(el, text, tone) {
    if (!el) return;
    el.textContent = text;
    el.classList.remove('ok', 'warn', 'bad');
    if (tone) el.classList.add(tone);
  }

  function updateBubbleUI() {
    const s = state.bubbleStatus;
    setBubbleValue(els.bubblePlatformStatus, s.pluginAvailable ? 'Android APK' : `${s.platform} (unavailable)`, s.pluginAvailable ? 'ok' : 'warn');
    setBubbleValue(els.bubbleOverlayStatus, s.overlayGranted ? 'Granted' : 'Required', s.overlayGranted ? 'ok' : 'warn');
    setBubbleValue(els.bubbleNotifyStatus, s.notificationsEnabled ? 'Enabled' : 'Required', s.notificationsEnabled ? 'ok' : 'warn');
    setBubbleValue(els.bubbleRunningStatus, s.running ? 'Running' : 'Stopped', s.running ? 'ok' : 'warn');

    if (els.bubbleEnableBtn) els.bubbleEnableBtn.disabled = !s.pluginAvailable || s.running;
    if (els.bubbleDisableBtn) els.bubbleDisableBtn.disabled = !s.pluginAvailable || !s.running;
    if (els.bubbleOverlaySettingsBtn) els.bubbleOverlaySettingsBtn.disabled = !s.pluginAvailable;
    if (els.bubbleNotifySettingsBtn) els.bubbleNotifySettingsBtn.disabled = !s.pluginAvailable;

    if (!els.bubbleHelp) return;
    if (!s.pluginAvailable) {
      els.bubbleHelp.textContent = 'Bubble controls are available only in the Android APK.';
    } else if (!s.overlayGranted) {
      els.bubbleHelp.textContent = 'Grant overlay permission first, then enable the bubble.';
    } else if (!s.notificationsEnabled) {
      els.bubbleHelp.textContent = 'Enable notifications before starting the bubble so the persistent stop control is visible.';
    } else if (s.running) {
      els.bubbleHelp.textContent = 'Bubble is running. Tap it from other apps to open Dewemoji, copy emoji, then switch back and paste.';
    } else {
      els.bubbleHelp.textContent = 'Bubble is off by default. It opens Dewemoji for quick search and copy.';
    }
  }

  async function refreshBubbleStatus() {
    state.bubbleStatus.platform = platform();
    state.bubbleStatus.pluginAvailable = isAndroid() && !!overlayPlugin;

    if (!state.bubbleStatus.pluginAvailable) {
      state.bubbleStatus.overlayGranted = false;
      state.bubbleStatus.notificationsEnabled = false;
      state.bubbleStatus.running = false;
      updateBubbleUI();
      updateSessionSummary();
      return;
    }

    const [overlayRes, notifyRes, runningRes] = await Promise.allSettled([
      callPlugin('isOverlayPermissionGranted'),
      callPlugin('areNotificationsEnabled'),
      callPlugin('isBubbleRunning'),
    ]);

    state.bubbleStatus.overlayGranted = overlayRes.status === 'fulfilled' && !!overlayRes.value.granted;
    state.bubbleStatus.notificationsEnabled = notifyRes.status === 'fulfilled' && !!notifyRes.value.enabled;
    state.bubbleStatus.running = runningRes.status === 'fulfilled' && !!runningRes.value.running;

    updateBubbleUI();
    updateSessionSummary();
  }

  async function enableBubble() {
    await refreshBubbleStatus();
    if (!state.bubbleStatus.pluginAvailable) {
      showToast('Bubble is available only in the Android APK.');
      return;
    }
    if (!state.bubbleStatus.overlayGranted) {
      showToast('Grant overlay permission first.');
      try { await callPlugin('openOverlayPermissionSettings'); } catch (_) {}
      return;
    }
    if (!state.bubbleStatus.notificationsEnabled) {
      showToast('Enable notifications first.');
      try { await callPlugin('openNotificationSettings'); } catch (_) {}
      return;
    }

    try {
      const res = await callPlugin('startBubble');
      if (res && res.started === false) {
        const reason = res.reason || 'unknown';
        showToast(reason === 'notifications_required' ? 'Notifications required for bubble.' : 'Could not start bubble.');
      } else {
        state.bubbleEnabled = true;
        await savePersistedStatePatch({ bubbleEnabled: true });
        showToast('Bubble enabled. Tap it from other apps to open Dewemoji.');
      }
    } catch (err) {
      console.error('startBubble failed', err);
      showToast('Could not start bubble.');
    }

    await refreshBubbleStatus();
  }

  async function disableBubble() {
    try {
      await callPlugin('stopBubble');
    } catch (err) {
      console.warn('stopBubble failed', err);
    }
    state.bubbleEnabled = false;
    await savePersistedStatePatch({ bubbleEnabled: false });
    showToast('Bubble disabled.');
    await refreshBubbleStatus();
  }

  function authHeaders() {
    const headers = { 'X-Dewemoji-Frontend': FRONTEND_ID };
    if (state.authApiKey) headers.Authorization = `Bearer ${state.authApiKey}`;
    return headers;
  }

  async function fetchJson(url, options) {
    const res = await fetch(url, options);
    let data = null;
    try {
      data = await res.json();
    } catch (_) {
      data = null;
    }
    return { res, data };
  }

  async function fetchCategories() {
    let lastError = null;
    for (const base of orderedApiBases()) {
      try {
        const { res, data } = await fetchJson(`${base}/categories`, { cache: 'no-store', headers: authHeaders() });
        if (res.status === 401) return { unauthorized: true };
        if (res.ok && data && typeof data === 'object') {
          state.apiBase = base;
          return { ok: true, data };
        }
        lastError = new Error(`Categories ${res.status}`);
      } catch (err) {
        lastError = err;
      }
    }
    throw lastError || new Error('Failed to load categories');
  }

  async function fetchCatalogPage(page) {
    const params = new URLSearchParams({ page: String(page), limit: String(PAGE_LIMIT) });
    const q = (els.q?.value || '').trim();
    const category = (els.cat?.value || '').trim();
    const subcategory = (!els.sub?.disabled ? (els.sub?.value || '').trim() : '');
    if (q) params.set('q', q);
    if (category) params.set('category', category);
    if (subcategory) params.set('subcategory', subcategory);

    let lastError = null;
    for (const base of orderedApiBases()) {
      for (const path of SEARCH_PATHS) {
        try {
          const { res, data } = await fetchJson(`${base}${path}?${params.toString()}`, { cache: 'no-store', headers: authHeaders() });
          if (res.status === 401) return { unauthorized: true };
          if (res.ok && data && Array.isArray(data.items)) {
            state.apiBase = base;
            return { ok: true, data, res, path };
          }
          lastError = new Error(`API ${res.status}`);
        } catch (err) {
          lastError = err;
        }
      }
    }
    throw lastError || new Error('Failed to load catalog');
  }

  function normalizeCategoryMap(payload) {
    if (!payload || typeof payload !== 'object') return {};
    if (Array.isArray(payload.items)) {
      const out = {};
      for (const item of payload.items) {
        if (!item || typeof item.name !== 'string') continue;
        out[item.name] = Array.isArray(item.subcategories) ? item.subcategories.map(String) : [];
      }
      return out;
    }
    const out = {};
    for (const [k, v] of Object.entries(payload)) {
      if (['ok', 'error', 'message'].includes(k)) continue;
      out[String(k)] = Array.isArray(v) ? v.map(String) : [];
    }
    return out;
  }

  function categoryNames() {
    return Object.keys(state.categoriesMap).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
  }

  function populateCategorySelect() {
    if (!els.cat) return;
    const current = els.cat.value;
    els.cat.innerHTML = '<option value="">All categories</option>';
    for (const name of categoryNames()) {
      const opt = document.createElement('option');
      opt.value = name;
      opt.textContent = name;
      els.cat.appendChild(opt);
    }
    if (current && state.categoriesMap[current]) els.cat.value = current;
    populateSubcategorySelect();
  }

  function populateSubcategorySelect() {
    if (!els.cat || !els.sub) return;
    const current = els.sub.value;
    const category = els.cat.value;
    const subs = Array.isArray(state.categoriesMap[category]) ? [...state.categoriesMap[category]] : [];
    subs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
    els.sub.innerHTML = '<option value="">All subcategories</option>';
    subs.forEach((sub) => {
      const opt = document.createElement('option');
      opt.value = sub;
      opt.textContent = sub;
      els.sub.appendChild(opt);
    });
    els.sub.disabled = subs.length === 0;
    if (subs.includes(current)) els.sub.value = current;
  }

  function cardCount() {
    return els.list ? els.list.querySelectorAll('.card').length : 0;
  }

  function updateCountLabel() {
    if (!els.count) return;
    const shown = cardCount();
    if (state.total > 0) {
      els.count.textContent = `${shown} of ${state.total}`;
    } else {
      els.count.textContent = `${shown} items`;
    }
    if (els.more) {
      els.more.disabled = state.loading || !state.hasMore;
      els.more.style.visibility = state.hasMore ? 'visible' : 'hidden';
      els.more.textContent = state.loading ? 'Loading…' : 'Load more';
    }
    scheduleAutoLoadCheck();
  }

  function clearList() {
    closeToneRow();
    if (els.list) els.list.innerHTML = '';
    state.total = 0;
    state.page = 1;
    state.hasMore = false;
    autoLoadBusy = false;
    updateCountLabel();
  }

  function canAutoLoadMore() {
    return !!els.autoloadSentinel && !state.loading && !autoLoadBusy && state.hasMore && cardCount() > 0;
  }

  async function autoLoadMoreIfNeeded() {
    if (!canAutoLoadMore()) return;
    autoLoadBusy = true;
    try {
      await loadCatalog(false);
    } finally {
      autoLoadBusy = false;
      scheduleAutoLoadCheck();
    }
  }

  function scheduleAutoLoadCheck() {
    if (autoLoadRaf) {
      cancelAnimationFrame(autoLoadRaf);
      autoLoadRaf = 0;
    }
    if (!els.autoloadSentinel || !('IntersectionObserver' in window)) return;
    if (!canAutoLoadMore()) return;

    autoLoadRaf = requestAnimationFrame(() => {
      autoLoadRaf = 0;
      if (!canAutoLoadMore()) return;
      const rect = els.autoloadSentinel.getBoundingClientRect();
      const preloadThreshold = window.innerHeight + 240;
      if (rect.top <= preloadThreshold) {
        autoLoadMoreIfNeeded().catch(console.error);
      }
    });
  }

  function setupAutoLoadObserver() {
    if (!els.autoloadSentinel || !('IntersectionObserver' in window)) return;
    if (autoObserver) {
      try { autoObserver.disconnect(); } catch (_) {}
    }

    autoObserver = new IntersectionObserver(
      (entries) => {
        const hit = entries.some((entry) => entry.isIntersecting);
        if (!hit) return;
        autoLoadMoreIfNeeded().catch(console.error);
      },
      { root: null, rootMargin: '0px 0px 240px 0px', threshold: 0 }
    );

    autoObserver.observe(els.autoloadSentinel);
  }

  function buildAlert(type, title, message) {
    const box = document.createElement('div');
    box.className = `alert ${type || ''}`.trim();
    const strong = document.createElement('strong');
    strong.textContent = title;
    const p = document.createElement('div');
    p.className = 'muted';
    p.textContent = message;
    box.append(strong, p);
    return box;
  }

  async function copyEmoji(glyph) {
    const text = String(glyph || '');
    if (!text) return;
    await copyTextToClipboard(text, {
      successMessage: 'Copied. Switch back and paste.',
      failMessage: 'Copy failed. Try again.',
    });
  }

  async function shareEmoji(glyph) {
    const text = String(glyph || '');
    if (!text) return;
    if (navigator.share) {
      try {
        await navigator.share({ text });
        return;
      } catch (err) {
        if (err?.name === 'AbortError') return;
      }
    }
    await copyEmoji(text);
    showToast('Share unavailable. Copied instead.');
  }

  function closeToneRow() {
    if (toneOutsideHandler) {
      document.removeEventListener('pointerdown', toneOutsideHandler, true);
      toneOutsideHandler = null;
    }
    if (toneRowCard) {
      toneRowCard.classList.remove('active');
    }
    if (toneRowEl && toneRowEl.parentElement) {
      toneRowEl.parentElement.removeChild(toneRowEl);
    }
    toneRowEl = null;
    toneRowCard = null;
    scheduleOverlayPanelHeightReport();
  }

  function toneCodePointForIndex(idx) {
    return TONE_CHAR_BY_INDEX[normalizePreferredToneIndex(idx)] || '';
  }

  function isPartiallyVisible(el) {
    if (!el || !el.getBoundingClientRect) return false;
    const rect = el.getBoundingClientRect();
    const vw = window.innerWidth || document.documentElement.clientWidth || 0;
    const vh = window.innerHeight || document.documentElement.clientHeight || 0;
    return rect.bottom > 0 && rect.top < vh && rect.right > 0 && rect.left < vw;
  }

  function buildVisibleToneDebugReport() {
    const toneChar = toneCodePointForIndex(state.preferredToneIndex);
    const toneLockActive = !!(state.toneLock && state.preferredToneIndex > 0);
    const cards = Array.from(els.list?.querySelectorAll?.('.card') || []);
    const visibleCards = cards.filter(isPartiallyVisible);

    const visible = visibleCards.map((card, index) => {
      const item = card.__dewemojiItem || null;
      const emo = card.__emoEl || null;
      const expectedGlyph = glyphForCardDisplay(item);
      const displayedGlyph = String(
        emo?.querySelector?.('img')?.alt ||
        emo?.textContent ||
        ''
      );
      const toneAllowed = !!isToneAllowedForItem(item);
      const fallbackEligible = shouldUseToneImageFallback(item, expectedGlyph);
      const renderStatus = String(emo?.__dewemojiRenderStatus || 'unknown');
      const renderCp = String(emo?.__dewemojiFallbackCp || '');
      const itemSupportsTone = !!item?.supports_skin_tone;
      const hasVariants = !!(item && Array.isArray(item.variants) && item.variants.length > 0);
      const hasToneableDot = card.classList.contains('toneable');

      const flags = [];
      if (toneLockActive && itemSupportsTone && !toneAllowed) flags.push('tone_filtered');
      if (fallbackEligible && renderStatus === 'image-failed') flags.push('fallback_failed');
      if (fallbackEligible && renderStatus === 'image-loading') flags.push('fallback_loading');
      if (toneLockActive && toneAllowed && toneChar && !expectedGlyph.includes(toneChar)) flags.push('expected_missing_tone_char');
      if (toneLockActive && toneAllowed && toneChar && !displayedGlyph.includes(toneChar) && !emo?.querySelector?.('img')) {
        flags.push('display_missing_tone_char');
      }

      return {
        visibleIndex: index,
        name: String(item?.name || ''),
        slug: String(item?.slug || ''),
        emoji: String(item?.emoji || ''),
        emoji_base: String(item?.emoji_base || ''),
        expectedGlyph,
        displayedGlyph,
        expectedCodepoints: twemojiCodePoint(expectedGlyph),
        displayedCodepoints: twemojiCodePoint(displayedGlyph),
        category: String(item?.category || ''),
        subcategory: String(item?.subcategory || ''),
        supports_skin_tone: itemSupportsTone,
        hasVariants,
        toneAllowed,
        toneableDot: hasToneableDot,
        fallbackEligible,
        renderStatus,
        fallbackCodepoints: renderCp,
        fallbackPrimaryUrl: renderCp ? twemojiSvgUrlFromCodepoints(renderCp) : '',
        fallbackSecondaryUrl: renderCp ? twemojiCdnjsSvgUrlFromCodepoints(renderCp) : '',
        flags,
      };
    });

    const flagged = visible.filter((row) => row.flags.length > 0);

    return {
      generatedAt: new Date().toISOString(),
      platform: platform(),
      ui: {
        theme: state.theme,
        toneLock: state.toneLock,
        preferredToneIndex: state.preferredToneIndex,
        preferredToneChar: toneChar || '',
        query: String(els.q?.value || ''),
        category: String(els.cat?.value || ''),
        subcategory: String(els.sub?.value || ''),
      },
      counts: {
        totalRenderedCards: cards.length,
        visibleCards: visible.length,
        flaggedVisibleCards: flagged.length,
      },
      flagged,
      visible,
    };
  }

  async function copyVisibleToneDebugReport() {
    const report = buildVisibleToneDebugReport();
    const payload = JSON.stringify(report, null, 2);
    const ok = await copyTextToClipboard(payload, {
      successMessage: `Tone debug copied (${report.counts.flaggedVisibleCards} flagged / ${report.counts.visibleCards} visible).`,
      failMessage: 'Could not copy tone debug report.',
    });
    if (!ok) return;
    console.info('Dewemoji tone debug report', report);
  }

  function persistToneSettings() {
    savePersistedStatePatch({
      toneLock: state.toneLock,
      preferredToneIndex: state.preferredToneIndex,
    }).catch(console.error);
  }

  function chooseTone(item, toneIndex, { copy = true } = {}) {
    applyToneSettings(state.toneLock, toneIndex);
    updateSessionSummary();
    persistToneSettings();
    refreshRenderedCardGlyphs();
    if (copy) {
      copyEmoji(toneGlyphForIndex(item, toneIndex)).catch(console.error);
    }
  }

  function openToneRowFor(card, item, { preserveIfSame = false } = {}) {
    if (state.toneLock) return;
    if (!hasToneVariants(item) || !els.list || !card?.isConnected) return;

    const sameCard = toneRowEl && toneRowCard === card;
    if (sameCard && !preserveIfSame) {
      closeToneRow();
      return;
    }

    closeToneRow();

    const row = document.createElement('div');
    row.className = 'tone-row';
    row.setAttribute('role', 'group');
    row.setAttribute('aria-label', 'Skin tone picker');

    const options = [{ idx: 0, glyph: toneGlyphForIndex(item, 0), label: 'Default' }];
    (Array.isArray(item.variants) ? item.variants : []).forEach((_, i) => {
      options.push({ idx: i + 1, glyph: toneGlyphForIndex(item, i + 1), label: `Tone ${i + 1}` });
    });

    options.forEach((opt) => {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'tone-option';
      btn.title = opt.label;
      btn.setAttribute('aria-label', `${opt.label} ${item?.name || 'emoji'}`);
      renderEmojiGlyph(btn, opt.glyph, { item });
      if (state.preferredToneIndex === opt.idx) {
        btn.classList.add('selected');
      }
      btn.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        closeToneRow();
        chooseTone(item, opt.idx, { copy: true });
      });
      row.appendChild(btn);
    });

    const note = document.createElement('div');
    note.className = 'tone-note';
    note.textContent = state.toneLock
      ? 'Tap a tone to copy and update your preferred tone (tone lock is on).'
      : 'Tap a tone to copy and save as preferred. Turn on Tone lock to use it on normal tap.';
    row.appendChild(note);

    card.insertAdjacentElement('afterend', row);
    card.classList.add('active');
    toneRowEl = row;
    toneRowCard = card;

    toneOutsideHandler = (event) => {
      const target = event.target;
      if (toneRowEl?.contains(target) || toneRowCard?.contains(target)) return;
      closeToneRow();
    };
    setTimeout(() => {
      if (toneOutsideHandler) {
        document.addEventListener('pointerdown', toneOutsideHandler, true);
      }
      scheduleOverlayPanelHeightReport();
    }, 0);
  }

  function renderCard(item) {
    const card = document.createElement('button');
    card.type = 'button';
    card.className = 'card';
    if (item && item.source === 'private') card.classList.add('private');
    if (hasToneVariants(item)) card.classList.add('toneable');

    const emoji = glyphForCardDisplay(item);
    const name = String(item?.name || item?.slug || 'emoji');
    card.title = `${name}${item?.category ? `\n${item.category}` : ''}${item?.subcategory ? ` / ${item.subcategory}` : ''}`;
    card.setAttribute('aria-label', `Copy ${name}`);

    const emo = document.createElement('div');
    emo.className = 'emo';
    renderEmojiGlyph(emo, emoji, { item });

    const nm = document.createElement('div');
    nm.className = 'nm';
    nm.textContent = name;

    card.__dewemojiItem = item;
    card.__emoEl = emo;

    card.append(emo, nm);
    let longPressTimer = 0;
    let longPressTriggered = false;
    let startX = 0;
    let startY = 0;

    const cancelLongPress = () => {
      if (longPressTimer) {
        clearTimeout(longPressTimer);
        longPressTimer = 0;
      }
    };

    if (hasToneVariants(item)) {
      card.addEventListener('pointerdown', (e) => {
        if (e.pointerType === 'mouse' && e.button !== 0) return;
        if (state.toneLock) return;
        cancelLongPress();
        longPressTriggered = false;
        startX = e.clientX;
        startY = e.clientY;
        longPressTimer = window.setTimeout(() => {
          longPressTimer = 0;
          longPressTriggered = true;
          openToneRowFor(card, item);
        }, LONG_PRESS_MS);
      });

      card.addEventListener('pointermove', (e) => {
        if (!longPressTimer) return;
        if (Math.abs(e.clientX - startX) > 16 || Math.abs(e.clientY - startY) > 16) {
          cancelLongPress();
        }
      });

      ['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => {
        card.addEventListener(type, cancelLongPress);
      });
    }

    card.addEventListener('click', (e) => {
      if (longPressTriggered) {
        longPressTriggered = false;
        e.preventDefault();
        e.stopPropagation();
        return;
      }
      copyEmoji(glyphForTap(item)).catch(console.error);
    });

    card.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (hasToneVariants(item) && !state.toneLock) {
        openToneRowFor(card, item, { preserveIfSame: true });
        return;
      }
      shareEmoji(glyphForTap(item)).catch(console.error);
    });

    return card;
  }

  function renderItems(items, { reset = false } = {}) {
    if (!els.list) return;
    if (reset) {
      closeToneRow();
      els.list.innerHTML = '';
    }

    if (reset && (!items || items.length === 0)) {
      els.list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or subcategory.'));
      updateCountLabel();
      scheduleOverlayPanelHeightReport();
      return;
    }

    (items || []).forEach((item) => els.list.appendChild(renderCard(item)));
    updateCountLabel();
    scheduleOverlayPanelHeightReport();
  }

  async function clearAuth({ silent = false } = {}) {
    state.authApiKey = '';
    state.authTier = 'guest';
    state.authName = '';
    await savePersistedStatePatch({ authApiKey: '', authTier: 'guest', authName: '' });
    setStatusTag();
    updateAccountUI();
    updateSessionSummary();
    if (!silent) showToast('Disconnected. Public keywords only.');
  }

  async function loadCategories() {
    try {
      const result = await fetchCategories();
      if (result.unauthorized) {
        await clearAuth({ silent: true });
        return;
      }
      if (result.ok) {
        state.categoriesMap = normalizeCategoryMap(result.data);
        populateCategorySelect();
      }
    } catch (err) {
      console.warn('loadCategories failed', err);
    }
  }

  async function loadCatalog(reset = false) {
    if (state.loading) return;
    const requestId = ++state.requestSeq;
    state.loading = true;
    if (reset) {
      state.page = 1;
      state.total = 0;
      state.hasMore = false;
      if (els.list) els.list.innerHTML = '';
    }
    updateCountLabel();
    setApiStatus(reset ? 'Loading catalog…' : 'Loading more…');

    try {
      const page = reset ? 1 : state.page;
      const result = await fetchCatalogPage(page);
      if (requestId !== state.requestSeq) return;

      if (result.unauthorized) {
        if (state.authApiKey) {
          await clearAuth({ silent: true });
          await loadCategories();
          setApiStatus('Session expired. Showing guest catalog.');
          state.loading = false;
          updateCountLabel();
          await loadCatalog(reset);
          return;
        }
        throw new Error('unauthorized');
        return;
      }

      const resTier = result.res?.headers?.get?.('X-Dewemoji-Tier');
      if (state.authApiKey && resTier) {
        state.authTier = resTier === 'pro' ? 'personal' : String(resTier || 'free');
        setStatusTag();
        updateAccountUI();
        await savePersistedStatePatch({ authTier: state.authTier });
      }

      const data = result.data || {};
      const items = Array.isArray(data.items) ? data.items : [];
      state.total = Number(data.total || 0);
      state.hasMore = (page - 1) * PAGE_LIMIT + items.length < state.total;
      state.page = page + 1;

      renderItems(items, { reset });
      const shown = cardCount();
      setApiStatus(`Loaded ${shown} items from ${state.total || shown}.`);
      scheduleAutoLoadCheck();
    } catch (err) {
      console.error('loadCatalog failed', err);
      if (requestId !== state.requestSeq) return;
      if (!els.list || (reset && els.list.children.length === 0)) {
        if (els.list) {
          els.list.innerHTML = '';
          els.list.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.'));
        }
      }
      state.hasMore = false;
      setApiStatus('Catalog request failed.');
      updateCountLabel();
    } finally {
      if (requestId === state.requestSeq) {
        state.loading = false;
        updateCountLabel();
      }
    }
  }

  async function login() {
    const email = String(els.accountEmail?.value || '').trim();
    const password = String(els.accountPassword?.value || '').trim();
    if (!email || !password) {
      showToast('Enter email and password.');
      return;
    }

    if (els.accountLogin) {
      els.accountLogin.disabled = true;
      els.accountLogin.textContent = 'Connecting…';
    }

    let lastErr = null;
    try {
      for (const base of orderedApiBases()) {
        try {
          const { res, data } = await fetchJson(`${base}/user/login`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'X-Dewemoji-Frontend': FRONTEND_ID,
            },
            body: JSON.stringify({ email, password }),
          });

          if (!res.ok || !data || data.ok === false || !data.api_key) {
            lastErr = new Error((data && data.error) || `Login ${res.status}`);
            continue;
          }

          state.apiBase = base;
          state.authApiKey = String(data.api_key || '');
          state.authTier = String(data.user?.tier || 'free');
          state.authEmail = String(data.user?.email || email);
          state.authName = String(data.user?.name || '');

          await savePersistedStatePatch({
            authApiKey: state.authApiKey,
            authTier: state.authTier,
            authEmail: state.authEmail,
            authName: state.authName,
          });

          setStatusTag();
          updateAccountUI();
          updateSessionSummary();
          showToast('Account connected. Private keyword matches can appear in search.');
          await loadCategories();
          await loadCatalog(true);
          return;
        } catch (err) {
          lastErr = err;
        }
      }
      throw lastErr || new Error('Login failed');
    } catch (err) {
      console.error('login failed', err);
      showToast(`Connect failed${err?.message ? `: ${err.message}` : ''}`);
    } finally {
      if (els.accountPassword) els.accountPassword.value = '';
      if (els.accountLogin) {
        els.accountLogin.disabled = false;
        els.accountLogin.textContent = 'Connect';
      }
    }
  }

  async function logout() {
    if (!state.authApiKey) return;
    const token = state.authApiKey;
    try {
      await fetch(`${state.apiBase}/user/logout`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${token}`,
          'X-Dewemoji-Frontend': FRONTEND_ID,
        },
      });
    } catch (_) {}

    await clearAuth();
    await loadCategories();
    await loadCatalog(true);
  }

  function scheduleSearch() {
    clearTimeout(state.searchDebounce);
    state.searchDebounce = setTimeout(() => {
      loadCatalog(true).catch(console.error);
    }, 250);
  }

  function bindEvents() {
    els.settings?.addEventListener('click', () => {
      if (OVERLAY_MODE) {
        openFullAppFromOverlay();
        return;
      }
      openSheet('general');
    });
    els.sheetClose?.addEventListener('click', closeSheet);
    els.sheetBackdrop?.addEventListener('click', closeSheet);

    els.tabs.forEach((btn) => {
      btn.addEventListener('click', () => setActiveTab(btn.dataset.tab || 'general'));
    });

    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape') return;
      if (toneRowEl) {
        closeToneRow();
        return;
      }
      closeSheet();
    });

    els.theme?.addEventListener('click', async () => {
      const next = state.theme === 'dark' ? 'light' : 'dark';
      applyTheme(next);
      await savePersistedStatePatch({ theme: next });
    });

    els.showEmojiNamesToggle?.addEventListener('change', async () => {
      const next = !!els.showEmojiNamesToggle.checked;
      applyEmojiNameVisibility(next);
      updateSessionSummary();
      await savePersistedStatePatch({ showEmojiNames: next });
    });

    els.toneLockToggle?.addEventListener('change', async () => {
      const next = !!els.toneLockToggle.checked;
      applyToneSettings(next, state.preferredToneIndex);
      refreshRenderedCardGlyphs();
      updateSessionSummary();
      await savePersistedStatePatch({ toneLock: next, preferredToneIndex: state.preferredToneIndex });
    });

    els.preferredSkinToneRadios?.forEach((radio) => {
      radio.addEventListener('change', async () => {
        if (!radio.checked) return;
        const next = normalizePreferredToneIndex(radio.value);
        applyToneSettings(state.toneLock, next);
        refreshRenderedCardGlyphs();
        updateSessionSummary();
        await savePersistedStatePatch({ preferredToneIndex: next, toneLock: state.toneLock });
      });
    });

    els.clear?.addEventListener('click', () => {
      if (els.q) els.q.value = '';
      scheduleSearch();
    });

    els.q?.addEventListener('input', scheduleSearch);
    els.q?.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        clearTimeout(state.searchDebounce);
        loadCatalog(true).catch(console.error);
      }
    });

    els.cat?.addEventListener('change', () => {
      populateSubcategorySelect();
      loadCatalog(true).catch(console.error);
    });
    els.sub?.addEventListener('change', () => loadCatalog(true).catch(console.error));

    els.more?.addEventListener('click', () => {
      if (!state.loading && state.hasMore) loadCatalog(false).catch(console.error);
    });

    els.refresh?.addEventListener('click', async () => {
      await refreshBubbleStatus();
      await loadCategories();
      await loadCatalog(true);
    });
    els.accountLogin?.addEventListener('click', (e) => {
      e.preventDefault();
      login().catch(console.error);
    });
    els.accountPassword?.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        login().catch(console.error);
      }
    });
    els.accountLogout?.addEventListener('click', (e) => {
      e.preventDefault();
      logout().catch(console.error);
    });

    els.bubbleEnableBtn?.addEventListener('click', (e) => {
      e.preventDefault();
      enableBubble().catch(console.error);
    });
    els.bubbleDisableBtn?.addEventListener('click', (e) => {
      e.preventDefault();
      disableBubble().catch(console.error);
    });
    els.bubbleOverlaySettingsBtn?.addEventListener('click', async (e) => {
      e.preventDefault();
      try { await callPlugin('openOverlayPermissionSettings'); } catch (_) { showToast('Overlay settings unavailable here.'); }
    });
    els.bubbleNotifySettingsBtn?.addEventListener('click', async (e) => {
      e.preventDefault();
      try { await callPlugin('openNotificationSettings'); } catch (_) { showToast('Notification settings unavailable here.'); }
    });

    window.addEventListener('focus', () => refreshBubbleStatus().catch(console.error));
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden) refreshBubbleStatus().catch(console.error);
    });
    window.addEventListener('resize', scheduleAutoLoadCheck);
  }

  window.dewemojiHandleAndroidBack = () => {
    if (toneRowEl) {
      closeToneRow();
      return true;
    }
    if (els.settingsSheet && !els.settingsSheet.hidden) {
      closeSheet();
      return true;
    }
    if (els.toast && els.toast.classList.contains('show')) {
      els.toast.classList.remove('show');
      return true;
    }
    const active = document.activeElement;
    if (active && (active.tagName === 'INPUT' || active.tagName === 'SELECT')) {
      active.blur();
      return true;
    }
    return false;
  };

  async function init() {
    bindEvents();
    setupOverlayModeBehavior();
    setupAutoLoadObserver();
    setVersionBadge();
    clearList();

    const persisted = await loadPersistedState();
    state.authApiKey = String(persisted.authApiKey || '');
    state.authTier = String(persisted.authTier || 'guest');
    state.authEmail = String(persisted.authEmail || '');
    state.authName = String(persisted.authName || '');
    state.bubbleEnabled = !!persisted.bubbleEnabled;
    state.theme = String(persisted.theme || preferredTheme());
    state.showEmojiNames = !!persisted.showEmojiNames;
    state.toneLock = !!persisted.toneLock;
    state.preferredToneIndex = normalizePreferredToneIndex(persisted.preferredToneIndex);

    if (els.accountEmail && state.authEmail) els.accountEmail.value = state.authEmail;

    applyTheme(state.theme);
    applyEmojiNameVisibility(state.showEmojiNames);
    applyToneSettings(state.toneLock, state.preferredToneIndex);
    setStatusTag();
    updateAccountUI();
    setApiStatus('Loading catalog…');

    await refreshBubbleStatus();
    await loadCategories();
    await loadCatalog(true);

    if (state.bubbleEnabled && !state.bubbleStatus.running && state.bubbleStatus.pluginAvailable) {
      els.bubbleHelp.textContent = 'Bubble was enabled before but is not currently running. Tap “Enable bubble” to start it again.';
    }
    updateSessionSummary();
    scheduleOverlayPanelHeightReport();
  }

  init().catch((err) => {
    console.error('init failed', err);
    setApiStatus('Dewemoji failed to initialize.');
    clearList();
    els.list?.appendChild(buildAlert('error', 'Failed to initialize', 'Reopen the app and try again.'));
  });
})();
