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

/**
 * `<tags-input-ui>` — SPEC-042.
 *
 * Free-form token / chip input. Type a value, press Enter (or the
 * configured delimiter), and the typed value commits as a chip;
 * Backspace from the empty inline input removes the last chip.
 * Distinct from `<select-ui multiple>` (SPEC-040), which gates against
 * a fixed options list — this primitive is for OPEN sets (labels,
 * email recipients, keyword inputs).
 *
 * Per ADR-0025 the inline editor is a `contenteditable` surface; there
 * is no `<input>` element wrapped inside. Form participation goes
 * through `UIFormElement` + `ElementInternals`; the form value is a
 * JSON-serialized string array under `name`.
 *
 *   <tags-input-ui name="labels" placeholder="Add a label…"
 *                  transform="lowercase" max="10"></tags-input-ui>
 *
 *   <tags-input-ui id="to" name="to" placeholder="Email…"></tags-input-ui>
 *   <script>
 *     const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 *     document.getElementById('to').validateFn = (value) => EMAIL_RE.test(value);
 *   </script>
 */

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

let tagsInstanceSeq = 0;

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

export class UITagsInput extends UIFormElement {
  // §198-pattern: above-field labels handled by <field-ui>; the host
  // does not render an inert label itself. Opt out of the base-class
  // deprecation warning that targets per-control labels.
  static labelDeprecated = false;

  static requiredIcons = ['circle-notch'];

  // NOTE: we intentionally exclude `value` from the reactive-property
  // table even though UIFormElement declares it. `installProps()` runs
  // `Object.defineProperty(this, 'value', …)` on the instance, which
  // would clobber the array-shaped `get/set value` accessors below
  // (instance descriptors win over prototype accessors). We re-implement
  // the attribute-routing for `value` in `attributeChangedCallback`.
  static get properties() {
    const { value: _drop, ...rest } = UIFormElement.properties;
    void _drop;
    return {
      ...rest,
      placeholder: { type: String,  default: 'Add tag…', reflect: true },
      delimiter:   { type: String,  default: ',',         reflect: true },
      pasteSplit:  { type: String,  default: ',\n',       reflect: true, attribute: 'paste-split' },
      max:         { type: Number,  default: 0,           reflect: true },
      min:         { type: Number,  default: 0,           reflect: true },
      minLength:   { type: Number,  default: 1,           reflect: true, attribute: 'min-length' },
      maxLength:   { type: Number,  default: 0,           reflect: true, attribute: 'max-length' },
      unique:      { type: Boolean, default: true,        reflect: true },
      transform:   { type: String,  default: '',          reflect: true },
      size:        { type: String,  default: 'md',        reflect: true },
    };
  }

  static get observedAttributes() {
    // Re-add `value` so attribute-side writes route through our setter.
    const base = super.observedAttributes;
    return base.includes('value') ? base : [...base, 'value'];
  }

  attributeChangedCallback(name, oldV, newV) {
    if (name === 'value') {
      // String → array routing for declarative `value="…"` writes.
      if (newV != null && oldV !== newV) {
        const parsed = this.#parseValueAttr(newV);
        if (parsed) this.value = parsed;
      }
      return;
    }
    super.attributeChangedCallback(name, oldV, newV);
  }

  static template = () => null;

  // ── Instance state ──
  #value = [];                   // string[] — the committed tokens
  #suggestions = [];             // string[] — autocomplete hints
  #validateFn = null;            // (value, index) => boolean|Promise<boolean>
  #activeSuggestion = -1;
  #suppressInput = false;        // guard contenteditable resyncs during programmatic ops
  #pendingValidationId = 0;      // async-validator generation counter

  // ── Refs ──
  #chipList = null;
  #inputEl = null;
  #suggestEl = null;
  #spinnerEl = null;
  #instanceId = `tags-input-${++tagsInstanceSeq}`;

  // ── Stable handler refs (so removeEventListener finds them) ──
  #onInputEvent = () => this.#handleInput();
  #onKeydown = (e) => this.#handleKeydown(e);
  #onPaste = (e) => this.#handlePaste(e);
  #onChipRemove = (e) => this.#handleChipRemove(e);
  #onHostMousedown = (e) => this.#handleHostMousedown(e);
  #onSuggestionClick = (e) => this.#handleSuggestionClick(e);

  // ── Lifecycle ──

