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

/**
 * <input-ui> — Text input. The host IS the interactive surface.
 * Uses contenteditable for text entry, ElementInternals for form participation.
 *
 * Slots inside [slot="field"]:
 *   prefix → [leading] → label → text → suffix → [trailing] → controls (number mode)
 *
 *   <input-ui label="Email" placeholder="you@acme.com"></input-ui>
 *   <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
 *   <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
 *   <input-ui prefix="@" value="kim"></input-ui>
 *
 *   <!-- Trailing buttons inside the input chrome (§199 v0.5.7) -->
 *   <input-ui value="Theme 1" suffix="Light">
 *     <button-ui slot="trailing" icon="arrow-square-out"
 *                variant="ghost" size="sm"
 *                aria-label="Open theme browser"></button-ui>
 *   </input-ui>
 *
 *   <!-- Leading button for inline actions before the value -->
 *   <input-ui value="https://...">
 *     <button-ui slot="leading" icon="link" variant="ghost" size="sm"></button-ui>
 *   </input-ui>
 *
 *   <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
 *   <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
 *
 * type="number" renders a contenteditable surface + [+]/[-] stepper buttons,
 * filters input to digits / minus / decimal, snaps to step, clamps to min/max,
 * and exposes ARIA spinbutton semantics. No native <input type=number>.
 *
 * type="password" still wraps a native <input> — only path that needs
 * `-webkit-text-security` disc masking, which only works on native inputs.
 *
 * label renders as a dim leading caption inside the chrome (next to the
 * value, sharing the input's border) — for stacked label / hint / error
 * compositions, wrap with field-ui.
 */

import { UIFormElement } from '../../core/form.js';
import { isIconName, whenIconRegistryReady } from '../../core/icons.js';
import { logicalSlotted } from '../../core/logical-children.js';

const renderAffix = (v) => isIconName(v)
  ? `<icon-ui name="${v}"></icon-ui>`
  : v;

export class UIInput extends UIFormElement {
  // Opt out of UIFormElement's per-control `label` deprecation warning.
  // input-ui's `label` is a first-class API rendering an inline-leading
  // caption inside the chrome with `aria-labelledby` wiring on the
  // editable surface — not the inert above-the-field rendering that
  // motivated the deprecation.
  static labelDeprecated = false;

  static properties = {
    ...UIFormElement.properties,
    placeholder: { type: String,  default: '', reflect: true },
    type:        { type: String,  default: 'text', reflect: true },
    label:       { type: String,  default: '', reflect: true },
    prefix:      { type: String,  default: '', reflect: true },
    suffix:      { type: String,  default: '', reflect: true },
    raw:         { type: Boolean, default: false, reflect: true },
    // ── Number mode ──
    min:         { type: Number,  default: null, reflect: true },
    max:         { type: Number,  default: null, reflect: true },
    step:        { type: Number,  default: 1,    reflect: true },
    precision:   { type: Number,  default: null, reflect: true },
    // BCP-47 locale tag, e.g. "de-DE" / "fr-FR" / "en-IN". Default empty =
    // en-US (`.` decimal separator, no thousands grouping). When set, the
    // input accepts both `.` AND the locale's decimal separator (so en-US-
    // formatted programmatic values still parse), and `#format` uses
    // `Intl.NumberFormat` for display. Internal storage stays in JS-Number
    // canonical form so `.value` round-trips through `Number(v)` unchanged.
    locale:      { type: String,  default: '',   reflect: true },
  };

  static template = () => null;

  #textEl = null;
  #labelEl = null;
  #upBtn = null;
  #downBtn = null;
  #valueAtFocus = '';
  #repeatTimer = null;
  #repeatDelayTimer = null;
  #cachedSep = '.';
  #cachedGroup = '';
  #cachedSepFor = null;
  static #labelSeq = 0;

  // Hold-to-repeat tuning. Initial delay before autorepeat begins, and the
  // interval between repeats. Values match the cadence of the native
  // <input type="number"> spinner behavior in Chromium/Safari.
  static #REPEAT_INITIAL_MS = 400;
  static #REPEAT_INTERVAL_MS = 60;

  get #isNativePassword() { return this.type === 'password'; }
  get #isNumberMode()     { return this.type === 'number'; }

