/**
 * 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>
 *
 * Single-thumb layout:
 *   [label]                                  [value] [suffix]
 *   [=====fill=====●─────────────────track───────────────────]
 *
 * Dual-thumb layout (set [dual] + [lower-value] / [upper-value]):
 *   [label]                              [lower–upper] [suffix]
 *   [───────●═════════════════●──────────────────────────────]
 *
 * 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 — single-thumb mode; ignored when [dual]. */
    value:       { type: Number,  default: 50,  reflect: true },
    /** dual: when true, two-thumb mode; lowerValue / upperValue authoritative. */
    dual:        { type: Boolean, default: false, reflect: true },
    /** lowerValue: dual mode; clamped to ≤ upperValue. */
    lowerValue:  { type: Number,  default: 0,   reflect: true, attribute: 'lower-value' },
    /** upperValue: dual mode; clamped to ≥ lowerValue. */
    upperValue:  { type: Number,  default: 100, reflect: true, attribute: 'upper-value' },
    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/§220 throttle inherited from UIFormElement.
  };

  static template = () => null;

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

  #trackEl = null;
  #thumbEl = null;
  #thumbLowerEl = null;
  #thumbUpperEl = null;
  #dragging = false;
  #draggingThumb = null; // 'lower' | 'upper' | null (dual only)
  #dragOffset = 0;
  // Previous-render values used by the dual-mode constraint to decide
  // which thumb to clamp when lower > upper. NaN sentinel = first render.
  #prevLowerValue = NaN;
  #prevUpperValue = NaN;

  #pctOf(v) {
    const range = this.max - this.min;
    return range > 0 ? (v - this.min) / range : 0;
  }

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

  connected() {
    super.connected();
    const isDual = this.dual;
    this.setAttribute('role', isDual ? 'group' : 'slider');
    this.setAttribute('tabindex', '0');
    if (!isDual) {
      this.setAttribute('aria-valuemin', this.min);
      this.setAttribute('aria-valuemax', this.max);
    } else {
      // Group host should not carry valuemin/max — the per-thumb sliders do.
      this.removeAttribute('aria-valuemin');
      this.removeAttribute('aria-valuemax');
    }
    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}` : '';
      const lowerLabel = this.label ? `${this.label} lower bound` : 'Lower bound';
      const upperLabel = this.label ? `${this.label} upper bound` : 'Upper bound';
      const readout = isDual
        ? `<span slot="value-lower">${this.#format(this.lowerValue)}</span><span slot="value-sep" aria-hidden="true">–</span><span slot="value-upper">${this.#format(this.upperValue)}</span>`
        : `<span slot="value">${this.#format(this.value)}</span>`;
      const thumbs = isDual
        ? `<div slot="thumb" data-thumb="lower" tabindex="0" role="slider" aria-label="${lowerLabel}" aria-valuemin="${this.min}" aria-valuemax="${this.max}"></div><div slot="thumb" data-thumb="upper" tabindex="0" role="slider" aria-label="${upperLabel}" aria-valuemin="${this.min}" aria-valuemax="${this.max}"></div>`
        : `<div slot="thumb" tabindex="0"></div>`;
      this.innerHTML = `
        <div slot="header">
          ${this.label ? `<span slot="label">${this.label}</span>` : ''}
          <span slot="readout">
            ${readout}
            ${this.suffix ? `<span slot="suffix">${this.suffix}</span>` : ''}
          </span>
        </div>
        <div slot="track">
          <div slot="fill"></div>
          ${thumbs}
        </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"]');
    if (isDual) {
      this.#thumbLowerEl = this.querySelector('[slot="thumb"][data-thumb="lower"]');
      this.#thumbUpperEl = this.querySelector('[slot="thumb"][data-thumb="upper"]');
      this.#thumbLowerEl?.addEventListener('pointerdown', this.#onPointerDown);
      this.#thumbUpperEl?.addEventListener('pointerdown', this.#onPointerDown);
    } else {
      this.#thumbEl = this.querySelector('[slot="thumb"]');
      this.#thumbEl?.addEventListener('pointerdown', this.#onPointerDown);
    }

    this.#trackEl?.addEventListener('click', this.#onTrackClick);
    this.addEventListener('keydown', this.#onKey);
  }

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

    // §153 function-typed value guard (extended to dual props).
    if (typeof this.value === 'function' || typeof this.lowerValue === 'function' || typeof this.upperValue === 'function') {
      // eslint-disable-next-line no-console
      console.warn(
        '[slider-ui] .value / .lowerValue / .upperValue 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;
    }

    if (this.dual) {
      // Bidirectional constraint: lower ≤ upper, but clamp the prop that
      // was just changed (so direct programmatic prop assignment behaves
      // intuitively in both directions).
      //   - lower bumped up past upper  → clamp lower DOWN to upper
      //   - upper bumped down past lower → clamp upper UP to lower
      //   - both/neither (first render) → fall back to clamp lower DOWN
      if (this.lowerValue > this.upperValue) {
        const lowerChanged = !Number.isNaN(this.#prevLowerValue) && this.lowerValue !== this.#prevLowerValue;
        const upperChanged = !Number.isNaN(this.#prevUpperValue) && this.upperValue !== this.#prevUpperValue;
        if (upperChanged && !lowerChanged) {
          this.upperValue = this.lowerValue;
        } else {
          this.lowerValue = this.upperValue;
        }
      }
      this.#prevLowerValue = this.lowerValue;
      this.#prevUpperValue = this.upperValue;
      const pctL = this.#pctOf(this.lowerValue);
      const pctU = this.#pctOf(this.upperValue);
      this.style.setProperty('--slider-pct-lower', String(pctL));
      this.style.setProperty('--slider-pct-upper', String(pctU));

      const valueLEl = this.querySelector('[slot="value-lower"]');
      const valueUEl = this.querySelector('[slot="value-upper"]');
      if (valueLEl) valueLEl.textContent = this.#format(this.lowerValue);
      if (valueUEl) valueUEl.textContent = this.#format(this.upperValue);

      if (this.#thumbLowerEl) {
        this.#thumbLowerEl.setAttribute('aria-valuenow', this.lowerValue);
        this.#thumbLowerEl.setAttribute('aria-valuetext', `${this.#format(this.lowerValue)}${this.suffix ? ' ' + this.suffix : ''}`);
      }
      if (this.#thumbUpperEl) {
        this.#thumbUpperEl.setAttribute('aria-valuenow', this.upperValue);
        this.#thumbUpperEl.setAttribute('aria-valuetext', `${this.#format(this.upperValue)}${this.suffix ? ' ' + this.suffix : ''}`);
      }
      this.syncValue(`${this.lowerValue},${this.upperValue}`);
    } else {
      const pct = this.#pctOf(this.value);
      this.style.setProperty('--slider-pct', String(pct));

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

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

    // §FB-45: label + suffix late-binding re-sync (shared across modes).
    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;
  }

  /**
   * Inverse geometry: given a *desired* thumb-center viewport-x, compute
   * the slider value such that the named thumb's visual center lands at
   * that coordinate (clamped to min/max).
   *
   * Forward geometry differs by mode (see slider.css):
   *   single                      → center = t/2  + p · (W − t)
   *   dual lower (data-thumb=lower) → center = t/2  + p · (W − 2t)
   *   dual upper (data-thumb=upper) → center = 1.5t + p · (W − 2t)
   *
   * The dual model reserves 2t of travel-space so the two thumbs can
   * never overlap — they touch edge-to-edge at equal values and span the
   * full track at opposite extremes.
   *
   * @param {number} clientX  viewport x of the desired thumb center
   * @param {'lower'|'upper'|null} which  which thumb's geometry to invert
   *   (null = single-thumb mode); ignored when this.dual is false
   */
  #valueFromX(clientX, which = null) {
    const refThumb = this.#thumbEl || this.#thumbLowerEl || this.#thumbUpperEl;
    if (!this.#trackEl || !refThumb) return this.min;
    const trackRect = this.#trackEl.getBoundingClientRect();
    const t = refThumb.getBoundingClientRect().width;
    const W = trackRect.width;
    let travel, offset;
    if (this.dual) {
      travel = W - 2 * t;
      offset = which === 'upper' ? 1.5 * t : 0.5 * t;
    } else {
      travel = W - t;
      offset = 0.5 * t;
    }
    if (travel <= 0) return this.min;
    const target = clientX - trackRect.left;
    const ratio = Math.max(0, Math.min(1, (target - offset) / travel));
    const raw = this.min + ratio * (this.max - this.min);
    return this.#snap(raw);
  }

  // Visual-center of the named thumb in viewport coords. Used by drag
  // logic to capture the cursor offset relative to the thumb's logical
  // (transition-immune) center. Mirrors the forward geometry in
  // slider.css per-mode.
  #centerOfThumb(which) {
    if (!this.#trackEl) return 0;
    const refThumb = this.#thumbEl || this.#thumbLowerEl || this.#thumbUpperEl;
    if (!refThumb) return 0;
    const trackRect = this.#trackEl.getBoundingClientRect();
    const t = refThumb.getBoundingClientRect().width;
    const W = trackRect.width;
    if (this.dual) {
      const travel = W - 2 * t;
      const p = which === 'upper' ? this.#pctOf(this.upperValue) : this.#pctOf(this.lowerValue);
      const offset = which === 'upper' ? 1.5 * t : 0.5 * t;
      return trackRect.left + offset + p * travel;
    } else {
      return trackRect.left + 0.5 * t + this.#pctOf(this.value) * (W - t);
    }
  }

  #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;
    this.scheduleThrottledInput();
  }

  #setLowerValue(v) {
    const clamped = Math.min(Math.max(v, this.min), this.upperValue);
    if (clamped === this.lowerValue) return;
    this.lowerValue = clamped;
    this.scheduleThrottledInput();
  }

  #setUpperValue(v) {
    const clamped = Math.max(Math.min(v, this.max), this.lowerValue);
    if (clamped === this.upperValue) return;
    this.upperValue = clamped;
    this.scheduleThrottledInput();
  }

  #emitChange() {
    const detail = this.dual
      ? { lower: this.lowerValue, upper: this.upperValue }
      : { value: this.value };
    this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail }));
  }

  #onPointerDown = (e) => {
    if (this.disabled) return;
    e.preventDefault();
    this.#dragging = true;
    this.setAttribute('data-dragging', '');

    let thumb;
    if (this.dual) {
      const which = e.currentTarget?.dataset?.thumb;
      this.#draggingThumb = which === 'upper' ? 'upper' : 'lower';
      thumb = this.#draggingThumb === 'lower' ? this.#thumbLowerEl : this.#thumbUpperEl;
      // Lift the dragged thumb above its sibling so it stays visually
      // anchored even when both thumbs land at the same value.
      thumb?.setAttribute('data-active', '');
    } else {
      thumb = this.#thumbEl;
    }

    if (this.#trackEl && thumb) {
      // Logical center derived from the forward geometry (transition-immune),
      // not from getBoundingClientRect which can be mid-animation.
      const which = this.dual ? this.#draggingThumb : null;
      this.#dragOffset = e.clientX - this.#centerOfThumb(which);
    } else {
      this.#dragOffset = 0;
    }

    thumb.setPointerCapture(e.pointerId);
    thumb.addEventListener('pointermove', this.#onPointerMove);
    thumb.addEventListener('pointerup', this.#onPointerUp);
  };

  #onPointerMove = (e) => {
    if (!this.#dragging) return;
    const adjustedX = e.clientX - this.#dragOffset;
    if (this.dual) {
      const v = this.#valueFromX(adjustedX, this.#draggingThumb);
      if (this.#draggingThumb === 'lower') this.#setLowerValue(v);
      else this.#setUpperValue(v);
    } else {
      this.#setValue(this.#valueFromX(adjustedX));
    }
  };

  #onPointerUp = (e) => {
    this.#dragging = false;
    this.#dragOffset = 0;
    this.removeAttribute('data-dragging');
    const thumb = this.dual
      ? (this.#draggingThumb === 'lower' ? this.#thumbLowerEl : this.#thumbUpperEl)
      : this.#thumbEl;
    thumb?.releasePointerCapture(e.pointerId);
    thumb?.removeEventListener('pointermove', this.#onPointerMove);
    thumb?.removeEventListener('pointerup', this.#onPointerUp);
    // Drop the active marker — but only after emitting change, so any
    // mid-drag z-index lift stays painted through the up event.
    thumb?.removeAttribute('data-active');
    this.flushPendingInput(); // §220 (was §184 #flushInput): pending throttled input fires before change
    this.#emitChange();
    this.#draggingThumb = null;
  };

  #onTrackClick = (e) => {
    if (this.disabled) return;
    if (this.dual) {
      if (e.target === this.#thumbLowerEl || e.target === this.#thumbUpperEl) return;
      // Pick the thumb closer to the click in pixel space (using each
      // thumb's own forward-geometry center), then re-invert with that
      // thumb's geometry so the chosen thumb lands precisely under the
      // cursor.
      const distLower = Math.abs(e.clientX - this.#centerOfThumb('lower'));
      const distUpper = Math.abs(e.clientX - this.#centerOfThumb('upper'));
      if (distLower <= distUpper) {
        this.#setLowerValue(this.#valueFromX(e.clientX, 'lower'));
      } else {
        this.#setUpperValue(this.#valueFromX(e.clientX, 'upper'));
      }
    } else {
      if (e.target === this.#thumbEl) return;
      this.#setValue(this.#valueFromX(e.clientX));
    }
    this.flushPendingInput();
    this.#emitChange();
  };

  #onKey = (e) => {
    if (this.disabled) return;
    let delta = 0;
    let absolute = null;
    switch (e.key) {
      case 'ArrowRight': case 'ArrowUp':   delta =  this.step; break;
      case 'ArrowLeft':  case 'ArrowDown': delta = -this.step; break;
      case 'Home':       absolute = this.min; break;
      case 'End':        absolute = this.max; break;
      case 'PageUp':     delta =  this.step * 10; break;
      case 'PageDown':   delta = -this.step * 10; break;
      default: return;
    }
    e.preventDefault();

    if (this.dual) {
      // Move whichever thumb is focused. Default to lower when neither.
      const active = this.ownerDocument?.activeElement;
      const targetThumb = active === this.#thumbUpperEl ? 'upper' : 'lower';
      const current = targetThumb === 'lower' ? this.lowerValue : this.upperValue;
      let next = absolute !== null ? absolute : current + delta;
      next = this.#snap(next);
      if (targetThumb === 'lower') this.#setLowerValue(next);
      else this.#setUpperValue(next);
    } else {
      let v = absolute !== null ? absolute : this.value + delta;
      this.#setValue(this.#snap(v));
    }
    this.flushPendingInput();
    this.#emitChange();
  };

  disconnected() {
    super.disconnected();
    this.#thumbEl?.removeEventListener('pointerdown', this.#onPointerDown);
    this.#thumbLowerEl?.removeEventListener('pointerdown', this.#onPointerDown);
    this.#thumbUpperEl?.removeEventListener('pointerdown', this.#onPointerDown);
    this.#trackEl?.removeEventListener('click', this.#onTrackClick);
    // Pointermove/up listeners are attached transiently per-drag to the
    // active thumb; #onPointerUp removes them. If disconnect happens
    // mid-drag the browser tears down listeners with the element anyway.
    this.removeEventListener('keydown', this.#onKey);
    this.#trackEl = null;
    this.#thumbEl = null;
    this.#thumbLowerEl = null;
    this.#thumbUpperEl = null;
  }
}
