/**
 * Non-side-effect class export for `<combobox-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/combobox`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * `<combobox-ui>` — SPEC-036.
 *
 * Typeahead-filterable single-select per WAI-APG "Combobox With List
 * Autocomplete (manual selection)". Constrained-choice by default:
 * `value` must come from the options list. `[free-text]` opts into the
 * autocomplete-style hybrid; `[creatable]` adds a create-flow on top.
 *
 * No native `<input>` wrap (per ADR-0025). The host carries
 * `role="combobox"` and the editable surface is a `contenteditable`
 * span. Form participation via `UIFormElement` + `ElementInternals`.
 *
 *   <combobox-ui name="country" placeholder="Select country…">
 *     <option value="us">United States</option>
 *     <option value="gb">United Kingdom</option>
 *   </combobox-ui>
 *
 *   <combobox-ui id="member" placeholder="Search members…"></combobox-ui>
 *   <script>
 *     document.getElementById('member').addEventListener('input', async e => {
 *       const res = await fetch(`/api/members?q=${e.detail.query}`);
 *       e.target.options = await res.json();
 *     });
 *   </script>
 */

import { UIFormElement } from '../../core/form.js';
import { anchorPopover } from '../../core/anchor.js';
import { untracked } from '../../core/signals.js';
import { logicalSlotted } from '../../core/logical-children.js';