  /** Parsed numeric value. NaN when empty or unparseable. When `locale` is
   *  set, the value may carry the locale's decimal separator (e.g. "1,5" in
   *  de-DE); we canonicalize to JS form before `Number(…)`. */
  get valueAsNumber() {
    const raw = String(this.value ?? '').trim();
    if (!raw) return NaN;
    const s = this.#toCanonical(raw);
    if (s === '-' || s === '.' || s === '-.') return NaN;
    const n = Number(s);
    return Number.isFinite(n) ? n : NaN;
  }
  set valueAsNumber(n) {
    if (!Number.isFinite(n)) { this.value = ''; return; }
    this.value = this.#format(n);
  }

  connected() {
    super.connected();
    this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');

    // §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
    // direct children of <input-ui> at author time. Capture references
    // BEFORE innerHTML wipes them, so we can move them into [slot="field"]
    // after the shell is built. logicalSlotted pierces the template engine's
    // display:contents wrapper spans (so `.map()`-interpolated affordances
    // aren't missed then wiped — the wrapper trap), and still won't re-match
    // nodes already moved into the field (those nest in a real element, not a
    // transparent wrapper).
    const leadingNodes = logicalSlotted(this, 'leading');
    const trailingNodes = logicalSlotted(this, 'trailing');

    if (!this.querySelector('[slot="text"]')) {
      const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
      this.innerHTML = this.#shellHTML(labelId);
      // innerHTML wiped the consumer-supplied leading/trailing nodes; re-attach
      // them into the field at the right positions. Order matches the JSDoc:
      //   prefix → leading → label → text → suffix → trailing → controls
      this.#installAffordances(leadingNodes, trailingNodes);
    }

    this.#textEl  = this.querySelector('[slot="text"]');
    this.#labelEl = this.querySelector('[slot="label"]');
    this.#upBtn   = this.querySelector('[data-step="up"]');
    this.#downBtn = this.querySelector('[data-step="down"]');

    if (!this.#isNativePassword && this.value) {
      this.#textEl.textContent = this.#isNumberMode
        ? this.#formatStored(this.value)
        : this.value;
    }

    if (this.#textEl) {
      this.#textEl.addEventListener('input',    this.#onInput);
      this.#textEl.addEventListener('keydown',  this.#onKeydown);
      this.#textEl.addEventListener('blur',     this.#onBlur);
      this.#textEl.addEventListener('focus',    this.#onFocus);
      this.#textEl.addEventListener('paste',    this.#onPaste);
      if (this.#isNumberMode) {
        this.#textEl.addEventListener('beforeinput', this.#onBeforeInput);
      }
    }

    // pointerdown.preventDefault keeps focus on the contenteditable surface
    // when the user pokes a stepper button with a pointing device. Same
    // handler fires the initial step + arms hold-to-repeat; pointerup/leave/
    // cancel on document stops it (the user can drag off the button to
    // abort the repeat without lifting their finger first).
    this.#upBtn?.addEventListener('pointerdown', this.#onStepperUpDown);
    this.#downBtn?.addEventListener('pointerdown', this.#onStepperDownDown);
    // Stop autorepeat on any pointer release, anywhere — captures the
    // "drag-off-then-lift" abort path without per-button leave/cancel
    // bookkeeping. Cheap; runs only while a stepper is held.

    // In non-Vite static deploys, the icon registry loads asynchronously
    // after the manifest fetch resolves. If our prefix/suffix were checked
    // by isIconName() during that window, kebab-case icon names like
    // "magnifying-glass" got baked into the DOM as literal text. Re-evaluate
    // once the registry is ready and promote text-rendered affixes to
    // <icon-ui>. (No-op on Vite dev where the promise resolves synchronously.)
    if (this.prefix || this.suffix) {
      whenIconRegistryReady.then(() => this.#promoteAffixes());
    }
  }

  #shellHTML(labelId) {
    const prefix  = this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : '';
    const label   = this.label  ? `<span slot="label" id="${labelId}">${this.label}</span>` : '';
    const suffix  = this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : '';
    const labelby = labelId ? `aria-labelledby="${labelId}"` : '';

    if (this.#isNativePassword) {
      return `
        <div slot="field">
          ${prefix}${label}
          <input slot="text" type="password" tabindex="0"
                 placeholder="${this.placeholder}" value="${this.value || ''}"
                 autocomplete="current-password" ${labelby}
                 ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />
          ${suffix}
        </div>
      `;
    }

    const editable = `
      <span slot="text" contenteditable="plaintext-only" tabindex="0"
            ${this.value ? '' : 'data-empty=""'}
            ${labelby}
            data-placeholder="${this.placeholder}"
            ${this.#isNumberMode ? 'inputmode="decimal"' : ''}></span>`;

    const controls = this.#isNumberMode ? `
      <span slot="controls" data-controls aria-hidden="true">
        <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
                   icon="caret-up" data-step="up" aria-label="Increase"></button-ui>
        <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
                   icon="caret-down" data-step="down" aria-label="Decrease"></button-ui>
      </span>` : '';

    return `
      <div slot="field"${this.#isNumberMode ? ' data-number' : ''}>
        ${prefix}${label}${editable}${suffix}${controls}
      </div>
    `;
  }

  /**
   * §199 (v0.5.7) — Move consumer-supplied [slot="leading"] +
   * [slot="trailing"] nodes into the field at the right insertion points.
   *
   * Yaml has declared `leading` + `trailing` slots since v1 ("Leading/
   * Trailing icon slot, sized to --content-height. Collapses text inline
   * padding when present.") but the shell never rendered them, so any
   * consumer-authored `<button-ui slot="trailing">` sat OUTSIDE the
   * field chrome. §199 closes the schema-vs-impl gap by:
   *
   *   1. Capturing the consumer's [slot="leading|trailing"] direct
   *      children BEFORE `innerHTML = shellHTML` wipes them.
   *   2. Re-inserting each into `[slot="field"]` at the canonical
   *      position: leading goes right after [slot="label"] (or after
   *      [slot="prefix"] when no label), trailing goes right after
   *      [slot="suffix"] (or after [slot="text"] when no suffix), but
   *      always BEFORE [slot="controls"] (number-mode stepper column).
   *
   * Consumers typically pass `<button-ui slot="trailing">` for inline
   * actions (copy / clear / open-in-modal); CSS handles the chrome
   * inheritance (border continuity + size token wiring) via
   * input.css §199 rules.
   */
  #installAffordances(leadingNodes, trailingNodes) {
    const field = this.querySelector(':scope > [slot="field"]');
    if (!field) return;

    if (leadingNodes.length) {
      // Insertion point: after [slot="label"] if present, else after
      // [slot="prefix"] if present, else as the first child. Walk the
      // anchor forward so author-order is preserved (first node lands
      // earliest in DOM).
      const start =
        field.querySelector(':scope > [slot="label"]') ||
        field.querySelector(':scope > [slot="prefix"]');
      if (start) {
        let anchor = start;
        for (const node of leadingNodes) {
          anchor.after(node);
          anchor = node;
        }
      } else {
        // No prefix/label — prepend in reverse so the FIRST author-order
        // node ends up first after all prepends complete.
        for (const node of leadingNodes.slice().reverse()) field.prepend(node);
      }
    }

    if (trailingNodes.length) {
      // Insertion point: after [slot="suffix"] if present, else after
      // [slot="text"], but always before [slot="controls"] (number-mode
      // stepper column). Insert in author order: walk the anchor forward
      // for each node so the first author-order node lands first in DOM.
      const controls = field.querySelector(':scope > [slot="controls"]');
      const after =
        field.querySelector(':scope > [slot="suffix"]') ||
        field.querySelector(':scope > [slot="text"]');
      if (after) {
        let anchor = after;
        for (const node of trailingNodes) {
          anchor.after(node);
          anchor = node;
        }
      } else if (controls) {
        let anchor = controls.previousElementSibling || controls;
        for (const node of trailingNodes) {
          anchor.after(node);
          anchor = node;
        }
      } else {
        for (const node of trailingNodes) field.appendChild(node);
      }
    }
  }

  #promoteAffixes() {
    if (!this.isConnected) return;
    for (const which of ['prefix', 'suffix']) {
      const value = this[which];
      if (!value) continue;
      const slot = this.querySelector(`:scope [slot="${which}"]`);
      if (!slot) continue;
      // Already an <icon-ui> — nothing to do.
      if (slot.querySelector(':scope > icon-ui')) continue;
      // Was rendered as text and the value is now a known icon — replace.
      if (isIconName(value)) {
        slot.replaceChildren();
        const icon = document.createElement('icon-ui');
        icon.setAttribute('name', value);
        slot.appendChild(icon);
      }
    }
  }

  /**
   * FEEDBACK-93 — re-resolve the prefix/suffix slots from the live property.
   *
   * `connected()` builds the shell ONCE (`this.innerHTML = #shellHTML()`),
   * reading `this.prefix` / `this.suffix` at that moment. For a reactive
   * attribute binding (`suffix="${expr}"`) the framework writes the literal
   * placeholder marker (`{{p:N}}`) into the attribute first and resolves it on
   * a later reactive tick — so the marker gets baked into the slot as text and,
   * since `render()` never rebuilt the affixes, it stuck. This mirrors the
   * slider-ui FEEDBACK-45 fix, but input affixes can be icon names
   * (`renderAffix` → `<icon-ui>`), so we handle text↔icon transitions here.
   * The static-deploy icon-registry race is still owned by `#promoteAffixes()`.
   */
  #syncAffixSlots() {
    for (const which of ['prefix', 'suffix']) {
      const el = this.querySelector(`:scope > [slot="field"] > [slot="${which}"]`);
      if (!el) continue;
      const value = this[which] || '';
      const icon = el.querySelector(':scope > icon-ui');
      if (value && isIconName(value)) {
        if (icon) {
          if (icon.getAttribute('name') !== value) icon.setAttribute('name', value);
        } else {
          el.replaceChildren();
          const created = document.createElement('icon-ui');
          created.setAttribute('name', value);
          el.appendChild(created);
        }
      } else {
        if (icon) el.replaceChildren();
        if (el.textContent !== value) el.textContent = value;
      }
    }
  }

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

    const text = this.value ?? '';

    if (this.#isNativePassword) {
      this.#textEl.placeholder = this.placeholder;
      this.#textEl.disabled = this.disabled;
      this.#textEl.readOnly = this.readonly;
      if (this.#textEl.value !== text) this.#textEl.value = text;
    } else {
      this.#textEl.setAttribute('data-placeholder', this.placeholder);
      if (this.disabled || this.readonly) {
        this.#textEl.contentEditable = 'false';
      } else {
        this.#textEl.contentEditable = 'plaintext-only';
      }
      // Sync programmatic value writes into the contenteditable surface.
      // Skip when already in sync to avoid clobbering an in-flight edit's
      // caret position. For number mode, render the formatted display, but
      // only when the surface DOESN'T have focus (mid-edit reformat would
      // wipe caret + lose the user's transient state like "9." → "9").
      const display = this.#isNumberMode && document.activeElement !== this.#textEl
        ? this.#formatStored(text)
        : String(text);
      if (this.#textEl.textContent !== display) {
        this.#textEl.textContent = display;
        this.#textEl.toggleAttribute('data-empty', !display);
      }
    }

    if (this.#labelEl) this.#labelEl.textContent = this.label || '';

    // Re-resolve prefix/suffix from the live property each render (FEEDBACK-93).
    this.#syncAffixSlots();

    if (this.label) {
      this.removeAttribute('aria-label');
    } else if (this.placeholder) {
      this.setAttribute('aria-label', this.placeholder);
    } else {
      this.removeAttribute('aria-label');
    }

    if (this.#isNumberMode) {
      const n = this.valueAsNumber;
      if (Number.isFinite(n)) {
        this.setAttribute('aria-valuenow', String(n));
        this.setAttribute('aria-valuetext', `${this.#format(n)}${this.suffix ? ' ' + this.suffix : ''}`);
      } else {
        this.removeAttribute('aria-valuenow');
        this.removeAttribute('aria-valuetext');
      }
      if (this.min != null) this.setAttribute('aria-valuemin', String(this.min));
      else this.removeAttribute('aria-valuemin');
      if (this.max != null) this.setAttribute('aria-valuemax', String(this.max));
      else this.removeAttribute('aria-valuemax');

      const disableUp   = this.disabled || this.readonly || (this.max != null && Number.isFinite(n) && n >= this.max);
      const disableDown = this.disabled || this.readonly || (this.min != null && Number.isFinite(n) && n <= this.min);
      this.#upBtn?.toggleAttribute('disabled', !!disableUp);
      this.#downBtn?.toggleAttribute('disabled', !!disableDown);
    }
  }

  // ── Value sync + validation override ──

  syncValue(val) {
    val = val ?? this.value ?? '';
    super.syncValue(String(val));
    if (this.#isNumberMode) this.#runNumberConstraints(String(val));
  }

  validate() {
    const baseValid = super.validate();
    if (!this.#isNumberMode) return baseValid;
    // super.validate cleared validity if all base constraints passed; layer
    // number-specific checks on top.
    if (!baseValid) return false;
    const numValid = this.#runNumberConstraints(this.value ?? '');
    if (!numValid) {
      this.setAttribute('aria-invalid', 'true');
      this.error = this.validationMessage;
    }
    return numValid;
  }

  #runNumberConstraints(val) {
    const raw = String(val ?? '').trim();
    // Empty is handled by `required` in the base class; nothing to check here.
    if (!raw) return true;
    // Canonicalize for `Number(…)` parse — when `locale` is set the raw
    // value may carry the locale's decimal separator.
    const s = this.#toCanonical(raw);
    const n = Number(s);
    if (!Number.isFinite(n)) {
      this.internals.setValidity(
        { badInput: true },
        this.getAttribute('data-msg-bad-input') || 'Please enter a valid number.',
        this,
      );
      return false;
    }
    if (this.min != null && n < this.min) {
      this.internals.setValidity(
        { rangeUnderflow: true },
        this.getAttribute('data-msg-min') || `Value must be ${this.min} or greater.`,
        this,
      );
      return false;
    }
    if (this.max != null && n > this.max) {
      this.internals.setValidity(
        { rangeOverflow: true },
        this.getAttribute('data-msg-max') || `Value must be ${this.max} or less.`,
        this,
      );
      return false;
    }
    return true;
  }

  // ── Number helpers ──

  #decimals() {
    if (this.precision != null) return Math.max(0, this.precision | 0);
    const stepStr = String(this.step ?? 1);
    return (stepStr.split('.')[1] || '').length;
  }

  /** Locale's decimal separator, or '.' for the default en-US-equivalent path.
   *  Result cached per-locale on the host so `Intl.NumberFormat.formatToParts`
   *  isn't called per keystroke. */
  #decimalSep() {
    if (!this.locale) return '.';
    if (this.#cachedSepFor === this.locale) return this.#cachedSep;
    this.#refreshSepCache();
    return this.#cachedSep;
  }

  /** Locale's thousands/grouping separator (e.g. `,` in en-US, `.` in de-DE).
   *  Returns '' for the default path (no locale → no grouping). Cached
   *  alongside the decimal separator. */
  #groupSep() {
    if (!this.locale) return '';
    if (this.#cachedSepFor === this.locale) return this.#cachedGroup;
    this.#refreshSepCache();
    return this.#cachedGroup;
  }

  #refreshSepCache() {
    try {
      const parts = new Intl.NumberFormat(this.locale).formatToParts(1234567.89);
      this.#cachedSep   = parts.find((p) => p.type === 'decimal')?.value || '.';
      this.#cachedGroup = parts.find((p) => p.type === 'group')?.value   || '';
    } catch {
      this.#cachedSep   = '.';
      this.#cachedGroup = '';
    }
    this.#cachedSepFor = this.locale;
  }

  /** Convert a locale-formatted numeric string to the JS-canonical form
   *  (decimal `.`, no thousands grouping). Strips group separators first so
   *  "1.234,5" (de-DE) → "1234.5", "1,234.5" (en-US) → "1234.5". Pure string
   *  transform; no validation. */
  #toCanonical(s) {
    const sep = this.#decimalSep();
    const group = this.#groupSep();
    let out = String(s);
    if (group) out = out.split(group).join('');
    if (sep !== '.') out = out.replace(new RegExp(`\\${sep}`, 'g'), '.');
    return out;
  }

