/**
 * Non-side-effect class export for `<select-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/select`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

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

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

export class UISelect extends UIFormElement {
  static labelDeprecated = false;  // §170 (v0.5.4): label is first-class per select.yaml
  // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
  // consumer markup). Aggregated by installIconLoadersForRegistered()
  // across all defined elements. Audited by check-required-icons.mjs
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
  // SPEC-040: [multiple] mode stamps <tag-ui> chips (own `x`), checkbox
  // indicator (`check`), and search-input prefix (`magnifying-glass`).
  static requiredIcons = ['caret-up-down', 'check', 'x', 'magnifying-glass'];

  // §225 (v0.5.9, FEEDBACK-10 §3): once-per-element console.warn dedup when
  // consumer authors children that aren't native <option>/<optgroup>. The
  // common authoring mistake is reaching for <select-option> (mirroring the
  // <*-ui> naming convention), which #parseOptions silently ignores. Matches
  // v0.5.5 §184 §8 button-ui safety-net + v0.5.7 §201 color-picker WeakSet
  // pattern. WeakSet so the warn doesn't pin the element from GC.
  static #warnedNonOption = new WeakSet();

  static properties = {
    ...UIFormElement.properties,
    placeholder: { type: String,  default: 'Select...', 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 },
    label:       { type: String,  default: '', reflect: true },
    icon:        { type: String,  default: '', reflect: true },
    avatar:      { type: String,  default: '', reflect: true },
    multiple:    { type: Boolean, default: false, reflect: true },
    searchable:  { type: Boolean, default: false, reflect: true },
    freeText:    { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
    divider:     { type: Boolean, default: false, reflect: true },
    // §184 (v0.5.5, FEEDBACK-08 §7): optional caption beneath the
    // trigger, wired to aria-describedby on the host.
    hint:        { type: String,  default: '', reflect: true },
    // SPEC-040 (multi-select): chip trigger + checkbox list extensions.
    // These are no-ops without [multiple] but reflect so [maxChips]
    // selectors etc. work in CSS even when authored declaratively.
    maxChips:    { type: Number,  default: 0, reflect: true, attribute: 'max-chips' },
    min:         { type: Number,  default: 0, reflect: true },
    max:         { type: Number,  default: 0, reflect: true },
    selectAll:   { type: Boolean, default: false, reflect: true, attribute: 'select-all' },
    clearable:   { type: Boolean, default: false, reflect: true },
  };

  // §184: per-instance hint id counter for aria-describedby wiring.
  static #hintSeq = 0;

  static template = () => null;

  #options = [];
  #ownTrigger = false; // true when WE stamped the trigger (vs a consumer-custom one)
  #listbox = null;
  #anchorCleanup = null;
  #query = '';
  #searchInput = null;
  #rafId = null;
  // FB-98: re-parse interpolated <option> children (.map()/repeat()) that arrive
  // wrapper-nested and/or after connected(); torn down in disconnected().
  #optionObserver = null;

  #onSearchFocus = () => { if (!this.disabled) this.open = true; };
  #onSearchClick = (e) => { e.stopPropagation(); if (!this.disabled) this.open = true; };

  #onOptionClick = (e) => {
    const el = e.currentTarget;
    e.stopPropagation();
    const opt = el.__adiaOption;
    if (!opt) return;
    if (opt.disabled) return;
    if (opt.action) {
      this.open = false;
      this.dispatchEvent(new CustomEvent('action', { bubbles: true, detail: { action: opt.action } }));
      return;
    }
    // SPEC-040 — multi-select: toggle membership without closing popover.
    if (this.multiple) {
      this.toggle(opt.value);
      return;
    }
    this.value = opt.value;
    this.open = false;
    this.#query = '';
    this.syncValue(opt.value);
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  };

  // SPEC-040 — internal helpers for [multiple] mode.

  /**
   * Override syncValue to enforce min/max constraints in multi-select mode.
   * When value.length < min, set form validity to invalid (tooShort).
   * When value.length > max, set form validity to invalid (tooLong).
   * Single-select path delegates straight to the base implementation.
   */
  syncValue(val) {
    if (!this.multiple) return super.syncValue(val);
    const v = val ?? this.value ?? '';
    this.internals.setFormValue(v);
    const arr = String(v).split(',').map((s) => s.trim()).filter(Boolean);
    if (this.min > 0 && arr.length < this.min) {
      this.internals.setValidity(
        { tooShort: true },
        this.getAttribute('data-msg-min') || `Please select at least ${this.min}.`,
        this,
      );
      return;
    }
    if (this.max > 0 && arr.length > this.max) {
      this.internals.setValidity(
        { tooLong: true },
        this.getAttribute('data-msg-max') || `Please select no more than ${this.max}.`,
        this,
      );
      return;
    }
    if (this.required && arr.length === 0) {
      this.internals.setValidity(
        { valueMissing: true },
        this.getAttribute('data-msg-required') || 'This field is required.',
        this,
      );
      return;
    }
    this.internals.setValidity({});
  }

  /**
   * Parse the current value as an array of selected ids (multi-select).
   * Returns an empty array for empty / falsy values.
   */
  #values() {
    if (!this.value) return [];
    return String(this.value).split(',').map((s) => s.trim()).filter(Boolean);
  }

  /**
   * Serialize an array of ids back to the canonical comma-separated form,
   * de-duplicating in insertion order (Set semantics per SPEC-040 §3).
   */
  #serialize(arr) {
    const seen = new Set();
    const out = [];
    for (const v of arr) {
      if (v == null || v === '') continue;
      const k = String(v);
      if (seen.has(k)) continue;
      seen.add(k);
      out.push(k);
    }
    return out.join(',');
  }

  /**
   * Public method (per SPEC-040 §4): toggle one option's selection in
   * multi-select mode. Single-select callers should set `value` directly.
   */
  toggle(id) {
    if (!this.multiple) {
      this.value = id;
      this.syncValue(id);
      this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
      return;
    }
    const cur = this.#values();
    const idx = cur.indexOf(String(id));
    const prev = cur.slice();
    let added = [];
    let removed = [];
    if (idx >= 0) {
      cur.splice(idx, 1);
      removed = [String(id)];
    } else {
      // SPEC-040 §4 max: suppress toggling past the cap.
      if (this.max > 0 && cur.length >= this.max) {
        this.dispatchEvent(new CustomEvent('invalid', {
          bubbles: true,
          detail: { value: prev, reason: 'max' },
        }));
        return;
      }
      cur.push(String(id));
      added = [String(id)];
    }
    const next = this.#serialize(cur);
    this.value = next;
    this.syncValue(next);
    // Re-render listbox aria-selected + chips
    this.render();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: cur, added, removed },
    }));
  }

  /**
   * Public method (SPEC-040 §4): select every non-disabled option.
   */
  selectAllOptions() {
    if (!this.multiple) return;
    const flat = this.#options.flatMap((o) => o.options || [o]).filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
    const next = this.#serialize(flat.map((o) => o.value));
    if (next === this.value) return;
    const prev = this.#values();
    this.value = next;
    this.syncValue(next);
    this.render();
    const nextArr = this.#values();
    const added = nextArr.filter((v) => !prev.includes(v));
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: nextArr, added, removed: [] },
    }));
  }

  /**
   * Public method (SPEC-040 §4): clear all selections.
   */
  clear() {
    if (!this.multiple) {
      this.value = '';
      this.syncValue('');
      this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '' } }));
      return;
    }
    const prev = this.#values();
    if (prev.length === 0) return;
    this.value = '';
    this.syncValue('');
    this.render();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: [], added: [], removed: prev },
    }));
  }

  /**
   * Internal: remove the last chip (Backspace handling).
   */
  #removeLastChip() {
    const cur = this.#values();
    if (cur.length === 0) return;
    const removed = cur.pop();
    const next = this.#serialize(cur);
    this.value = next;
    this.syncValue(next);
    this.render();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: cur, added: [], removed: [removed] },
    }));
  }

  /**
   * Internal: handler for tag-ui `remove` events bubbling out of chips.
   */
  #onChipRemove = (e) => {
    if (!this.multiple) return;
    const target = e.target;
    if (!target || target.tagName !== 'TAG-UI') return;
    const id = target.dataset.chipValue;
    if (!id) return;
    e.stopPropagation();
    // `tag-ui` already removes itself from the DOM; rebuild value as a
    // single source of truth and re-stamp.
    const cur = this.#values().filter((v) => v !== id);
    const next = this.#serialize(cur);
    this.value = next;
    this.syncValue(next);
    this.render();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: cur, added: [], removed: [id] },
    }));
  };

  /**
   * Internal: handler for the "+N more" pill click — opens the popover.
   */
  #onMoreClick = (e) => {
    e.stopPropagation();
    if (this.disabled) return;
    this.open = true;
  };

  /**
   * Internal: handler for the clear-all `x` on the trigger.
   */
  #onClearAllClick = (e) => {
    e.stopPropagation();
    if (this.disabled || this.readonly) return;
    this.clear();
  };

  /**
   * Internal: handler for the "Select all" / "Clear" header button.
   */
  #onSelectAllClick = (e) => {
    e.stopPropagation();
    if (this.disabled || this.readonly) return;
    const flat = this.#options.flatMap((o) => o.options || [o]).filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
    const allIds = flat.map((o) => String(o.value));
    const cur = new Set(this.#values());
    const everyone = allIds.length > 0 && allIds.every((id) => cur.has(id));
    if (everyone) this.clear();
    else this.selectAllOptions();
  };

  // FB-88: tracks whether the host's current aria-label was derived from
  // `placeholder` (vs author-supplied), so re-renders refresh/clear OUR name
  // without ever clobbering an author's aria-label.
  #placeholderNamed = false;

  /**
   * Derive an accessible name from `placeholder` when the select is otherwise
   * unnamed — mirrors input-ui so a placeholder-only select (the idiomatic
   * filter-bar shape) still has a name for assistive tech (WCAG 4.1.2, FB-88).
   *
   * Guards (a placeholder-only fix must NOT clobber a real label):
   *  • an author `aria-label`, a visible `label`, or an `aria-labelledby` wins;
   *  • the default 'Select...' prompt is NOT used as a name — otherwise every
   *    field-ui-wrapped select (named by a sibling `<label for>`, which aria-label
   *    would override) would be renamed to the generic prompt.
   */
  #syncAccessibleName() {
    const DEFAULT_PLACEHOLDER = 'Select...'; // matches static properties.placeholder.default
    const authoredAria = this.hasAttribute('aria-label') && !this.#placeholderNamed;
    const labeledElsewhere = !!this.label || this.hasAttribute('aria-labelledby');
    const namable = !authoredAria && !labeledElsewhere
      && this.placeholder && this.placeholder !== DEFAULT_PLACEHOLDER;
    if (namable) {
      this.setAttribute('aria-label', this.placeholder);
      this.#placeholderNamed = true;
    } else if (this.#placeholderNamed) {
      this.removeAttribute('aria-label');
      this.#placeholderNamed = false;
    }
  }

  connected() {
    super.connected();
    this.setAttribute('role', 'combobox');
    this.setAttribute('tabindex', '0');
    this.addEventListener('click', this.#onClick);
    this.addEventListener('keydown', this.#onKey);
    // SPEC-040: trap `remove` events from chip tag-ui children so the host
    // value array stays the single source of truth. Bubbles, so we
    // capture them at the host level — no per-chip wiring needed.
    this.addEventListener('remove', this.#onChipRemove);
    // Only parse declarative <option>/<optgroup> children if options
    // weren't already set programmatically (e.g. via .options = [...])
    if (this.#options.length === 0) {
      this.#parseOptions();
    }
    // FB-98: interpolated <option>/<optgroup> (.map()/repeat()) are nested in the
    // engine's display:contents wrapper spans and often applied in the template's
    // update() pass AFTER connected() — so the parse above can see nothing ("No
    // options"). Observe for them and re-parse once they appear. Guards: skip if
    // options are already parsed/set, and skip unless there's a real
    // <option>/<optgroup> to parse — so the component's own listbox/empty-state
    // renders (and #parseOptions's own option removals) don't re-enter or loop.
    if (!this.#optionObserver) {
      this.#optionObserver = new MutationObserver(() => {
        if (this.#options.length) return;
        const kids = this.#logicalOptionChildren();
        if (!kids.some((c) => c.tagName === 'OPTION' || c.tagName === 'OPTGROUP')) return;
        this.#parseOptions();
        if (this.#options.length) {
          this.#renderOptions();
          const display = this.querySelector('[slot="display"]');
          if (display) display.textContent = this.#displayText();
        }
      });
    }
    this.#optionObserver.observe(this, { childList: true, subtree: true });
  }

  render() {
    // SPEC-040 — in multi-select mode the trigger needs to host chips +
    // optional clear-x + optional "+N more" pill alongside the usual
    // leading / display / caret. We toggle host attribute [data-multi-chips]
    // so CSS can switch layout from single-line text to flex-wrap chip row.
    if (this.multiple) this.setAttribute('data-multi-chips', '');
    else this.removeAttribute('data-multi-chips');

    // A11y (FB-88): name a placeholder-only select from its placeholder, like
    // input-ui, so it isn't unnamed for assistive tech (WCAG 4.1.2).
    this.#syncAccessibleName();

    // Stamp default trigger if none provided
    if (!this.querySelector('[slot="trigger"]')) {
      this.#ownTrigger = true;
      // Detach listbox before innerHTML wipe so it isn't destroyed
      const lb = this.#listbox;
      if (lb?.parentNode === this) this.removeChild(lb);

      // Initial leading reflects the host [avatar]/[icon]; #syncLeading() then
      // reconciles it to the SELECTED option's icon/avatar on every render. The
      // `data-select-leading` marker scopes that reconciliation to our element.
      const leading = this.avatar
        ? `<img slot="leading" data-select-leading src="${escapeHTML(this.avatar)}" alt="" />`
        : this.icon
          ? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
          : '';
      const displayMarkup = this.searchable
        ? `<input slot="display" type="text" role="combobox" aria-autocomplete="list" autocomplete="off" placeholder="${escapeHTML(this.placeholder || '')}" value="${escapeHTML(this.#displayText() === this.placeholder ? '' : this.#displayText())}" />`
        : `<span slot="display">${escapeHTML(this.#displayText())}</span>`;
      // §184 (v0.5.5, FEEDBACK-08 §7): optional hint slot beneath the
      // trigger. Mirrors slider-ui's hint pattern + matches the
      // schema-declared `hint` prop on switch-ui (which had the spec
      // but not the rendering until this arc).
      const hintId = this.hint ? `select-hint-${++UISelect.#hintSeq}` : '';
      const hintMarkup = this.hint ? `<span slot="hint" id="${hintId}">${escapeHTML(this.hint)}</span>` : '';
      // SPEC-040 — trigger needs a [data-chips] slot we can stamp tag-ui
      // into in multi-select mode. The chip row sits BEFORE [slot="display"]
      // so the display (placeholder or search input) renders inline at the
      // end of the chips, matching standard multi-select UX.
      this.innerHTML = `
        <span slot="trigger">
          ${leading}
          <span data-chips></span>
          ${displayMarkup}
          <button type="button" data-clear-all aria-label="Clear all" hidden>
            <icon-ui name="x"></icon-ui>
          </button>
          <icon-ui name="caret-up-down" slot="caret"></icon-ui>
        </span>
        ${hintMarkup}
      `;
      if (this.hint) this.setAttribute('aria-describedby', hintId);

      // Wire clear-all click once per stamp.
      const clearBtn = this.querySelector('[data-clear-all]');
      if (clearBtn) {
        clearBtn.removeEventListener('click', this.#onClearAllClick);
        clearBtn.addEventListener('click', this.#onClearAllClick);
      }

      if (this.searchable) {
        // Detach from previous search input if any
        if (this.#searchInput) {
          this.#searchInput.removeEventListener('input', this.#onSearchInput);
          this.#searchInput.removeEventListener('focus', this.#onSearchFocus);
          this.#searchInput.removeEventListener('click', this.#onSearchClick);
        }
        this.#searchInput = this.querySelector('input[slot="display"]');
        if (this.#searchInput) {
          this.#searchInput.addEventListener('input', this.#onSearchInput);
          this.#searchInput.addEventListener('focus', this.#onSearchFocus);
          this.#searchInput.addEventListener('click', this.#onSearchClick);
        }
      }

      // Re-attach listbox after trigger is stamped
      if (lb) this.appendChild(lb);
    } else {
      const display = this.querySelector('[slot="display"]');
      if (display && !this.multiple) {
        // Single-select: keep the canonical text-rendering path.
        if (display.tagName === 'INPUT') {
          // Only update value when not actively editing (no active query)
          if (!this.#query) display.value = this.#displayText() === this.placeholder ? '' : this.#displayText();
        } else {
          display.textContent = this.#displayText();
        }
      } else if (display && this.multiple) {
        // Multi-select: display element holds the placeholder ONLY when
        // no chips are present (otherwise the chip row IS the display).
        const hasChips = this.#values().length > 0;
        if (display.tagName === 'INPUT') {
          // search input — keep placeholder; never inject the value text.
          display.placeholder = hasChips ? '' : (this.placeholder || '');
          if (!this.#query) display.value = '';
        } else {
          display.textContent = hasChips ? '' : (this.placeholder || '');
        }
      }
    }

    // Reflect the selected option's icon/avatar in the trigger leading.
    this.#syncLeading();

    // SPEC-040 — stamp / reconcile chips + "+N more" pill on every render.
    if (this.multiple) this.#stampChips();
    // Show clear-all only in multi-select mode when [clearable] + chips present.
    const clearBtn = this.querySelector('[data-clear-all]');
    if (clearBtn) {
      const show = this.multiple && this.clearable && this.#values().length > 0 && !this.disabled && !this.readonly;
      if (show) clearBtn.removeAttribute('hidden');
      else clearBtn.setAttribute('hidden', '');
    }

    // Ensure listbox exists (regardless of trigger source)
    if (!this.#listbox) {
      this.#listbox = this.querySelector('[slot="listbox"]');
      if (!this.#listbox) {
        this.#listbox = document.createElement('div');
        this.#listbox.setAttribute('slot', 'listbox');
        this.#listbox.setAttribute('role', 'listbox');
        this.#listbox.setAttribute('popover', 'manual');
        this.appendChild(this.#listbox);
      }
      this.#renderOptions();
    }

    // Sync aria-selected when value changes
    // §FB-46: [multiple] value is comma-separated — use Set membership.
    if (this.#listbox) {
      const selSet = this.multiple
        ? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
        : null;
      for (const opt of this.#listbox.querySelectorAll('[role="option"]')) {
        const v = opt.getAttribute('data-value');
        const selected = selSet ? selSet.has(v) : v === this.value;
        if (selected) opt.setAttribute('aria-selected', 'true');
        else opt.removeAttribute('aria-selected');
      }
    }

    this.setAttribute('aria-expanded', String(this.open));

    // Toggle popover
    if (this.open) {
      this.#listbox?.showPopover?.();
      const trigger = this.querySelector('[slot="trigger"]') || this;
      this.#listbox.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
      this.#listbox.style.width = 'max-content';
      this.#anchorCleanup = anchorPopover(trigger, this.#listbox, {
        placement: this.getAttribute('placement') || 'bottom-start', gap: 4, matchWidth: false,
      });
      // Dismiss on outside click (next frame so current click doesn't trigger it)
      this.#rafId = requestAnimationFrame(() => {
        this.#rafId = null;
        document.addEventListener('pointerdown', this.#onOutside, { once: true });
      });
    } else {
      this.#anchorCleanup?.();
      this.#anchorCleanup = null;
      this.#listbox?.hidePopover?.();
      if (this.#rafId != null) {
        cancelAnimationFrame(this.#rafId);
        this.#rafId = null;
      }
      document.removeEventListener('pointerdown', this.#onOutside);
    }
  }

  /** Parse <option> and <optgroup> children into internal model. */
  // FB-98: collect declarative <option>/<optgroup> children — direct OR nested
  // inside the template engine's display:contents wrapper spans (which carry
  // role="presentation" since 0.7.2) — so .map()/repeat()-rendered options are
  // seen, not just static literals. Skips component-stamped [slot] children
  // (display/listbox/trigger). Mirrors tree-ui's #logicalSlotChildren (FB-96).
  #logicalOptionChildren() {
    const out = [];
    const walk = (parent) => {
      for (const ch of parent.children) {
        if (ch.hasAttribute('slot')) continue;                  // component-stamped — skip, don't descend
        if (ch.tagName === 'OPTION' || ch.tagName === 'OPTGROUP') { out.push(ch); continue; }
        if (ch.matches('[role="presentation"]') || ch.style?.display === 'contents') { walk(ch); continue; }
        out.push(ch);                                           // unknown authored child — caller warns
      }
    };
    walk(this);
    return out;
  }

  #parseOptions() {
    this.#options = [];
    // §225 (v0.5.9): track unknown-child-tag names so we can warn once per element.
    const unknownTags = new Set();
    // FEEDBACK-36: capture the value of an <option selected> child so a
    // declarative initial selection is honoured (native <select> parity).
    // §FB-46: collect ALL selected options for [multiple] mode (not just first).
    const preSelectedArr = [];
    // FB-98: iterate wrapper-pierced logical children so interpolated options work.
    for (const child of this.#logicalOptionChildren()) {
      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, icon: opt.getAttribute('icon') || '', avatar: opt.getAttribute('avatar') || '' });
          if (opt.hasAttribute('selected')) preSelectedArr.push(opt.value);
        }
        this.#options.push(group);
      } else if (child.tagName === 'OPTION') {
        this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled, icon: child.getAttribute('icon') || '', avatar: child.getAttribute('avatar') || '' });
        if (child.hasAttribute('selected')) preSelectedArr.push(child.value);
      } else if (
        // §225: skip [slot="display"] / [slot="listbox"] / [slot="action"] etc. — these are
        // stamped by the component itself, not consumer-authored options. Anything other
        // than a slot-bearing child OR <option>/<optgroup> is a likely authoring mistake.
        !child.hasAttribute('slot')
      ) {
        unknownTags.add(child.tagName.toLowerCase());
      }
    }
    for (const child of [...this.querySelectorAll('option, optgroup')]) child.remove();
    // FEEDBACK-36: honour <option selected> as the initial value when the host
    // carries no [value] — matches native <select> semantics. Guarded on an
    // empty this.value so consumers who set [value] explicitly are unaffected.
    // §FB-46: for [multiple], join all selected values with comma.
    if (!this.value && preSelectedArr.length) {
      this.value = this.multiple ? preSelectedArr.join(',') : preSelectedArr[0];
    }
    // §225 (v0.5.9): warn once per element when consumer authored non-<option> children.
    if (unknownTags.size > 0 && !UISelect.#warnedNonOption.has(this)) {
      UISelect.#warnedNonOption.add(this);
      // eslint-disable-next-line no-console
      console.warn(
        `<select-ui>: ignoring unrecognized child element(s) [${[...unknownTags].join(', ')}]. ` +
        `Consumer-authored children must be native <option> or <optgroup>, NOT <select-option> ` +
        `or other <*-ui> shapes. Or set element.options = [...] programmatically. ` +
        `(Once-per-element warn; see select.yaml usage: for details.)`,
      );
    }
  }

  set options(list) {
    // Wrap the internal rebuild in `untracked` so reads of `this.value` inside
    // `#renderOptions()` (for aria-selected) and `#displayText()` (for the
    // trigger label) don't subscribe a calling effect to this element's value
    // signal. Without this, setting `.options` followed by `.value` from
    // inside an effect creates a self-trigger cycle — the effect subscribes
    // to value via #renderOptions, then notifies itself via the value write,
    // and re-runs until the drain-loop guard fires.
    untracked(() => {
      this.#options = list;
      // Ensure listbox exists before rendering (may be called before first render)
      if (!this.#listbox) {
        this.#listbox = this.querySelector('[slot="listbox"]');
        if (!this.#listbox) {
          this.#listbox = document.createElement('div');
          this.#listbox.setAttribute('slot', 'listbox');
          this.#listbox.setAttribute('role', 'listbox');
          this.#listbox.setAttribute('popover', 'manual');
          this.appendChild(this.#listbox);
        }
      }
      this.#renderOptions();
      const display = this.querySelector('[slot="display"]');
      if (display) display.textContent = this.#displayText();
    });
  }

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

  // Per-option leading markup: avatar (img) beats icon (icon-ui). Shared by
  // the listbox rows AND the trigger (resolved against the selected option).
  static #optionLeadHTML(opt) {
    if (!opt) return '';
    if (opt.avatar) return `<img data-option-avatar src="${escapeHTML(opt.avatar)}" alt="" />`;
    if (opt.icon) return `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>`;
    return '';
  }

  /**
   * Reflect the SELECTED option's icon/avatar in the trigger's leading slot.
   * Single-select only (multi-select shows chips). Falls back to the host
   * [avatar]/[icon] when the selected option carries neither. Only manages the
   * leading WE stamped (`[data-select-leading]`) — a consumer-custom trigger
   * owns its own leading.
   */
  #syncLeading() {
    if (this.multiple || !this.#ownTrigger) return;
    const trigger = this.querySelector('[slot="trigger"]');
    if (!trigger) return;
    const flat = this.#options.flatMap((o) => o.options || [o]);
    const sel = flat.find((o) => !o.header && !o.separator && o.value === this.value);
    const avatar = (sel && sel.avatar) || this.avatar || '';
    const icon = (sel && sel.icon) || this.icon || '';
    const existing = trigger.querySelector(':scope > [data-select-leading]');
    let html = '';
    if (avatar) html = `<img slot="leading" data-select-leading src="${escapeHTML(avatar)}" alt="" />`;
    else if (icon) html = `<icon-ui slot="leading" data-select-leading name="${escapeHTML(icon)}"></icon-ui>`;
    if (!html) { existing?.remove(); return; }
    const tmp = document.createElement('template');
    tmp.innerHTML = html;
    const next = tmp.content.firstElementChild;
    if (!existing) {
      trigger.insertBefore(next, trigger.firstChild);
    } else if (existing.tagName === next.tagName) {
      // Same element type — update the changing attribute in place.
      if (next.tagName === 'IMG') {
        if (existing.getAttribute('src') !== next.getAttribute('src')) existing.setAttribute('src', next.getAttribute('src'));
      } else if (existing.getAttribute('name') !== next.getAttribute('name')) {
        existing.setAttribute('name', next.getAttribute('name'));
      }
    } else {
      existing.replaceWith(next); // icon ↔ avatar switch
    }
  }

  #renderOptions() {
    if (!this.#listbox) return;
    this.#listbox.innerHTML = '';

    // SPEC-040 — multi-select listbox is aria-multiselectable; checkbox
    // indicator on each option row marks selection visually.
    if (this.multiple) this.#listbox.setAttribute('aria-multiselectable', 'true');
    else this.#listbox.removeAttribute('aria-multiselectable');

    // SPEC-040 — Select-all / Clear control row at the top of the list.
    if (this.multiple && this.selectAll) {
      const header = document.createElement('div');
      header.setAttribute('data-select-all', '');
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.dataset.selectAllBtn = '';
      btn.textContent = this.#allSelected() ? 'Clear' : 'Select all';
      btn.addEventListener('click', this.#onSelectAllClick);
      header.appendChild(btn);
      this.#listbox.appendChild(header);
    }

    const renderOpt = (opt) => {
      if (opt.separator) {
        const hr = document.createElement('hr');
        hr.setAttribute('data-separator', '');
        return hr;
      }
      const el = document.createElement('div');
      el.setAttribute('role', 'option');
      el.setAttribute('data-value', opt.value || '');
      // §FB-46: [multiple] value is comma-separated — use Set membership.
      const selSet = this.multiple
        ? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
        : null;
      const isSelected = selSet ? selSet.has(opt.value) : opt.value === this.value;
      // SPEC-040 — multi-select option rows render a leading checkbox
      // indicator (CSS-driven via [data-multi-option]); the `check` icon
      // shows when aria-selected="true".
      // Per-option leading glyph — avatar (img) wins over icon (icon-ui).
      const lead = UISelect.#optionLeadHTML(opt);
      if (this.multiple) {
        el.setAttribute('data-multi-option', '');
        const box = document.createElement('span');
        box.setAttribute('data-checkbox', '');
        box.innerHTML = '<icon-ui name="check" aria-hidden="true"></icon-ui>';
        el.appendChild(box);
        const label = document.createElement('span');
        label.setAttribute('data-option-label', '');
        if (lead) label.innerHTML = `${lead}${escapeHTML(opt.label)}`;
        else label.textContent = opt.label;
        el.appendChild(label);
      } else if (lead) {
        el.innerHTML = `${lead}${escapeHTML(opt.label)}`;
      } else {
        el.textContent = opt.label;
      }
      if (isSelected) el.setAttribute('aria-selected', 'true');
      if (opt.disabled) el.setAttribute('aria-disabled', 'true');
      if (opt.action) el.dataset.action = opt.action;
      el.__adiaOption = opt;
      el.addEventListener('click', this.#onOptionClick);
      return el;
    };

    for (const item of this.#options) {
      if (item.separator) {
        this.#listbox.appendChild(renderOpt(item));
      } else if (item.header) {
        const hdr = document.createElement('div');
        hdr.setAttribute('data-menu-header', '');
        hdr.innerHTML = item.avatar
          ? `<img src="${item.avatar}" alt="" /><div><strong>${escapeHTML(item.label)}</strong><span>${escapeHTML(item.description || '')}</span></div>`
          : `<div><strong>${escapeHTML(item.label)}</strong><span>${escapeHTML(item.description || '')}</span></div>`;
        this.#listbox.appendChild(hdr);
      } else if (item.options) {
        const group = document.createElement('div');
        group.setAttribute('role', 'group');
        group.innerHTML = `<div slot="group-label">${escapeHTML(item.label)}</div>`;
        for (const opt of item.options) group.appendChild(renderOpt(opt));
        this.#listbox.appendChild(group);
      } else {
        this.#listbox.appendChild(renderOpt(item));
      }
    }

    // SPEC-040 — empty-state stamp when there are no consumer options.
    if (this.#options.length === 0) {
      const empty = document.createElement('div');
      empty.setAttribute('data-empty', '');
      empty.textContent = 'No options';
      this.#listbox.appendChild(empty);
    }

    if (this.#query) this.#applyFilter();
  }

  /**
   * SPEC-040 — returns true when every non-disabled option is currently
   * selected. Used by the Select-all header to flip its label to "Clear".
   */
  #allSelected() {
    if (!this.multiple) return false;
    const flat = this.#options.flatMap((o) => o.options || [o])
      .filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
    if (flat.length === 0) return false;
    const cur = new Set(this.#values());
    return flat.every((o) => cur.has(String(o.value)));
  }

  /**
   * SPEC-040 — reconcile the trigger chip row against the current value.
   * Tag-ui children carry [data-chip-value] so we can identify which chip
   * fired a `remove` event without re-parsing labels.
   */
  #stampChips() {
    const slot = this.querySelector('[slot="trigger"] [data-chips]');
    if (!slot) return;
    const values = this.#values();
    const cap = Number(this.maxChips) || 0;
    const cappedValues = cap > 0 && values.length > cap ? values.slice(0, cap) : values;
    const overflow = cap > 0 && values.length > cap ? values.length - cap : 0;
    // Label lookup from internal options model (handles grouped form too).
    const flat = this.#options.flatMap((o) => o.options || [o])
      .filter((o) => !o.separator && !o.header && o.value != null);
    const labelOf = (v) => {
      const found = flat.find((o) => String(o.value) === String(v));
      return found ? found.label : String(v);
    };
    // Rebuild — chips are cheap and reconciliation gets us correct order
    // + correct labels with no per-chip tracking state.
    while (slot.firstChild) slot.removeChild(slot.firstChild);
    for (const v of cappedValues) {
      const tag = document.createElement('tag-ui');
      tag.setAttribute('size', 'sm');
      // Only show the dismiss `x` when interactive (matches [disabled]/[readonly] gating).
      if (!this.disabled && !this.readonly) tag.setAttribute('removable', '');
      tag.dataset.chipValue = String(v);
      tag.setAttribute('text', labelOf(v));
      slot.appendChild(tag);
    }
    if (overflow > 0) {
      const more = document.createElement('button');
      more.type = 'button';
      more.dataset.more = '';
      more.textContent = `+${overflow} more`;
      more.addEventListener('click', this.#onMoreClick);
      slot.appendChild(more);
    }
  }

  #displayText() {
    if (this.multiple) {
      // SPEC-040 — chips render the selection; display element holds the
      // placeholder when empty and is otherwise empty (CSS sees [data-multi-chips]).
      return this.value ? '' : this.placeholder;
    }
    const flat = this.#options.flatMap(o => o.options || [o]);
    const selected = flat.find(o => o.value === this.value || (o.header && o.label === this.value));
    return selected?.label || this.value || this.placeholder;
  }

  #onClick = (e) => {
    if (this.disabled) return;
    // Ignore clicks inside the popover listbox
    if (this.#listbox && e.composedPath().includes(this.#listbox)) return;
    this.open = !this.open;
  };

  #onOutside = (e) => {
    // Close if click is outside the select and outside the popover
    if (!this.contains(e.target) && !(this.#listbox && e.composedPath().includes(this.#listbox))) {
      this.open = false;
    }
  };

  #onKey = (e) => {
    // SPEC-040 — Backspace from the trigger removes the last chip.
    // Two paths: (1) trigger focused, no search input — straight Backspace.
    // (2) search input focused with empty query — Backspace removes a chip
    // before it would start removing query characters.
    if (e.key === 'Backspace' && this.multiple && !this.disabled && !this.readonly) {
      const target = e.target;
      const fromInput = target && target.tagName === 'INPUT' && target.getAttribute('slot') === 'display';
      const queryEmpty = !this.#query || this.#query.length === 0;
      if (!fromInput || queryEmpty) {
        if (this.#values().length > 0) {
          e.preventDefault();
          this.#removeLastChip();
          return;
        }
      }
    }
    if (e.key === 'Escape') {
      if (this.searchable && this.#query) {
        // First Escape: clear query
        this.#query = '';
        const input = this.querySelector('input[slot="display"]');
        if (input) input.value = '';
        this.#applyFilter();
        return;
      }
      this.open = false;
      return;
    }
    if (e.key === 'Enter') {
      e.preventDefault();
      if (this.open) {
        const focused = this.#listbox?.querySelector('[role="option"][data-focused]:not([data-filtered-out])');
        if (focused) {
          focused.click();
          // SPEC-040 — Enter in multi-select commits + closes (matches the
          // WAI-APG commit pattern). #onOptionClick keeps the popover open
          // on plain clicks; Enter is the explicit commit.
          if (this.multiple) this.open = false;
        } else if (this.searchable && this.freeText && this.#query) {
          // Commit free-text
          const q = this.#query;
          this.value = q;
          this.#query = '';
          this.open = false;
          this.syncValue?.(q);
          this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
        } else {
          this.open = false;
        }
      } else {
        this.open = true;
      }
      return;
    }
    if (e.key === ' ' && !this.searchable) {
      e.preventDefault();
      if (this.open) {
        const focused = this.#listbox?.querySelector('[role="option"][data-focused]');
        if (focused) focused.click();
        else if (!this.multiple) this.open = false;
      } else {
        this.open = true;
      }
      return;
    }
    if (e.key === 'ArrowDown' && this.open) {
      e.preventDefault();
      this.#focusOption(1);
    }
    if (e.key === 'ArrowUp' && this.open) {
      e.preventDefault();
      this.#focusOption(-1);
    }
  };

  #onSearchInput = (e) => {
    this.#query = e.target.value || '';
    if (!this.open) this.open = true;
    this.#applyFilter();
  };

  #applyFilter() {
    if (!this.#listbox) return;
    const q = this.#query.toLowerCase();
    const opts = this.#listbox.querySelectorAll('[role="option"]');
    let anyVisible = false;
    for (const opt of opts) {
      const label = (opt.textContent || '').toLowerCase();
      const match = !q || label.includes(q);
      if (match) {
        opt.removeAttribute('data-filtered-out');
        opt.style.display = '';
        anyVisible = true;
      } else {
        opt.setAttribute('data-filtered-out', '');
        opt.style.display = 'none';
      }
    }
    // Hide empty groups
    for (const grp of this.#listbox.querySelectorAll('[role="group"]')) {
      const visible = grp.querySelectorAll('[role="option"]:not([data-filtered-out])').length;
      grp.style.display = visible ? '' : 'none';
    }
    // Clear focus if filtered-out
    const focused = this.#listbox.querySelector('[role="option"][data-focused]');
    if (focused && focused.hasAttribute('data-filtered-out')) {
      focused.removeAttribute('data-focused');
    }
  }

  #focusOption(dir) {
    if (!this.#listbox) return;
    const opts = [...this.#listbox.querySelectorAll('[role="option"]:not([aria-disabled]):not([data-filtered-out])')];
    if (!opts.length) return;
    const current = this.#listbox.querySelector('[role="option"][data-focused]');
    const i = current ? opts.indexOf(current) : -1;
    const next = opts[(i + dir + opts.length) % opts.length];
    if (current) current.removeAttribute('data-focused');
    if (next) {
      next.setAttribute('data-focused', '');
      next.scrollIntoView({ block: 'nearest' });
    }
  }

  disconnected() {
    super.disconnected();
    this.removeEventListener('click', this.#onClick);
    this.removeEventListener('keydown', this.#onKey);
    this.removeEventListener('remove', this.#onChipRemove);
    this.#optionObserver?.disconnect();   // FB-98
    if (this.#rafId != null) {
      cancelAnimationFrame(this.#rafId);
      this.#rafId = null;
    }
    document.removeEventListener('pointerdown', this.#onOutside);
    if (this.#searchInput) {
      this.#searchInput.removeEventListener('input', this.#onSearchInput);
      this.#searchInput.removeEventListener('focus', this.#onSearchFocus);
      this.#searchInput.removeEventListener('click', this.#onSearchClick);
      this.#searchInput = null;
    }
    this.#anchorCleanup?.();
    this.#anchorCleanup = null;
    this.#listbox?.hidePopover?.();
    this.#listbox = null;
  }
}