  connected() {
    super.connected();
    // Read `value` attribute as JSON array (when set declaratively).
    if (!this.#value.length && this.hasAttribute('value')) {
      const parsed = this.#parseValueAttr(this.getAttribute('value'));
      if (parsed) this.#value = parsed;
    }
    this.#stampShell();
    this.#renderChips();
    this.#syncStateAttrs();
    this.#syncFormValue();
  }

  disconnected() {
    super.disconnected();
    this.#teardownListeners();
    this.#chipList = null;
    this.#inputEl = null;
    this.#suggestEl = null;
    this.#spinnerEl = null;
  }

  render() {
    if (!this.#inputEl) return;
    // Disabled / readonly toggle the editable surface.
    if (this.disabled || this.readonly) {
      this.#inputEl.contentEditable = 'false';
    } else {
      this.#inputEl.contentEditable = 'plaintext-only';
    }
    this.#inputEl.setAttribute('data-placeholder', this.placeholder || '');
    // ARIA: combobox when suggestions wired, group otherwise.
    if (this.#suggestions.length) {
      this.setAttribute('role', 'combobox');
      this.setAttribute('aria-haspopup', 'listbox');
      this.setAttribute('aria-expanded', String(!!this.hasAttribute('suggesting')));
    } else {
      this.setAttribute('role', 'group');
      this.removeAttribute('aria-haspopup');
      this.removeAttribute('aria-expanded');
    }
    if (!this.hasAttribute('aria-label')) {
      this.setAttribute('aria-label', this.placeholder || 'Tags');
    }
  }

  // ── Public properties ──

  /** Current token list. Setting replaces the entire list. */
  set value(list) {
    untracked(() => {
      const next = Array.isArray(list)
        ? list.map((s) => String(s ?? ''))
        : (typeof list === 'string' ? this.#parseValueAttr(list) || [] : []);
      const before = this.#value.slice();
      this.#value = next;
      this.#renderChips();
      this.#syncStateAttrs();
      this.#syncFormValue();
      this.#announceChange(before, next, 'programmatic');
    });
  }

  get value() { return this.#value.slice(); }

  set suggestions(list) {
    untracked(() => {
      this.#suggestions = Array.isArray(list) ? list.map((s) => String(s ?? '')) : [];
      this.#renderSuggestions();
      this.render();
    });
  }

  get suggestions() { return this.#suggestions.slice(); }

  set validateFn(fn) {
    this.#validateFn = typeof fn === 'function' ? fn : null;
  }

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

  // ── Public methods ──

  /** Commit a token programmatically. Returns true on success. */
  addToken(text) {
    return this.#commitToken(String(text ?? ''), 'programmatic');
  }

  /** Remove a token by index. */
  removeToken(index) {
    const i = Number(index);
    if (!Number.isInteger(i) || i < 0 || i >= this.#value.length) return;
    this.#removeByIndex(i, 'programmatic');
  }

  /** Clear all tokens. */
  clear() {
    if (this.#value.length === 0) return;
    const before = this.#value.slice();
    this.#value = [];
    this.#renderChips();
    this.#syncStateAttrs();
    this.#syncFormValue();
    this.#announceChange(before, [], 'programmatic');
  }

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

  // ── Shell ──

  #stampShell() {
    if (this.querySelector(':scope > [data-chip-list]')) {
      this.#queryRefs();
      this.#bindListeners();
      return;
    }
    const suggestId = `${this.#instanceId}-suggestions`;
    const inputId = `${this.#instanceId}-input`;
    this.innerHTML = `
      <div data-chip-list role="list" aria-label="Tags"></div>
      <span data-inline-input
            id="${inputId}"
            contenteditable="plaintext-only"
            role="searchbox"
            tabindex="0"
            spellcheck="false"
            aria-autocomplete="list"
            aria-controls="${suggestId}"
            data-placeholder="${this.placeholder || ''}"
            data-empty></span>
      <span data-spinner hidden aria-hidden="true">
        <icon-ui name="circle-notch"></icon-ui>
      </span>
      <div data-suggestions id="${suggestId}" role="listbox" hidden></div>
    `;
    this.#queryRefs();
    this.#bindListeners();
  }

  #queryRefs() {
    this.#chipList = this.querySelector(':scope > [data-chip-list]');
    this.#inputEl = this.querySelector(':scope > [data-inline-input]');
    this.#spinnerEl = this.querySelector(':scope > [data-spinner]');
    this.#suggestEl = this.querySelector(':scope > [data-suggestions]');
  }

  #bindListeners() {
    if (this.#inputEl) {
      this.#inputEl.addEventListener('input', this.#onInputEvent);
      this.#inputEl.addEventListener('keydown', this.#onKeydown);
      this.#inputEl.addEventListener('paste', this.#onPaste);
    }
    if (this.#chipList) {
      this.#chipList.addEventListener('remove', this.#onChipRemove);
    }
    if (this.#suggestEl) {
      this.#suggestEl.addEventListener('click', this.#onSuggestionClick);
    }
    this.addEventListener('mousedown', this.#onHostMousedown);
  }

  #teardownListeners() {
    if (this.#inputEl) {
      this.#inputEl.removeEventListener('input', this.#onInputEvent);
      this.#inputEl.removeEventListener('keydown', this.#onKeydown);
      this.#inputEl.removeEventListener('paste', this.#onPaste);
    }
    if (this.#chipList) {
      this.#chipList.removeEventListener('remove', this.#onChipRemove);
    }
    if (this.#suggestEl) {
      this.#suggestEl.removeEventListener('click', this.#onSuggestionClick);
    }
    this.removeEventListener('mousedown', this.#onHostMousedown);
  }

  // ── Render chips ──

  #renderChips() {
    if (!this.#chipList) return;
    // Reconcile in place: existing chips that still match keep their nodes
    // (so the user's :hover / :focus / animation states don't flicker on
    // each typed-character render); excess chips get dropped; new chips
    // append at the end.
    const existing = Array.from(this.#chipList.querySelectorAll(':scope > tag-ui'));
    for (let i = 0; i < this.#value.length; i++) {
      const v = this.#value[i];
      let chip = existing[i];
      if (!chip) {
        chip = document.createElement('tag-ui');
        chip.setAttribute('role', 'listitem');
        this.#chipList.appendChild(chip);
      }
      chip.setAttribute('text', v);
      if (this.readonly || this.disabled) {
        chip.removeAttribute('removable');
      } else {
        chip.setAttribute('removable', '');
      }
      chip.setAttribute('data-index', String(i));
    }
    // Drop overflow chips.
    for (let i = this.#value.length; i < existing.length; i++) {
      existing[i].remove();
    }
  }

  #renderSuggestions() {
    if (!this.#suggestEl) return;
    const typed = this.#inputEl ? (this.#inputEl.textContent || '').trim().toLowerCase() : '';
    const list = this.#suggestions
      .filter((s) => !this.#value.includes(s))
      .filter((s) => !typed || s.toLowerCase().includes(typed));
    this.#suggestEl.replaceChildren();
    list.forEach((s, idx) => {
      const item = document.createElement('div');
      item.setAttribute('role', 'option');
      item.setAttribute('id', `${this.#instanceId}-opt-${idx}`);
      item.dataset.value = s;
      item.textContent = s;
      this.#suggestEl.appendChild(item);
    });
    const shouldShow = this.#suggestions.length > 0 && list.length > 0 && this.hasAttribute('editing');
    this.#suggestEl.hidden = !shouldShow;
    this.toggleAttribute('suggesting', shouldShow);
    if (shouldShow) {
      this.setAttribute('aria-expanded', 'true');
    } else {
      this.removeAttribute('aria-expanded');
    }
    // Reset active option when the rendered list changes.
    this.#activeSuggestion = -1;
    this.#inputEl?.removeAttribute('aria-activedescendant');
    for (const node of this.#suggestEl.children) node.removeAttribute('data-active');
  }

  // ── State attrs ──

  #syncStateAttrs() {
    this.toggleAttribute('populated', this.#value.length > 0);
    if (!this.#inputEl) return;
    const empty = !(this.#inputEl.textContent || '').length;
    this.#inputEl.toggleAttribute('data-empty', empty);
    this.toggleAttribute('editing', !empty);
  }

  #syncFormValue() {
    // Empty array → empty FormData entry (per spec §11). Non-empty → JSON.
    const payload = this.#value.length ? JSON.stringify(this.#value) : '';
    super.syncValue(payload);
    // Required-validation: empty array fails when [required].
    if (this.required && this.#value.length === 0) {
      this.internals.setValidity(
        { valueMissing: true },
        this.getAttribute('data-msg-required') || 'Please add at least one tag.',
        this,
      );
    } else if (this.min > 0 && this.#value.length < this.min) {
      this.internals.setValidity(
        { tooShort: true },
        this.getAttribute('data-msg-min')
          || `Please add at least ${this.min} tag${this.min === 1 ? '' : 's'}.`,
        this,
      );
    } else {
      this.internals.setValidity({});
    }
  }

  // ── Input handling ──

  #handleInput() {
    if (this.#suppressInput) return;
    const text = (this.#inputEl.textContent || '');
    // Delimiter-driven commit (when the user types the delimiter inline,
    // commit the substring before it and leave whatever's after as the new
    // typed buffer). Skipped when delimiter is the `enter` sentinel.
    if (this.delimiter && this.delimiter !== 'enter' && text.includes(this.delimiter)) {
      const parts = text.split(this.delimiter);
      const trailing = parts.pop();
      let anyCommitted = false;
      for (const fragment of parts) {
        if (fragment.length) {
          const committed = this.#commitToken(fragment, 'delimiter');
          if (committed) anyCommitted = true;
        }
      }
      // Anything committed (or not), reset the contenteditable to the trailing
      // remainder so subsequent keystrokes append after the delimiter.
      this.#suppressInput = true;
      this.#inputEl.textContent = trailing || '';
      this.#suppressInput = false;
      this.#placeCaretAtEnd();
      // Fall through to fire `input` with the new query state.
      this.#syncStateAttrs();
      this.#renderSuggestions();
      this.dispatchEvent(new CustomEvent('input', {
        bubbles: true,
        detail: { query: trailing || '' },
      }));
      return;
    }
    this.#syncStateAttrs();
    this.#renderSuggestions();
    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { query: text },
    }));
  }

  #handleKeydown(e) {
    const k = e.key;
    if (this.disabled || this.readonly) return;
    const text = this.#inputEl ? (this.#inputEl.textContent || '') : '';
    if (k === 'Enter') {
      e.preventDefault();
      // If a suggestion is highlighted, commit it; otherwise commit typed.
      if (this.#activeSuggestion >= 0) {
        const opt = this.#suggestEl?.children[this.#activeSuggestion];
        if (opt) {
          this.#commitToken(opt.dataset.value || '', 'suggestion');
          return;
        }
      }
      if (text) this.#commitToken(text, 'keyboard');
      return;
    }
    if (k === 'Backspace' && text.length === 0) {
      if (this.#value.length > 0) {
        e.preventDefault();
        this.#removeByIndex(this.#value.length - 1, 'backspace');
      }
      return;
    }
    if (k === 'ArrowDown') {
      if (!this.hasAttribute('suggesting')) return;
      e.preventDefault();
      this.#moveActiveSuggestion(1);
      return;
    }
    if (k === 'ArrowUp') {
      if (!this.hasAttribute('suggesting')) return;
      e.preventDefault();
      this.#moveActiveSuggestion(-1);
      return;
    }
    if (k === 'Escape') {
      if (this.hasAttribute('suggesting')) {
        e.preventDefault();
        this.#closeSuggestions();
      }
    }
  }

  #handlePaste(e) {
    if (this.disabled || this.readonly) return;
    const raw = e.clipboardData?.getData('text/plain') || '';
    if (!raw) return;
    if (!this.pasteSplit) {
      // No splitting — let the default contenteditable paste happen.
      return;
    }
    e.preventDefault();
    const splitRE = new RegExp(`[${escapeRegExp(this.pasteSplit)}]+`);
    const fragments = raw.split(splitRE).map((s) => s.trim()).filter(Boolean);
    if (fragments.length === 0) return;
    if (fragments.length === 1) {
      // Single fragment: insert as plain text rather than auto-committing —
      // pasting "foo" into the input should let the user keep typing.
      document.execCommand('insertText', false, fragments[0]);
      this.#syncStateAttrs();
      this.#renderSuggestions();
      return;
    }
    // Multi-fragment paste — commit each, leave the input empty afterwards.
    let lastSurplus = '';
    for (let i = 0; i < fragments.length; i++) {
      const f = fragments[i];
      const ok = this.#commitToken(f, 'paste');
      // If the LAST fragment was rejected (e.g. count cap), surface it as
      // the new typed buffer so the user can correct it.
      if (!ok && i === fragments.length - 1) lastSurplus = f;
    }
    if (lastSurplus) {
      this.#suppressInput = true;
      this.#inputEl.textContent = lastSurplus;
      this.#suppressInput = false;
      this.#placeCaretAtEnd();
      this.#syncStateAttrs();
      this.#renderSuggestions();
    }
  }

  #handleChipRemove(e) {
    const chip = e.target.closest('tag-ui[data-index]');
    if (!chip) return;
    // tag-ui's `remove` CustomEvent bubbles SYNCHRONOUSLY before tag-ui's
    // own `this.remove()` runs (see tag/tag.class.js:59-63 — dispatch then
    // remove). If we react inside the bubble (call `#renderChips()` now),
    // the DOM still contains the about-to-be-removed chip and the
    // positional reconcile in `#renderChips()` mutates the WRONG chip's
    // text — visually erasing one or more adjacent chips. Pre-emptively
    // detach the source chip ourselves so the DOM matches `#value` before
    // render. tag-ui's later `this.remove()` is a harmless no-op on an
    // already-detached node.
    e.stopPropagation();
    const idx = Number(chip.dataset.index);
    if (!Number.isInteger(idx)) return;
    chip.remove();
    this.#removeByIndex(idx, 'chip-click');
  }

  #handleHostMousedown(e) {
    // Click anywhere on the host that isn't a chip's remove button focuses
    // the inline input. Mirrors gmail-style "tap-on-recipient-row" UX.
    if (this.disabled) return;
    const onRemove = e.target.closest('[slot="dismiss"], tag-ui');
    const onSuggestion = e.target.closest('[role="option"]');
    if (onRemove || onSuggestion) return;
    if (e.target === this.#inputEl) return;
    e.preventDefault(); // don't shift focus from the input mid-type
    this.#inputEl?.focus();
    this.#placeCaretAtEnd();
  }

  #handleSuggestionClick(e) {
    const opt = e.target.closest('[role="option"]');
    if (!opt) return;
    this.#commitToken(opt.dataset.value || '', 'suggestion');
  }

  // ── Commit / remove ──

  #applyTransform(raw) {
    const t = this.transform;
    if (t === 'lowercase') return raw.toLowerCase();
    if (t === 'trim') return raw.trim();
    if (t === 'strip-spaces') return raw.replace(/\s+/g, '');
    return raw;
  }

  #commitToken(rawInput, source) {
    if (this.disabled || this.readonly) return false;
    // Always strip surrounding whitespace before transform — typed strings
    // from the contenteditable surface routinely carry trailing spaces.
    let candidate = this.#applyTransform(rawInput.trim());
    if (!candidate) {
      // Empty after transform — silently drop without firing invalid.
      this.#clearInputBuffer();
      return false;
    }

    // ── built-in validators ──
    if (candidate.length < this.minLength) {
      this.#fireInvalid(candidate, 'too-short');
      return false;
    }
    if (this.maxLength > 0 && candidate.length > this.maxLength) {
      this.#fireInvalid(candidate, 'too-long');
      return false;
    }
    if (this.unique && this.#value.includes(candidate)) {
      // Silent dedup per spec §A2UI rules — clear buffer, no invalid event.
      this.#clearInputBuffer();
      return false;
    }
    if (this.max > 0 && this.#value.length >= this.max) {
      this.#fireInvalid(candidate, 'max');
      return false;
    }

    // commit event (cancelable)
    const commitEvent = new CustomEvent('commit', {
      bubbles: true,
      cancelable: true,
      detail: { value: candidate, accepted: true },
    });
    const accepted = this.dispatchEvent(commitEvent);
    if (!accepted) {
      this.#fireInvalid(candidate, 'validator');
      return false;
    }

    // ── consumer-supplied validateFn (sync or async) ──
    if (this.#validateFn) {
      try {
        const result = this.#validateFn(candidate, this.#value.length);
        if (result && typeof result.then === 'function') {
          // Async — flip to [validating], resolve later.
          this.#runAsyncValidation(candidate, result, source);
          return false; // not yet committed
        }
        if (result === false) {
          this.#fireInvalid(candidate, 'validator');
          return false;
        }
      } catch (err) {
        this.#fireInvalid(candidate, 'validator');
        return false;
      }
    }

    this.#applyCommit(candidate, source);
    return true;
  }