  /** Internal/edit-mode format: locale decimal separator, NO thousands
   *  grouping. Used for `this.value` storage and for the textContent
   *  rendering while the input is focused (so the user can edit without
   *  the group separator jumping around as they type). */
  #format(n) {
    if (!Number.isFinite(n)) return '';
    const d = this.#decimals();
    if (this.locale) {
      try {
        return new Intl.NumberFormat(this.locale, {
          minimumFractionDigits: d,
          maximumFractionDigits: d,
          useGrouping: false,
        }).format(n);
      } catch { /* fall through to JS toFixed */ }
    }
    return d > 0 ? n.toFixed(d) : String(Math.round(n));
  }

  /** Display-mode format: locale decimal separator + thousands grouping when
   *  the locale supports it. Used for the textContent rendering when the
   *  input is NOT focused (initial render + post-blur). Returns the same as
   *  `#format` when no `locale` is set. */
  #formatDisplay(n) {
    if (!Number.isFinite(n)) return '';
    if (!this.locale) return this.#format(n);
    const d = this.#decimals();
    try {
      return new Intl.NumberFormat(this.locale, {
        minimumFractionDigits: d,
        maximumFractionDigits: d,
        useGrouping: true,
      }).format(n);
    } catch { return this.#format(n); }
  }

  /** Display value derived from the stored string. During focus we leave
   *  the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
   *  for precision=2). Non-numeric stored strings pass through unchanged
   *  so error-state visuals can echo what the user typed. */
  #formatStored(stored) {
    const s = String(stored ?? '');
    if (!s) return '';
    // Canonicalize before Number() — `.value` may carry the locale's
    // decimal separator if the host has `locale` set.
    const n = Number(this.#toCanonical(s));
    if (!Number.isFinite(n)) return s;
    // If the input is currently focused, render without grouping so the
    // user can edit naturally; otherwise group when locale is set. Falls
    // back to #format (ungrouped) when there's no locale.
    return document.activeElement === this.#textEl
      ? this.#format(n)
      : this.#formatDisplay(n);
  }

  #snap(raw) {
    const step = this.step || 1;
    const base = this.min != null ? this.min : 0;
    const stepped = Math.round((raw - base) / step) * step + base;
    const clamped = Math.max(
      this.min != null ? this.min : -Infinity,
      Math.min(this.max != null ? this.max : Infinity, stepped),
    );
    return parseFloat(clamped.toFixed(10));
  }

  #stepBy(multiplier) {
    if (this.disabled || this.readonly) return;
    const step = (this.step || 1) * multiplier;
    const current = Number.isFinite(this.valueAsNumber)
      ? this.valueAsNumber
      : (this.min != null ? this.min : 0);
    const next = this.#snap(current + step);
    if (next === this.valueAsNumber) return;
    this.value = this.#format(next);
    this.syncValue(this.value);
    this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  }

  // ── Event handlers ──

  #onInput = () => {
    let text;
    if (this.#isNativePassword) {
      text = this.#textEl.value || '';
    } else if (this.#isNumberMode) {
      // beforeinput filtered the keystroke; some browsers still let through
      // composition or paste events that bypass beforeinput. Re-sanitize.
      const raw = this.#textEl.textContent || '';
      text = this.#sanitizeNumeric(raw);
      if (text !== raw) {
        // Soft-revert: restore filtered text + put caret at end. Rare path.
        this.#textEl.textContent = text;
        this.#placeCaretAtEnd();
      }
    } else {
      text = this.#textEl.textContent || '';
    }
    this.value = text;
    if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
    this.syncValue(text);
    // §220 (v0.5.9, FEEDBACK-14 §3): trailing-debounce when `throttle > 0`.
    this.scheduleThrottledInput();
  };

  #onBeforeInput = (e) => {
    // Allow deletions, formatting, composition — only gate text insertions.
    const t = e.inputType;
    if (!t || !t.startsWith('insert')) return;
    if (t === 'insertCompositionText') return; // IME — let through, #onInput cleans up
    const incoming = (e.data ?? '');
    if (!incoming) return;
    const current = this.#textEl.textContent || '';
    const sel = window.getSelection();
    // Build prospective string: replace selection (or insert at caret).
    let start = current.length, end = current.length;
    if (sel && sel.rangeCount && this.#textEl.contains(sel.anchorNode)) {
      const r = sel.getRangeAt(0);
      start = this.#offsetFromTextStart(r.startContainer, r.startOffset);
      end   = this.#offsetFromTextStart(r.endContainer,   r.endOffset);
      if (start > end) [start, end] = [end, start];
    }
    const prospective = current.slice(0, start) + incoming + current.slice(end);
    if (!this.#isNumericProspect(prospective)) e.preventDefault();
  };

  #isNumericProspect(s) {
    // Permissive while typing: allow lone '-', lone '.', and trailing '.'.
    // Reject scientific notation, multiple decimals, multiple signs.
    // When `locale` is set, accept both '.' AND the locale's decimal
    // separator, and silently strip thousands-group separators (paste of
    // "1,234.5" or "1.234,5" both validate).
    const c = this.#toCanonical(s);
    if (c === '' || c === '-' || c === '.' || c === '-.') {
      return c === '' || c === '-' || (this.min == null || this.min < 0) ? true : false;
    }
    if (!/^-?\d*\.?\d*$/.test(c)) return false;
    if (c.startsWith('-') && this.min != null && this.min >= 0) return false;
    return true;
  }

  #sanitizeNumeric(s) {
    // Strip everything but digits / one leading minus / one decimal point.
    // The decimal mark is the locale's separator; characters that match the
    // locale's group separator (e.g. `.` in de-DE, `,` in en-US) are silently
    // dropped — never preserved in `this.value`. The blur handler re-renders
    // with grouping for display via `#formatDisplay`.
    //
    // Note on programmatic `.value = "1.5"` in de-DE: that path doesn't run
    // through sanitization (UIFormElement.value setter is string-only), so
    // canonical-form programmatic values still parse correctly via
    // `valueAsNumber` (which canonicalizes through `#toCanonical`). Only
    // user-typed/-pasted input flows through this sanitizer, and there the
    // locale interpretation (`.` = group when sep=`,`) is the correct read.
    const sep = this.#decimalSep();
    let out = '';
    let sawDecimal = false;
    for (let i = 0; i < s.length; i++) {
      const c = s[i];
      if (c >= '0' && c <= '9') out += c;
      else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
      else if (c === sep && !sawDecimal) { out += sep; sawDecimal = true; }
      // group separator and other punctuation silently dropped
    }
    return out;
  }

  #offsetFromTextStart(node, offset) {
    // Walk the text descendants until we reach `node`, accumulating chars.
    if (!this.#textEl.contains(node)) return 0;
    let acc = 0;
    const walker = document.createTreeWalker(this.#textEl, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = walker.nextNode())) {
      if (n === node) return acc + offset;
      acc += n.textContent.length;
    }
    return node === this.#textEl ? offset : acc;
  }

  #placeCaretAtEnd() {
    const sel = window.getSelection();
    const range = document.createRange();
    range.selectNodeContents(this.#textEl);
    range.collapse(false);
    sel.removeAllRanges();
    sel.addRange(range);
  }

  #onKeydown = (e) => {
    if (this.#isNumberMode) {
      switch (e.key) {
        case 'ArrowUp':   e.preventDefault(); this.#stepBy( 1);  return;
        case 'ArrowDown': e.preventDefault(); this.#stepBy(-1);  return;
        case 'PageUp':    e.preventDefault(); this.#stepBy( 10); return;
        case 'PageDown':  e.preventDefault(); this.#stepBy(-10); return;
        case 'Home':
          if (this.min != null) { e.preventDefault(); this.#commitNumeric(this.min); }
          return;
        case 'End':
          if (this.max != null) { e.preventDefault(); this.#commitNumeric(this.max); }
          return;
        case 'Escape':
          e.preventDefault();
          this.value = this.#valueAtFocus;
          this.#textEl.textContent = this.#formatStored(this.value);
          this.#textEl.toggleAttribute('data-empty', !this.value);
          this.syncValue(this.value);
          this.#textEl.blur();
          return;
        case 'Enter':
          e.preventDefault();
          // Commit normalized value before firing form events.
          this.#commitOnBlur();
          this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
          this.dispatchEvent(new Event('submit', { bubbles: true }));
          return;
      }
      return;
    }
    if (e.key === 'Enter') {
      e.preventDefault();
      this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
      this.dispatchEvent(new Event('submit', { bubbles: true }));
    }
  };

  #onFocus = () => {
    this.#valueAtFocus = this.value ?? '';
    // When focused: re-render textContent without thousands grouping so the
    // user can edit naturally — group separators jumping mid-keystroke is
    // disorienting. Only matters when `locale` is set AND the post-blur
    // render added grouping; no-op for the default `.` path.
    if (this.#isNumberMode && this.locale) {
      const raw = String(this.value ?? '').trim();
      if (!raw) return;
      const n = Number(this.#toCanonical(raw));
      if (!Number.isFinite(n)) return;
      const ungrouped = this.#format(n);
      if (this.#textEl.textContent !== ungrouped) this.#textEl.textContent = ungrouped;
    }
  };

  #onBlur = () => {
    if (this.#isNumberMode) this.#commitOnBlur();
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  };

  #commitOnBlur() {
    const raw = String(this.value ?? '').trim();
    if (!raw) return;
    // Canonicalize before Number() — `this.value` may carry the locale's
    // decimal separator (e.g. "1,5" in de-DE).
    const n = Number(this.#toCanonical(raw));
    if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
    const snapped = this.#snap(n);
    // `this.value` stores the ungrouped, locale-decimal form (round-trippable
    // through #toCanonical → Number → #format). textContent shows the
    // grouped display form when `locale` is set.
    const stored    = this.#format(snapped);
    const displayed = this.#formatDisplay(snapped);
    if (this.value !== stored) {
      this.value = stored;
      this.syncValue(stored);
      this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
    }
    if (this.#textEl.textContent !== displayed) {
      this.#textEl.textContent = displayed;
      this.#textEl.toggleAttribute('data-empty', !displayed);
    }
  }

  #commitNumeric(n) {
    const snapped = this.#snap(n);
    if (snapped === this.valueAsNumber) return;
    this.value = this.#format(snapped);
    this.syncValue(this.value);
    this.#textEl.textContent = this.value;
    this.#textEl.toggleAttribute('data-empty', !this.value);
    this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  }

  #onPaste = (e) => {
    e.preventDefault();
    const raw = e.clipboardData?.getData('text/plain') || '';
    const text = this.#isNumberMode ? this.#sanitizeNumeric(raw) : raw;
    document.execCommand('insertText', false, text);
  };

  // Hold-to-repeat: pointerdown fires the initial step + arms an autorepeat
  // timer. The first repeat fires after REPEAT_INITIAL_MS; subsequent ones
  // every REPEAT_INTERVAL_MS. pointerup on document stops everything. We
  // also stop on a stale value (disabled at min/max boundary) so the
  // browser doesn't keep firing input events for no-op increments.
  #onStepperUpDown   = (e) => this.#startStepperHold(e, 1);
  #onStepperDownDown = (e) => this.#startStepperHold(e, -1);

  #startStepperHold(e, multiplier) {
    // Keep focus on the editable surface when the button is pressed.
    e.preventDefault();
    if (this.disabled || this.readonly) return;
    // Initial step fires immediately on press.
    this.#stepBy(multiplier);
    this.#stopStepperHold();
    // Listen for release on document (cheap; only while held).
    document.addEventListener('pointerup',     this.#onStepperRelease, { once: true });
    document.addEventListener('pointercancel', this.#onStepperRelease, { once: true });
    // Initial delay → then continuous repeat.
    this.#repeatDelayTimer = window.setTimeout(() => {
      this.#repeatDelayTimer = null;
      this.#repeatTimer = window.setInterval(() => {
        const before = this.valueAsNumber;
        this.#stepBy(multiplier);
        // Boundary hit → no-op; cancel to avoid wasted intervals + event spam.
        if (this.valueAsNumber === before) this.#stopStepperHold();
      }, UIInput.#REPEAT_INTERVAL_MS);
    }, UIInput.#REPEAT_INITIAL_MS);
  }

  #onStepperRelease = () => this.#stopStepperHold();

  #stopStepperHold() {
    if (this.#repeatDelayTimer != null) {
      window.clearTimeout(this.#repeatDelayTimer);
      this.#repeatDelayTimer = null;
    }
    if (this.#repeatTimer != null) {
      window.clearInterval(this.#repeatTimer);
      this.#repeatTimer = null;
    }
    document.removeEventListener('pointerup',     this.#onStepperRelease);
    document.removeEventListener('pointercancel', this.#onStepperRelease);
  }

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

  clear() {
    this.value = '';
    if (this.#textEl) {
      if (this.#isNativePassword) {
        this.#textEl.value = '';
      } else {
        this.#textEl.textContent = '';
        this.#textEl.setAttribute('data-empty', '');
      }
    }
    this.syncValue('');
  }

  disconnected() {
    super.disconnected();
    if (this.#textEl) {
      this.#textEl.removeEventListener('input',    this.#onInput);
      this.#textEl.removeEventListener('keydown',  this.#onKeydown);
      this.#textEl.removeEventListener('blur',     this.#onBlur);
      this.#textEl.removeEventListener('focus',    this.#onFocus);
      this.#textEl.removeEventListener('paste',    this.#onPaste);
      this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
    }
    this.#upBtn?.removeEventListener('pointerdown', this.#onStepperUpDown);
    this.#downBtn?.removeEventListener('pointerdown', this.#onStepperDownDown);
    // Cancel any in-flight hold (the document-level pointerup listener
    // is `{once: true}` so it self-cleans on fire; this also clears the
    // timers if the host disconnects mid-hold).
    this.#stopStepperHold();
    this.#textEl = null;
    this.#labelEl = null;
    this.#upBtn = null;
    this.#downBtn = null;
  }
}