function escapeHTML(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function escapeRegExp(s) {
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

let cbInstanceSeq = 0;

export class UICombobox extends UIFormElement {
  // `label` here is first-class — rendered above the field with proper
  // `aria-labelledby` wiring on the editable surface. Opt out of the
  // base-class deprecation warning that targets inert above-field labels.
  static labelDeprecated = false;

  static requiredIcons = ['caret-down', 'x-circle', 'check'];

  static properties = {
    ...UIFormElement.properties,
    placeholder:     { type: String,  default: 'Select...', reflect: true },
    label:           { type: String,  default: '',          reflect: true },
    // Universal [size] system — sm/md/lg → 24/30/36 px (with density).
    // yaml documented this as reflect:true since v1; closing the
    // static-properties gap so el.size = 'lg' actually updates.
    size:            { type: String,  default: 'md',        reflect: true },
    open:            { type: Boolean, default: false,       reflect: true },
    freeText:        { type: Boolean, default: false,       reflect: true, attribute: 'free-text' },
    creatable:       { type: Boolean, default: false,       reflect: true },
    clearable:       { type: Boolean, default: false,       reflect: true },
    filterMode:      { type: String,  default: 'substring', reflect: true, attribute: 'filter-mode' },
    loading:         { type: Boolean, default: false,       reflect: true },
    maxOptions:      { type: Number,  default: 100,         reflect: true, attribute: 'max-options' },
    highlightMatch:  { type: Boolean, default: true,        reflect: true, attribute: 'highlight-match' },
  };

  static template = () => null;

  // ── Instance state ──
  #options = [];
  #lastCommittedValue = '';
  #query = '';
  #activeIndex = -1;
  #suppressInput = false;  // guard contenteditable re-syncs during programmatic updates

  // ── Refs ──
  #inputEl = null;
  #labelEl = null;
  #listbox = null;
  #emptyEl = null;
  #loadingEl = null;
  #footerEl = null;
  #clearBtn = null;
  #suffixEl = null;
  #anchorCleanup = null;
  #rafId = null;
  #instanceId = `combobox-${++cbInstanceSeq}`;

  // ── Stable handler refs (so removeEventListener finds them) ──
  #onInputEvent = () => this.#handleInput();
  #onKeydown = (e) => this.#handleKeydown(e);
  #onFocus = () => this.#handleFocus();
  #onBlur = (e) => this.#handleBlur(e);
  #onOptionClick = (e) => this.#handleOptionClick(e);
  #onOptionMouseEnter = (e) => this.#handleOptionMouseEnter(e);
  #onClearClick = (e) => this.#handleClearClick(e);
  #onSuffixClick = (e) => this.#handleSuffixClick(e);
  #onOutside = (e) => this.#handleOutside(e);

  // ── Lifecycle ──

  connected() {
    super.connected();
    // Host carries presentation. role="combobox" + ARIA expansion state
    // live on the inner [data-input] per WAI-APG combobox-list-autocomplete
    // (the editable surface IS the combobox; the host wraps the input +
    // popover into one form-bearing custom element).

    // Parse declarative <option> / <optgroup> children once on connect,
    // unless options were already set programmatically.
    if (this.#options.length === 0) {
      this.#parseOptions();
    }

    this.#lastCommittedValue = this.value || '';

    this.#stampShell();
    this.#renderOptions();
    this.#syncInputDisplay();
  }

  disconnected() {
    super.disconnected();
    this.#teardownListeners();
    this.#anchorCleanup?.();
    this.#anchorCleanup = null;
    if (this.#rafId != null) {
      cancelAnimationFrame(this.#rafId);
      this.#rafId = null;
    }
    document.removeEventListener('pointerdown', this.#onOutside);
    this.#listbox?.hidePopover?.();
    this.#listbox = null;
    this.#inputEl = null;
    this.#labelEl = null;
    this.#emptyEl = null;
    this.#loadingEl = null;
    this.#footerEl = null;
    this.#clearBtn = null;
    this.#suffixEl = null;
  }

  render() {
    if (!this.#inputEl) return;

    // Sync ARIA expanded on the inner combobox surface (WAI-APG).
    this.#inputEl.setAttribute('aria-expanded', String(this.open));

    // Disabled / readonly state on the editable surface.
    if (this.disabled || this.readonly) {
      this.#inputEl.contentEditable = 'false';
    } else {
      this.#inputEl.contentEditable = 'plaintext-only';
    }

    if (this.#labelEl) {
      this.#labelEl.textContent = this.label || '';
      this.#labelEl.style.display = this.label ? '' : 'none';
    }

    // aria-label fallback when no [label] is set.
    if (this.label) {
      this.removeAttribute('aria-label');
    } else if (this.placeholder) {
      this.setAttribute('aria-label', this.placeholder);
    } else {
      this.removeAttribute('aria-label');
    }

    // Toggle clear-button visibility when [clearable].
    if (this.#clearBtn) {
      this.#clearBtn.style.display = this.clearable && this.value ? '' : 'none';
    }

    // Show/hide the loading indicator + empty state in the popover.
    if (this.#loadingEl) {
      this.#loadingEl.style.display = this.loading ? '' : 'none';
    }

    // Popover open / close.
    if (this.open) {
      this.#openPopover();
    } else {
      this.#closePopover();
    }
  }

  // ── Shell ──

  #stampShell() {
    if (this.querySelector(':scope > [data-field]')) {
      // Already stamped (re-connect path); just re-query refs.
      this.#queryRefs();
      this.#bindListeners();
      return;
    }

    const labelId = `${this.#instanceId}-label`;
    const listboxId = `${this.#instanceId}-listbox`;
    const inputId = `${this.#instanceId}-input`;

    // Capture consumer-supplied slotted children before innerHTML wipes them.
    // logicalSlotted pierces the template engine's display:contents wrapper spans,
    // so `.map()`/`repeat()`-interpolated affordances aren't missed then wiped
    // (the wrapper trap — audit-wrapper-trap arm 2).
    const prefixNodes = logicalSlotted(this, 'prefix');
    const suffixNodes = logicalSlotted(this, 'suffix');
    const emptyNodes = logicalSlotted(this, 'empty');
    const loadingNodes = logicalSlotted(this, 'loading');
    const footerNodes = logicalSlotted(this, 'footer');

    this.innerHTML = `
      <span data-label id="${labelId}"${this.label ? '' : ' style="display:none"'}>${escapeHTML(this.label || '')}</span>
      <div data-field>
        <span data-prefix></span>
        <span data-input
              id="${inputId}"
              contenteditable="plaintext-only"
              role="combobox"
              tabindex="0"
              aria-autocomplete="list"
              aria-expanded="false"
              aria-controls="${listboxId}"
              aria-labelledby="${labelId}"
              data-placeholder="${escapeHTML(this.placeholder || '')}"></span>
        <button type="button" data-clear aria-label="Clear" tabindex="-1" style="display:none">
          <icon-ui name="x-circle"></icon-ui>
        </button>
        <span data-suffix>
          <icon-ui name="caret-down"></icon-ui>
        </span>
      </div>
      <div data-listbox id="${listboxId}" role="listbox" popover="manual" aria-labelledby="${labelId}">
        <div data-loading style="display:none"><spinner-ui></spinner-ui></div>
        <div data-options></div>
        <div data-empty>No matches</div>
        <div data-footer></div>
      </div>
    `;

    this.#queryRefs();

    // Re-insert consumer-supplied slot nodes into the appropriate region.
    if (prefixNodes.length) {
      const prefix = this.querySelector(':scope > [data-field] > [data-prefix]');
      prefix.replaceChildren(...prefixNodes);
    }
    if (suffixNodes.length) {
      // Consumer-supplied suffix replaces the default caret-down.
      this.#suffixEl.replaceChildren(...suffixNodes);
    }
    if (emptyNodes.length) {
      this.#emptyEl.replaceChildren(...emptyNodes);
    }
    if (loadingNodes.length) {
      this.#loadingEl.replaceChildren(...loadingNodes);
    }
    if (footerNodes.length) {
      this.#footerEl.replaceChildren(...footerNodes);
    }

    this.#bindListeners();
  }

  #queryRefs() {
    this.#inputEl = this.querySelector(':scope > [data-field] > [data-input]');
    this.#labelEl = this.querySelector(':scope > [data-label]');
    this.#clearBtn = this.querySelector(':scope > [data-field] > [data-clear]');
    this.#suffixEl = this.querySelector(':scope > [data-field] > [data-suffix]');
    this.#listbox = this.querySelector(':scope > [data-listbox]');
    this.#loadingEl = this.querySelector(':scope > [data-listbox] > [data-loading]');
    this.#emptyEl = this.querySelector(':scope > [data-listbox] > [data-empty]');
    this.#footerEl = this.querySelector(':scope > [data-listbox] > [data-footer]');
  }

  #bindListeners() {
    if (this.#inputEl) {
      this.#inputEl.addEventListener('input', this.#onInputEvent);
      this.#inputEl.addEventListener('keydown', this.#onKeydown);
      this.#inputEl.addEventListener('focus', this.#onFocus);
      this.#inputEl.addEventListener('blur', this.#onBlur);
    }
    if (this.#clearBtn) {
      this.#clearBtn.addEventListener('click', this.#onClearClick);
    }
    if (this.#suffixEl) {
      this.#suffixEl.addEventListener('click', this.#onSuffixClick);
    }
  }

  #teardownListeners() {
    if (this.#inputEl) {
      this.#inputEl.removeEventListener('input', this.#onInputEvent);
      this.#inputEl.removeEventListener('keydown', this.#onKeydown);
      this.#inputEl.removeEventListener('focus', this.#onFocus);
      this.#inputEl.removeEventListener('blur', this.#onBlur);
    }
    if (this.#clearBtn) {
      this.#clearBtn.removeEventListener('click', this.#onClearClick);
    }
    if (this.#suffixEl) {
      this.#suffixEl.removeEventListener('click', this.#onSuffixClick);
    }
  }

  // ── Options ──

  /** Parse <option> + <optgroup> children into the internal option model. */
  #parseOptions() {
    const opts = [];
    let preSelected = '';
    for (const child of Array.from(this.children)) {
      if (child.tagName === 'OPTGROUP') {
        const group = { label: child.label || child.getAttribute('label') || '', options: [] };
        for (const opt of child.querySelectorAll('option')) {
          group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled });
          if (opt.hasAttribute('selected') && !preSelected) preSelected = opt.value;
        }
        opts.push(group);
      } else if (child.tagName === 'OPTION') {
        opts.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled });
        if (child.hasAttribute('selected') && !preSelected) preSelected = child.value;
      }
    }
    // Remove the parsed nodes so they don't compete with the stamped shell.
    for (const child of [...this.querySelectorAll(':scope > option, :scope > optgroup')]) child.remove();
    this.#options = opts;
    if (!this.value && preSelected) this.value = preSelected;
  }

  set options(list) {
    // Reads of `this.value` happen below (for aria-selected wiring). Wrap
    // the rebuild in `untracked` so calling-effect contexts don't subscribe
    // to value via this setter — mirrors the select-ui pattern from
    // FEEDBACK-22 / v0.5.18.
    untracked(() => {
      this.#options = Array.isArray(list) ? list : [];
      if (this.#listbox) this.#renderOptions();
      this.#syncInputDisplay();
    });
  }

  get options() { return this.#options; }

  #flatOptions() {
    const flat = [];
    for (const item of this.#options) {
      if (item && Array.isArray(item.options)) {
        for (const opt of item.options) flat.push(opt);
      } else if (item) {
        flat.push(item);
      }
    }
    return flat;
  }

  #findOption(value) {
    return this.#flatOptions().find((o) => o.value === value) || null;
  }

  // ── Render the listbox ──

  #renderOptions() {
    if (!this.#listbox) return;
    const container = this.querySelector(':scope > [data-listbox] > [data-options]');
    if (!container) return;
    container.replaceChildren();

    const flat = this.#flatOptions();
    const renderable = this.#applyFilter(flat).slice(0, Number(this.maxOptions) || 100);

    let optionCounter = 0;
    const renderOpt = (opt) => {
      optionCounter += 1;
      const el = document.createElement('div');
      el.setAttribute('role', 'option');
      el.setAttribute('id', `${this.#instanceId}-opt-${optionCounter}`);
      el.dataset.value = opt.value ?? '';
      if (opt.disabled) el.setAttribute('aria-disabled', 'true');
      if (opt.value === this.value) el.setAttribute('aria-selected', 'true');
      el.innerHTML = this.#renderOptionLabel(opt.label ?? opt.value);
      el.addEventListener('click', this.#onOptionClick);
      el.addEventListener('mouseenter', this.#onOptionMouseEnter);
      return el;
    };

    if (this.#options.some((o) => o && Array.isArray(o.options))) {
      // Grouped path — render groups with section headers, filtered.
      for (const item of this.#options) {
        if (item && Array.isArray(item.options)) {
          const groupOpts = this.#applyFilter(item.options);
          if (!groupOpts.length) continue;
          const groupEl = document.createElement('div');
          groupEl.setAttribute('role', 'group');
          const header = document.createElement('div');
          header.setAttribute('data-group-label', '');
          header.textContent = item.label || '';
          groupEl.appendChild(header);
          for (const opt of groupOpts) groupEl.appendChild(renderOpt(opt));
          container.appendChild(groupEl);
        } else if (item) {
          // Flat option mixed into a grouped list — still render it.
          if (this.#matches(item)) container.appendChild(renderOpt(item));
        }
      }
    } else {
      for (const opt of renderable) container.appendChild(renderOpt(opt));
    }

    // Update the empty-state visibility based on what got rendered.
    const visible = container.querySelectorAll('[role="option"]').length;
    if (this.#emptyEl) {
      this.#emptyEl.style.display = visible || this.loading ? 'none' : '';
    }

    // Clear any stale active-descendant pointer.
    this.#activeIndex = -1;
    this.#inputEl?.removeAttribute('aria-activedescendant');
  }

  #renderOptionLabel(label) {
    const text = String(label ?? '');
    if (!this.highlightMatch || !this.#query) return escapeHTML(text);
    const q = this.#query;
    const re = new RegExp(escapeRegExp(q), 'i');
    const match = text.match(re);
    if (!match) return escapeHTML(text);
    const start = match.index ?? 0;
    const end = start + match[0].length;
    return (
      escapeHTML(text.slice(0, start)) +
      '<mark>' +
      escapeHTML(text.slice(start, end)) +
      '</mark>' +
      escapeHTML(text.slice(end))
    );
  }

  // ── Filtering ──

  #matches(opt) {
    if (!this.#query) return true;
    const q = this.#query.toLowerCase();
    const label = String(opt.label ?? opt.value ?? '').toLowerCase();
    switch (this.filterMode) {
      case 'prefix':
        return label.startsWith(q);
      case 'fuzzy': {
        let li = 0;
        for (const ch of q) {
          li = label.indexOf(ch, li);
          if (li === -1) return false;
          li += 1;
        }
        return true;
      }
      case 'substring':
      default:
        return label.includes(q);
    }
  }

  #applyFilter(list) {
    if (!this.#query) return list.slice();
    return list.filter((o) => this.#matches(o));
  }

  // ── Input handling ──

  #handleInput() {
    if (this.#suppressInput) return;
    const text = this.#inputEl.textContent || '';
    this.#query = text.trim();
    // Hide the placeholder pseudo as soon as anything is typed — it's keyed
    // off [data-empty], which the programmatic text-setter toggles but the
    // typing path did not, so the placeholder bled THROUGH the typed text.
    this.#inputEl.toggleAttribute('data-empty', !text);

    if (!this.open) {
      this.open = true;
      this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'typing' } }));
    }

    this.#renderOptions();

    // WAI-APG list-autocomplete: pre-activate the first match while typing so
    // pressing Enter commits it (and the user sees which option Enter will
    // pick). #renderOptions() reset the active index; only activate when a
    // query is present and there is something to match.
    const opts = this.#renderedOptions();
    if (this.#query && opts.length) this.#setActiveIndex(0);

    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { value: text, query: this.#query.toLowerCase() },
    }));
  }

  #handleKeydown(e) {
    const k = e.key;

    if (k === 'ArrowDown') {
      e.preventDefault();
      if (!this.open) {
        this.open = true;
        this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'keyboard' } }));
      }
      this.#moveActive(1);
      return;
    }
    if (k === 'ArrowUp') {
      if (!this.open) return;
      e.preventDefault();
      this.#moveActive(-1);
      return;
    }
    if (k === 'Home' && this.open) {
      e.preventDefault();
      this.#setActiveIndex(0);
      return;
    }
    if (k === 'End' && this.open) {
      e.preventDefault();
      const opts = this.#renderedOptions();
      this.#setActiveIndex(opts.length - 1);
      return;
    }
    if (k === 'Enter') {
      e.preventDefault();
      this.#commitOnEnter();
      return;
    }
    if (k === 'Escape') {
      if (this.open) {
        e.preventDefault();
        this.#closeAndRestore('escape');
      }
      return;
    }
    if (k === 'Tab') {
      // Tab: commit active option if any; otherwise per free-text policy.
      this.#commitOnTab();
      return;
    }
    if (k === 'Backspace') {
      // When clearable + the input is empty + a value is committed, clear.
      const empty = !(this.#inputEl.textContent || '').length;
      if (this.clearable && empty && this.value) {
        e.preventDefault();
        this.#clear('keyboard');
      }
      return;
    }
  }

  #handleFocus() {
    // No auto-open on focus; user opens with Arrow / click / typing.
  }

  #handleBlur(e) {
    // Closing on blur is handled by the document-level outside-click handler
    // (so clicks on listbox options don't close before they fire). Just
    // restore the input display if free-text is off and the typed query
    // doesn't resolve to a real value.
    if (!this.freeText && this.#query && !this.value) {
      // No commit happened; restore last committed value into the display.
      this.#syncInputDisplay();
      this.#query = '';
    }
    void e;
  }

  // ── Active-descendant management ──

  #renderedOptions() {
    if (!this.#listbox) return [];
    return Array.from(this.#listbox.querySelectorAll('[role="option"]:not([aria-disabled="true"])'));
  }

  #moveActive(dir) {
    const opts = this.#renderedOptions();
    if (!opts.length) return;
    if (this.#activeIndex < 0) {
      this.#setActiveIndex(dir > 0 ? 0 : opts.length - 1);
      return;
    }
    const next = (this.#activeIndex + dir + opts.length) % opts.length;
    this.#setActiveIndex(next);
  }

  #setActiveIndex(idx) {
    const opts = this.#renderedOptions();
    if (!opts.length) {
      this.#activeIndex = -1;
      this.#inputEl?.removeAttribute('aria-activedescendant');
      return;
    }
    // Clear previous data-active marker.
    for (const o of opts) o.removeAttribute('data-active');
    const clamped = Math.max(0, Math.min(idx, opts.length - 1));
    const el = opts[clamped];
    if (!el) return;
    el.setAttribute('data-active', '');
    el.scrollIntoView({ block: 'nearest' });
    this.#activeIndex = clamped;
    this.#inputEl?.setAttribute('aria-activedescendant', el.id);
  }

  // ── Commit paths ──

  #handleOptionClick(e) {
    const el = e.currentTarget;
    if (el.getAttribute('aria-disabled') === 'true') return;
    const value = el.dataset.value || '';
    this.#commitOption(value, 'click');
  }

  #handleOptionMouseEnter(e) {
    const el = e.currentTarget;
    const opts = this.#renderedOptions();
    const idx = opts.indexOf(el);
    if (idx >= 0) this.#setActiveIndex(idx);
  }

  #commitOnEnter() {
    if (!this.open) {
      // Closed: open the popover for the user.
      this.open = true;
      this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'keyboard' } }));
      return;
    }
    const opts = this.#renderedOptions();
    const active = this.#activeIndex >= 0 ? opts[this.#activeIndex] : null;
    if (active) {
      this.#commitOption(active.dataset.value || '', 'keyboard');
      return;
    }
    const typed = (this.#inputEl.textContent || '').trim();
    if (!typed) {
      this.#closePopover('select');
      return;
    }
    if (this.creatable) {
      this.dispatchEvent(new CustomEvent('create', { bubbles: true, detail: { value: typed } }));
      // Free-text commit also fires for creatable (consumer can refresh options after).
      this.#commitFreeText(typed, 'keyboard');
      return;
    }
    if (this.freeText) {
      this.#commitFreeText(typed, 'keyboard');
      return;
    }
    // Constrained-choice + no match: invalid.
    this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { query: typed } }));
    this.#syncInputDisplay();
    this.#query = '';
    this.#renderOptions();
  }

  #commitOnTab() {
    if (!this.open) return;
    const opts = this.#renderedOptions();
    const active = this.#activeIndex >= 0 ? opts[this.#activeIndex] : null;
    if (active) {
      this.#commitOption(active.dataset.value || '', 'keyboard');
      return;
    }
    const typed = (this.#inputEl.textContent || '').trim();
    if (typed && (this.freeText || this.creatable)) {
      if (this.creatable && !this.#findOption(typed)) {
        this.dispatchEvent(new CustomEvent('create', { bubbles: true, detail: { value: typed } }));
      }
      this.#commitFreeText(typed, 'keyboard');
      return;
    }
    // No active + no free-text: restore + close.
    this.#syncInputDisplay();
    this.#query = '';
    this.#closePopover('blur');
  }

  #commitOption(value, source) {
    if (this.readonly) {
      this.#closePopover('select');
      return;
    }
    const opt = this.#findOption(value);
    untracked(() => {
      this.value = value;
    });
    this.#lastCommittedValue = value;
    this.#query = '';
    this.syncValue(value);
    this.#syncInputDisplay();
    this.#renderOptions();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value, option: opt, source },
    }));
    this.open = false;
    this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'select' } }));
  }

  #commitFreeText(value, source) {
    if (this.readonly) {
      this.#closePopover('select');
      return;
    }
    untracked(() => {
      this.value = value;
    });
    this.#lastCommittedValue = value;
    this.#query = '';
    this.syncValue(value);
    this.#syncInputDisplay();
    this.#renderOptions();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value, option: null, source },
    }));
    this.open = false;
    this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'select' } }));
  }

  // ── Clear ──

  #handleClearClick(e) {
    e.preventDefault();
    e.stopPropagation();
    this.#clear('click');
    this.#inputEl?.focus();
  }

  #clear(source) {
    untracked(() => { this.value = ''; });
    this.#lastCommittedValue = '';
    this.#query = '';
    this.syncValue('');
    this.#syncInputDisplay();
    this.#renderOptions();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: '', option: null, source },
    }));
  }

  clear() { this.#clear('programmatic'); }

  // ── Suffix (caret) toggles popover ──

  #handleSuffixClick(e) {
    e.preventDefault();
    e.stopPropagation();
    if (this.disabled) return;
    if (this.open) {
      this.open = false;
      this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'outside' } }));
    } else {
      this.open = true;
      this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'click' } }));
      this.#inputEl?.focus();
    }
  }

  // ── Outside-click + popover anchor ──

  #handleOutside(e) {
    const insideHost = this.contains(e.target);
    const insidePopover = this.#listbox && e.composedPath().includes(this.#listbox);
    if (!insideHost && !insidePopover) {
      // Restore the displayed text to the last committed value if there
      // was an in-flight unmatched query.
      if (this.#query && !this.freeText) {
        this.#syncInputDisplay();
        this.#query = '';
      }
      this.open = false;
      this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'outside' } }));
    }
  }

  #closeAndRestore(reason) {
    this.#syncInputDisplay();
    this.#query = '';
    this.#renderOptions();
    this.open = false;
    this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason } }));
  }

  #openPopover() {
    if (!this.#listbox) return;
    this.#listbox.showPopover?.();
    const trigger = this.querySelector(':scope > [data-field]') || this;
    this.#listbox.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
    this.#anchorCleanup?.();
    this.#anchorCleanup = anchorPopover(trigger, this.#listbox, {
      placement: this.getAttribute('placement') || 'bottom-start',
      gap: 4,
      matchWidth: false,
    });
    // Defer registration so the current click that opened us doesn't
    // immediately close us.
    if (this.#rafId != null) cancelAnimationFrame(this.#rafId);
    this.#rafId = requestAnimationFrame(() => {
      this.#rafId = null;
      document.addEventListener('pointerdown', this.#onOutside);
    });
    // Initial active option: the selected one if visible, else first.
    const opts = this.#renderedOptions();
    if (!opts.length) return;
    const selectedIdx = opts.findIndex((o) => o.dataset.value === this.value);
    this.#setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
  }

  #closePopover(reason) {
    if (!this.#listbox) return;
    this.#anchorCleanup?.();
    this.#anchorCleanup = null;
    this.#listbox.hidePopover?.();
    if (this.#rafId != null) {
      cancelAnimationFrame(this.#rafId);
      this.#rafId = null;
    }
    document.removeEventListener('pointerdown', this.#onOutside);
    this.#activeIndex = -1;
    this.#inputEl?.removeAttribute('aria-activedescendant');
    if (reason) {
      // Note: caller usually dispatches close; this is a no-op signal path.
    }
  }

  // ── Display sync ──

  #syncInputDisplay() {
    if (!this.#inputEl) return;
    const opt = this.#findOption(this.value);
    const text = opt ? (opt.label ?? opt.value ?? '') : (this.value || '');
    if ((this.#inputEl.textContent || '') !== text) {
      this.#suppressInput = true;
      this.#inputEl.textContent = text;
      this.#suppressInput = false;
    }
    this.#inputEl.toggleAttribute('data-empty', !text);
  }

  // ── Public methods ──

  open$() { this.open = true; }
  close$() { this.open = false; }

  focus() { this.#inputEl?.focus(); }

  selectOption(value) {
    const opt = this.#findOption(value);
    if (!opt) return false;
    this.#commitOption(value, 'programmatic');
    return true;
  }

  // ── Form value sync override ──

  syncValue(val) {
    const v = val ?? this.value ?? '';
    super.syncValue(String(v));
  }
}
