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

/**
 * <slider-ui label="Width" value="63" min="0" max="200" step="1" suffix="rem"></slider-ui>
 *
 * Layout:
 *   [label]                                  [value] [suffix]
 *   [=====fill=====●─────────────────track───────────────────]
 *
 * The `label` attribute renders as a first-class in-component caption in
 * the slider header and is mirrored to `aria-label` on the host for
 * screen-reader announcement. Wrap in `<field-ui>` only when you need
 * external label-row composition (hint, error, action slot, etc.).
 */

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

export class UISlider extends UIFormElement {
  // Opt out of UIFormElement's per-control `label` deprecation warning.
  // slider-ui's `label` is a first-class API rendering as the in-header
  // caption next to the value, with `aria-label` wired on the host for
  // a11y. Same opt-out rationale as input-ui and range-ui.
  static labelDeprecated = false;

  static properties = {
    ...UIFormElement.properties,
    /** value: Number — overrides UIFormElement.value (String); syncs as string on form submit. */
    value:  { type: Number,  default: 50, reflect: true },
    min:    { type: Number,  default: 0,  reflect: true },
    max:    { type: Number,  default: 100, reflect: true },
    step:   { type: Number,  default: 1,  reflect: true },
    label:  { type: String,  default: '', reflect: true },
    suffix: { type: String,  default: '', reflect: true },
    // §184 (v0.5.5, FEEDBACK-08 §4) → §220 (v0.5.9, FEEDBACK-14 §3):
    // declarative trailing-debounce on `input` for expensive consumers
    // (palette regen, shader compile, large list reflow). When > 0,
    // value updates + visual feedback are immediate but `input` is
    // collapsed across the window. `change` fires unthrottled on
    // pointerup / track click / keyboard; any pending `input` flushes
    // BEFORE `change` so consumers always see
    // input→input→…→input→change ordering. throttle="0" (default)
    // preserves the pre-§184 every-pointer-move-fires-input behavior.
    // The mechanism graduated to UIFormElement at v0.5.9 §220 — slider
    // delegates via scheduleThrottledInput() + flushPendingInput().
  };

  static template = () => null;

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

  #trackEl = null;
  #thumbEl = null;
  #dragging = false;
  #dragOffset = 0;