  #runAsyncValidation(candidate, promise, source) {
    const id = ++this.#pendingValidationId;
    this.setAttribute('validating', '');
    if (this.#spinnerEl) this.#spinnerEl.hidden = false;
    promise.then(
      (ok) => {
        if (id !== this.#pendingValidationId || !this.isConnected) return;
        this.removeAttribute('validating');
        if (this.#spinnerEl) this.#spinnerEl.hidden = true;
        if (ok === false) {
          this.#fireInvalid(candidate, 'validator');
          return;
        }
        this.#applyCommit(candidate, source);
      },
      () => {
        if (id !== this.#pendingValidationId || !this.isConnected) return;
        this.removeAttribute('validating');
        if (this.#spinnerEl) this.#spinnerEl.hidden = true;
        this.#fireInvalid(candidate, 'validator');
      },
    );
  }

  #applyCommit(token, source) {
    const before = this.#value.slice();
    this.#value = [...this.#value, token];
    this.#renderChips();
    this.#clearInputBuffer();
    this.#syncStateAttrs();
    this.#syncFormValue();
    this.#announceChange(before, this.#value, source);
    // Keep focus on the inline input post-commit (spec §5 focus model).
    if (source !== 'programmatic') this.#inputEl?.focus();
  }

  #removeByIndex(index, source) {
    if (index < 0 || index >= this.#value.length) return;
    const before = this.#value.slice();
    const next = before.slice();
    next.splice(index, 1);
    this.#value = next;
    this.#renderChips();
    this.#syncStateAttrs();
    this.#syncFormValue();
    this.#announceChange(before, next, source);
    if (source !== 'programmatic') this.#inputEl?.focus();
  }

  #announceChange(before, after, source) {
    // Compute added / removed against `before` snapshot.
    const beforeSet = new Set(before);
    const afterSet = new Set(after);
    const added = after.filter((t) => !beforeSet.has(t));
    const removed = before.filter((t) => !afterSet.has(t));
    if (added.length === 0 && removed.length === 0) return;
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: after.slice(), added, removed, source },
    }));
  }

  #fireInvalid(value, reason) {
    this.dispatchEvent(new CustomEvent('invalid', {
      bubbles: true,
      detail: { value, reason },
    }));
  }

  #clearInputBuffer() {
    if (!this.#inputEl) return;
    this.#suppressInput = true;
    this.#inputEl.textContent = '';
    this.#suppressInput = false;
    this.#inputEl.setAttribute('data-empty', '');
    this.removeAttribute('editing');
    this.#renderSuggestions();
  }

  #placeCaretAtEnd() {
    if (!this.#inputEl || !this.#inputEl.isConnected) return;
    try {
      const sel = window.getSelection();
      const range = document.createRange();
      range.selectNodeContents(this.#inputEl);
      range.collapse(false);
      sel.removeAllRanges();
      sel.addRange(range);
    } catch { /* happy-dom may not support selection — ignore */ }
  }

  // ── Suggestions navigation ──

  #moveActiveSuggestion(dir) {
    if (!this.#suggestEl) return;
    const opts = Array.from(this.#suggestEl.querySelectorAll('[role="option"]'));
    if (!opts.length) return;
    if (this.#activeSuggestion < 0) {
      this.#activeSuggestion = dir > 0 ? 0 : opts.length - 1;
    } else {
      this.#activeSuggestion = (this.#activeSuggestion + dir + opts.length) % opts.length;
    }
    for (const node of opts) node.removeAttribute('data-active');
    const active = opts[this.#activeSuggestion];
    active.setAttribute('data-active', '');
    active.scrollIntoView({ block: 'nearest' });
    this.#inputEl?.setAttribute('aria-activedescendant', active.id);
  }

  #closeSuggestions() {
    this.removeAttribute('suggesting');
    if (this.#suggestEl) this.#suggestEl.hidden = true;
    this.#activeSuggestion = -1;
    this.#inputEl?.removeAttribute('aria-activedescendant');
    this.removeAttribute('aria-expanded');
  }

  // ── Value attribute parsing ──

  #parseValueAttr(raw) {
    if (raw == null) return null;
    const s = String(raw).trim();
    if (!s) return [];
    try {
      const parsed = JSON.parse(s);
      if (Array.isArray(parsed)) return parsed.map((t) => String(t ?? ''));
    } catch { /* fall through to comma-fallback */ }
    // Comma-fallback: be charitable to consumers who pass "a,b,c" — but
    // emit `invalid` so the A2UI authoring contract surfaces the smell.
    if (s.includes(',')) {
      this.#fireInvalid(s, 'validator');
      return s.split(',').map((t) => t.trim()).filter(Boolean);
    }
    return [s];
  }

  // ── Form value sync override ──

  syncValue(val) {
    // Subclasses that override `value` setter route through `#syncFormValue`,
    // which is the canonical path. Direct calls from the base class
    // (e.g. attributeChangedCallback for `value` attribute writes) land
    // here — parse JSON, reroute through `set value`.
    if (typeof val === 'string') {
      const parsed = this.#parseValueAttr(val);
      if (parsed) {
        this.value = parsed;
        return;
      }
    }
    super.syncValue(val);
  }
}