  get #pct() {
    const range = this.max - this.min;
    return range > 0 ? ((this.value - this.min) / range) * 100 : 0;
  }

  #format(v) {
    const decimals = (String(this.step).split('.')[1] || '').length;
    return decimals > 0 ? v.toFixed(decimals) : String(v);
  }

  connected() {
    super.connected();
    this.setAttribute('role', 'slider');
    this.setAttribute('tabindex', '0');
    this.setAttribute('aria-valuemin', this.min);
    this.setAttribute('aria-valuemax', this.max);
    if (this.label) this.setAttribute('aria-label', this.label);

    if (!this.querySelector('[slot="track"]')) {
      // §184 (v0.5.5, FEEDBACK-08 §7): hint slot stamped underneath
      // the track when [hint] is set. Wired to aria-describedby below.
      const hintId = this.hint ? `slider-hint-${++UISlider.#hintSeq}` : '';
      this.innerHTML = `
        <div slot="header">
          ${this.label ? `<span slot="label">${this.label}</span>` : ''}
          <span slot="readout">
            <span slot="value">${this.#format(this.value)}</span>
            ${this.suffix ? `<span slot="suffix">${this.suffix}</span>` : ''}
          </span>
        </div>
        <div slot="track">
          <div slot="fill"></div>
          <div slot="thumb" tabindex="0"></div>
        </div>
        ${this.hint ? `<span slot="hint" id="${hintId}">${this.hint}</span>` : ''}
      `;
      if (this.hint) this.setAttribute('aria-describedby', hintId);
    }

    this.#trackEl = this.querySelector('[slot="track"]');
    this.#thumbEl = this.querySelector('[slot="thumb"]');

    if (this.#thumbEl) this.#thumbEl.addEventListener('pointerdown', this.#onPointerDown);
    if (this.#trackEl) this.#trackEl.addEventListener('click', this.#onTrackClick);
    this.addEventListener('keydown', this.#onKey);
  }

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

    // §153 (v0.5.3): function-typed `.value` runtime guard. Per FEEDBACK-07
    // §3, consumers sometimes pass `() => signal.value * 100` expecting
    // auto-subscribe; AdiaUI's reactive system (template.js `isFn` branch)
    // does wrap functions in effects + call them per dep change, but the
    // result of `v()` must be a number for `#pct` / `#format` to work.
    // When the function returns non-number (e.g. doesn't read a signal,
    // returns object/string) OR when consumers bypass the template engine
    // (manual `sliderEl.value = someFunction`), `this.value` ends up as
    // the function itself — `#pct` does NaN math, thumb stays at 0%,
    // silent fail. Warn loudly + skip render so the bug class is
    // diagnosable in dev. See USAGE.md "Reactive binding" for the
    // documented patterns.
    if (typeof this.value === 'function') {
      // eslint-disable-next-line no-console
      console.warn(
        '[slider-ui] .value received a function. Did you mean:\n' +
        '  .value=${fn()}            ← call the function to get the current value\n' +
        '  .value=${signal.value}     ← read the signal\'s current value\n' +
        'Functions are not auto-invoked at render time; the slider reads\n' +
        '.value synchronously. See USAGE.md "Reactive binding" for the\n' +
        'parent-template-re-read pattern that works across frameworks.'
      );
      return;
    }

    const pct = this.#pct;
    // Write progress to CSS custom property for pure-CSS positioning.
    // --slider-pct is a fraction (0.0–1.0) used in calc() alongside
    // --slider-travel so thumb + fill stay inside the track.
    this.style.setProperty('--slider-pct', String(pct / 100));

    const valueEl = this.querySelector('[slot="value"]');
    if (valueEl) valueEl.textContent = this.#format(this.value);

    // §FB-45: label and suffix are read in connected() but may be set later
    // by the template engine (which first writes {{p:N}} then resolves).
    // Re-sync both slots on every render so reactive bindings work.
    const labelEl = this.querySelector('[slot="label"]');
    if (labelEl) {
      labelEl.textContent = this.label;
    } else if (this.label) {
      const header = this.querySelector('[slot="header"]');
      if (header) { const s = document.createElement('span'); s.slot = 'label'; s.textContent = this.label; header.prepend(s); }
    }
    if (this.label) this.setAttribute('aria-label', this.label);

    const suffixEl = this.querySelector('[slot="suffix"]');
    if (suffixEl) suffixEl.textContent = this.suffix;

    this.setAttribute('aria-valuenow', this.value);
    this.setAttribute('aria-valuetext', `${this.#format(this.value)}${this.suffix ? ' ' + this.suffix : ''}`);
    this.syncValue(String(this.value));
  }

  /**
   * Inverse geometry: given a *desired* thumb-center viewport-x, compute
   * the slider value such that the thumb center lands exactly at that
   * coordinate (clamped at the min/max extremes).
   *
   * Forward geometry (in slider.css):
   *   thumb_center(p) = t/2 + p · (W − t)
   *
   * Inverse (solve for p, clamped):
   *   p = clamp01((target − t/2) / (W − t))
   *
   * Two call paths share this:
   *   • #onTrackClick — clientX is the click position; thumb center lands
   *     under the cursor (or snaps to the t/2 end-zone when clicked beyond).
   *   • #onPointerMove — (clientX − dragOffset) is the *intended* thumb
   *     center (offset preserves where the user originally grabbed the
   *     thumb, so dragging feels relative rather than snap-to-cursor).
   */
  #valueFromX(clientX) {
    if (!this.#trackEl || !this.#thumbEl) return this.min;
    const trackRect = this.#trackEl.getBoundingClientRect();
    const thumbRect = this.#thumbEl.getBoundingClientRect();
    const W = trackRect.width;
    const t = thumbRect.width;
    const travel = W - t;
    if (travel <= 0) return this.min;
    const target = clientX - trackRect.left;            // desired thumb-center, track-relative
    const ratio = Math.max(0, Math.min(1, (target - t / 2) / travel));
    const raw = this.min + ratio * (this.max - this.min);
    return this.#snap(raw);
  }

  #snap(raw) {
    const stepped = Math.round((raw - this.min) / this.step) * this.step + this.min;
    return Math.max(this.min, Math.min(this.max, parseFloat(stepped.toFixed(10))));
  }

  #setValue(v) {
    if (v === this.value) return;
    this.value = v;
    // §220 (v0.5.9): delegate to UIFormElement's shared throttle helper.
    // When `this.throttle > 0`, the dispatch is trailing-debounced
    // (collapses pointer-move bursts to one emission). When 0, fires
    // synchronously.
    this.scheduleThrottledInput();
  }

  #onPointerDown = (e) => {
    if (this.disabled) return;
    e.preventDefault();
    this.#dragging = true;
    this.setAttribute('data-dragging', '');
    // Capture the offset between the click point and the thumb's
    // *logical* center (derived from `this.value` via the same forward
    // equation the CSS uses). We deliberately do NOT read the thumb's
    // bounding rect here — the CSS `left` transition can leave the
    // physical rect mid-animation between values, producing a stale
    // offset that would translate into a snap on the first move.
    // Logical center is transition-immune and matches what #valueFromX
    // inverts.
    if (this.#trackEl && this.#thumbEl) {
      const trackRect = this.#trackEl.getBoundingClientRect();
      const thumbRect = this.#thumbEl.getBoundingClientRect();
      const W = trackRect.width;
      const t = thumbRect.width;
      const travel = W - t;
      const range = this.max - this.min;
      const p = range > 0 ? (this.value - this.min) / range : 0;
      const logicalCenter = trackRect.left + t / 2 + p * travel;
      this.#dragOffset = e.clientX - logicalCenter;
    } else {
      this.#dragOffset = 0;
    }
    this.#thumbEl.setPointerCapture(e.pointerId);
    this.#thumbEl.addEventListener('pointermove', this.#onPointerMove);
    this.#thumbEl.addEventListener('pointerup', this.#onPointerUp);
  };

  #onPointerMove = (e) => {
    if (!this.#dragging) return;
    // Subtract the captured offset so the thumb center tracks the
    // cursor relative to where the user originally pressed, avoiding
    // the initial snap.
    this.#setValue(this.#valueFromX(e.clientX - this.#dragOffset));
  };

  #onPointerUp = (e) => {
    this.#dragging = false;
    this.#dragOffset = 0;
    this.removeAttribute('data-dragging');
    this.#thumbEl.releasePointerCapture(e.pointerId);
    this.#thumbEl.removeEventListener('pointermove', this.#onPointerMove);
    this.#thumbEl.removeEventListener('pointerup', this.#onPointerUp);
    this.flushPendingInput(); // §220 (was §184 #flushInput): pending throttled input fires before change
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  };

  #onTrackClick = (e) => {
    if (this.disabled || e.target === this.#thumbEl) return;
    this.#setValue(this.#valueFromX(e.clientX));
    this.flushPendingInput(); // §220 (was §184 #flushInput): ensure trailing input precedes change
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  };

  #onKey = (e) => {
    if (this.disabled) return;
    let v = this.value;
    switch (e.key) {
      case 'ArrowRight': case 'ArrowUp':   v += this.step; break;
      case 'ArrowLeft':  case 'ArrowDown': v -= this.step; break;
      case 'Home': v = this.min; break;
      case 'End':  v = this.max; break;
      case 'PageUp':   v += this.step * 10; break;
      case 'PageDown': v -= this.step * 10; break;
      default: return;
    }
    e.preventDefault();
    this.#setValue(this.#snap(v));
    this.flushPendingInput(); // §220 (was §184 #flushInput): trailing input fires before change
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
  };

  disconnected() {
    super.disconnected();
    this.#thumbEl?.removeEventListener('pointerdown', this.#onPointerDown);
    this.#trackEl?.removeEventListener('click', this.#onTrackClick);
    this.#thumbEl?.removeEventListener('pointermove', this.#onPointerMove);
    this.#thumbEl?.removeEventListener('pointerup', this.#onPointerUp);
    this.removeEventListener('keydown', this.#onKey);
    // §220 (v0.5.9): UIFormElement.disconnected() auto-drops the pending
    // throttled input dispatch via super.disconnected().
    this.#trackEl = null;
    this.#thumbEl = null;
  }
}
