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

/**
 * <date-range-picker-ui>
 *
 * Compound form primitive for selecting a start + end date pair with
 * optional preset shortcuts. Composes <calendar-picker-ui> (two
 * instances — start + end pane) + <popover-ui> + <button-ui> for the
 * trigger and preset rail.
 *
 * Form participation:
 *   ElementInternals serializes the selected range as JSON:
 *     `{"from":"2026-01-01","to":"2026-01-07"}`
 *   under the [name] attribute. With [comparison] set, a secondary
 *   field `<name>-compare` carries the comparison range.
 *
 * Keyboard:
 *   Enter/Space (trigger) — open popover, focus start pane
 *   Escape (open)         — close popover (no commit)
 *   Tab (open)            — cycle trigger → start cal → end cal → presets → footer
 *   Arrows (calendar)     — navigate days (delegated to <calendar-picker-ui>)
 *
 * A11y:
 *   role=combobox on the host; role=dialog on the popover; focus moves
 *   to the start calendar's grid on open and returns to the trigger on
 *   close. Tab is trapped inside the popover while open.
 */

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

const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTHS_LONG  = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

function pad(n) { return String(n).padStart(2, '0'); }

function toISO(d) {
  if (!d) return '';
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

function parseISO(str) {
  if (!str) return null;
  const m = String(str).match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (!m) return null;
  const d = new Date(+m[1], +m[2] - 1, +m[3]);
  return Number.isNaN(d.getTime()) ? null : d;
}

function parseRange(v) {
  if (v == null) return null;
  if (typeof v === 'object') {
    if (!v.from || !v.to) return null;
    return { from: String(v.from), to: String(v.to) };
  }
  if (typeof v !== 'string' || !v.trim()) return null;
  try {
    const obj = JSON.parse(v);
    if (obj && obj.from && obj.to) return { from: String(obj.from), to: String(obj.to) };
  } catch {}
  return null;
}

function rangeIsOrdered(r) {
  if (!r) return false;
  return r.from <= r.to;
}

function formatDate(iso, fmt) {
  const d = parseISO(iso);
  if (!d) return '';
  switch (fmt) {
    case 'long':
      return `${MONTHS_LONG[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
    case 'iso':
      return iso;
    case 'short':
    default:
      return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
  }
}

/* Default presets (today is computed lazily on first render). */
function defaultPresets(today = new Date()) {
  const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate());
  const isoToday = toISO(t0);
  const sub = (days) => {
    const d = new Date(t0);
    d.setDate(d.getDate() - days);
    return toISO(d);
  };
  const startOfMonth = toISO(new Date(t0.getFullYear(), t0.getMonth(), 1));
  const endOfMonth   = toISO(new Date(t0.getFullYear(), t0.getMonth() + 1, 0));
  const startOfYear  = toISO(new Date(t0.getFullYear(), 0, 1));
  return [
    { label: 'Today',        range: { from: isoToday,      to: isoToday } },
    { label: 'Last 7 days',  range: { from: sub(6),        to: isoToday } },
    { label: 'Last 30 days', range: { from: sub(29),       to: isoToday } },
    { label: 'This month',   range: { from: startOfMonth,  to: endOfMonth } },
    { label: 'Year to date', range: { from: startOfYear,   to: isoToday } },
  ];
}

/* Default comparison presets (analytics surfaces). */
function defaultComparisonPresets() {
  return [
    { label: 'Previous period' },
    { label: 'Same period last year' },
    { label: 'Same period last month' },
  ];
}

export class UIDateRangePicker extends UIFormElement {
  // Per AGENTS.md §170: label is NOT first-class here — use <field-ui label>.
  static labelDeprecated = true;

  // §154: Phosphor icons this primitive auto-stamps (without consumer
  // markup). Aggregated by installIconLoadersForRegistered() across all
  // defined elements. Audited by check-required-icons.mjs.
  static requiredIcons = ['calendar', 'caret-down', 'arrow-right'];

  static get properties() {
    return {
      ...UIFormElement.properties,
      // value/compareValue are STRINGS at the attribute boundary (JSON
      // serialized); typed accessors expose the parsed object form.
      compareValue: { type: String,  default: '', attribute: 'compare-value' },
      comparison:   { type: Boolean, default: false, reflect: true },
      min:          { type: String,  default: '', reflect: true },
      max:          { type: String,  default: '', reflect: true },
      open:         { type: Boolean, default: false, reflect: true },
      placeholder:  { type: String,  default: 'Select range', reflect: true },
      format:       { type: String,  default: 'short', reflect: true },
      noPresets:    { type: Boolean, default: false, attribute: 'no-presets', reflect: true },
      // Popover placement — yaml documented reflect:true since v1.
      placement:    { type: String,  default: 'bottom', reflect: true },
    };
  }

  // Static parts — fixed structural skeleton stamped on first connect.
  // The popover is a `popover="manual"` element so we control showPopover()
  // / hidePopover() programmatically. Two calendar instances + a preset
  // rail + a calendar area sit inside.
  static parts = {
    trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" trailing-icon="caret-down" aria-label="Open date range picker"></button-ui>',
    popover: '<div slot="popover" popover="manual" role="dialog" aria-label="Date range picker"></div>',
    presets: '<div data-preset-rail role="group" aria-label="Date range presets"></div>',
    calArea: '<div data-calendar-area></div>',
    // Substrate primitive — bare grid, no trigger / no popover (added §FB-Wave1-QA).
    calFrom: '<calendar-grid-ui data-cal-from aria-label="Start date"></calendar-grid-ui>',
    calTo:   '<calendar-grid-ui data-cal-to aria-label="End date"></calendar-grid-ui>',
  };

  // No template — date-range-picker composes from authored light-DOM
  // children + ensured parts. An empty html`` result would trigger
  // stamp() → replaceChildren(), wiping authored [slot="presets|footer"]
  // before render() can migrate them.
  static template = () => null;

  // ── State ─────────────────────────────────────────────────────────
  #presetsAttr = null;   // explicit override via property
  #pending = null;       // partial range during selection
  #previousFocus = null; // focus restoration target on close
  #bound = false;
  #popoverShown = false; // local mirror of popover open state
  #triggerRef = null;
  #popoverRef = null;
  #presetRailRef = null;
  #calFromRef = null;
  #calToRef = null;
  #anchorCleanup = null; // §FB-Wave1-QA — popover anchor positioning cleanup

  // ── Public accessors ──────────────────────────────────────────────

  /** Parsed `{from, to}` object form of the value attribute (or null). */
  get rangeValue() {
    return parseRange(this.value);
  }
  set rangeValue(v) {
    if (v == null) {
      this.value = '';
    } else {
      this.value = JSON.stringify({ from: String(v.from), to: String(v.to) });
    }
  }

  /** Parsed `{from, to}` object form of compare-value (or null). */
  get rangeCompareValue() {
    return parseRange(this.compareValue);
  }
  set rangeCompareValue(v) {
    if (v == null) {
      this.compareValue = '';
    } else {
      this.compareValue = JSON.stringify({ from: String(v.from), to: String(v.to) });
    }
  }

  /** Override the preset list. Setter accepts an array of `{label, range}`. */
  get presets() {
    if (Array.isArray(this.#presetsAttr)) return this.#presetsAttr;
    return defaultPresets();
  }
  set presets(arr) {
    // Wrap in untracked() so reactive-property reads in this setter don't
    // leak subscriptions upward (per MEMORY entry: custom setter `untracked` wrap).
    untracked(() => {
      this.#presetsAttr = Array.isArray(arr) ? arr.slice() : null;
      if (this.isConnected) this.#renderPresets();
    });
  }

  // ── Imperative API ────────────────────────────────────────────────

  open$() { this.open = true; }
  close$() { this.open = false; }

  /**
   * Programmatically open the popover (matches the spec §4 method shape
   * `open()`). The reactive prop `open` is already exposed via the
   * setter; this method is the public commit point that also wires
   * focus + dispatches `open` event with trigger='programmatic'.
   */
  openPopover() {
    if (this.disabled || this.readonly) return;
    if (!this.open) {
      this.#previousFocus = document.activeElement;
      this.open = true;
      this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'programmatic' } }));
    }
  }

  /** Programmatically close. */
  closePopover(reason = 'programmatic') {
    if (!this.open) return;
    this.open = false;
    this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason } }));
  }

  /** Reset value (and compareValue when comparison is set). */
  clear() {
    this.value = '';
    if (this.comparison) this.compareValue = '';
    this.#pending = null;
    this.syncValue('');
  }

  /** Look up a preset by label and apply it. Returns true on hit. */
  applyPreset(label) {
    const preset = this.presets.find((p) => p.label === label);
    if (!preset || !preset.range) return false;
    this.#commitRange(preset.range, { presetLabel: preset.label });
    return true;
  }

  // ── Constraint validation ─────────────────────────────────────────

  /**
   * Override `syncValue` so the form value reflects the JSON-serialized
   * range. The base UIFormElement::syncValue writes raw `value` to the
   * form; we additionally write the comparison field if applicable.
   */
  syncValue(val) {
    const baseVal = val ?? this.value ?? '';
    const range = parseRange(baseVal);
    if (this.comparison && this.name) {
      // Compose a FormData so two named fields land in the form.
      const fd = new FormData();
      if (range) fd.append(this.name, baseVal);
      const cmp = parseRange(this.compareValue);
      if (cmp) fd.append(`${this.name}-compare`, this.compareValue);
      this.internals.setFormValue(fd);
    } else {
      this.internals.setFormValue(range ? baseVal : '');
    }
    this.#runRangeConstraints(range);
  }

  #runRangeConstraints(range) {
    if (this.required && !range) {
      this.internals.setValidity(
        { valueMissing: true },
        this.getAttribute('data-msg-required') || 'Please select a date range.',
        this,
      );
      return false;
    }
    if (range && !rangeIsOrdered(range)) {
      this.internals.setValidity(
        { rangeUnderflow: true },
        'End date must be on or after start date.',
        this,
      );
      return false;
    }
    if (range && this.min && range.from < this.min) {
      this.internals.setValidity(
        { rangeUnderflow: true },
        `Earliest selectable date is ${this.min}.`,
        this,
      );
      return false;
    }
    if (range && this.max && range.to > this.max) {
      this.internals.setValidity(
        { rangeOverflow: true },
        `Latest selectable date is ${this.max}.`,
        this,
      );
      return false;
    }
    this.internals.setValidity({});
    return true;
  }

  // ── Lifecycle ─────────────────────────────────────────────────────

  connected() {
    super.connected();
    this.setAttribute('role', 'combobox');
    this.setAttribute('aria-haspopup', 'dialog');
    this.setAttribute('aria-expanded', this.open ? 'true' : 'false');

    // Stamp parts via this.ensure() — idempotent. Wire listeners once.
    if (!this.#bound) {
      this.#bound = true;
      this.#ensureParts();
      this.#triggerRef.addEventListener('click', this.#onTriggerClick);
      // Register keydown in CAPTURE phase so we set the keyboard-origin
      // flag BEFORE <button-ui>'s own keydown handler synthesizes a
      // click via `this.click()` (see button/button.class.js:148).
      this.#triggerRef.addEventListener('keydown', this.#onTriggerKey, true);
      this.#popoverRef.addEventListener('click', this.#onPopoverClick);
      this.#popoverRef.addEventListener('keydown', this.#onPopoverKey);
      this.#calFromRef.addEventListener('change', this.#onCalFromChange);
      this.#calToRef.addEventListener('change', this.#onCalToChange);
    }
    // Wire form value on first connect.
    this.syncValue();
  }

  disconnected() {
    super.disconnected();
    if (this.#triggerRef) {
      this.#triggerRef.removeEventListener('click', this.#onTriggerClick);
      // Capture-phase removal must mirror the capture-phase registration.
      this.#triggerRef.removeEventListener('keydown', this.#onTriggerKey, true);
    }
    if (this.#popoverRef) {
      this.#popoverRef.removeEventListener('click', this.#onPopoverClick);
      this.#popoverRef.removeEventListener('keydown', this.#onPopoverKey);
      this.#popoverRef.hidePopover?.();
    }
    if (this.#calFromRef) this.#calFromRef.removeEventListener('change', this.#onCalFromChange);
    if (this.#calToRef) this.#calToRef.removeEventListener('change', this.#onCalToChange);
    document.removeEventListener('pointerdown', this.#onOutside);
    document.removeEventListener('keydown', this.#onDocKey);
    this.#anchorCleanup?.();
    this.#anchorCleanup = null;
    this.#bound = false;
    this.#popoverShown = false;
    this.#triggerRef = null;
    this.#popoverRef = null;
    this.#presetRailRef = null;
    this.#calFromRef = null;
    this.#calToRef = null;
  }

  #ensureParts() {
    // Trigger — light DOM. Default <button-ui> stamped unless author
    // supplied [slot="trigger"].
    this.#triggerRef = this.ensure('trigger');
    // Popover is a div hosted in light DOM but lifted to the top layer
    // via the Popover API on showPopover().
    this.#popoverRef = this.ensure('popover');
    if (this.id) this.#popoverRef.setAttribute('aria-labelledby', this.id);
    // Calendar area lives inside the popover.
    if (!this.#popoverRef.querySelector(':scope > [data-calendar-area]')) {
      const calArea = this.constructor._pp.calArea.cloneNode(true);
      this.#popoverRef.appendChild(calArea);
    }
    // Preset rail (unless author supplied [slot="presets"] OR no-presets is set).
    const authorPresets = logicalSlotted(this, 'presets')[0];
    if (authorPresets && authorPresets.parentElement !== this.#popoverRef) {
      this.#popoverRef.appendChild(authorPresets);
      this.#presetRailRef = authorPresets;
    } else if (!this.noPresets && !authorPresets) {
      if (!this.#popoverRef.querySelector(':scope > [data-preset-rail]')) {
        const rail = this.constructor._pp.presets.cloneNode(true);
        this.#popoverRef.insertBefore(rail, this.#popoverRef.querySelector(':scope > [data-calendar-area]'));
      }
      this.#presetRailRef = this.#popoverRef.querySelector(':scope > [data-preset-rail]');
    }
    // Two calendar panes — start + end.
    const calArea = this.#popoverRef.querySelector(':scope > [data-calendar-area]');
    if (!calArea.querySelector(':scope > [data-cal-from]')) {
      const calFrom = this.constructor._pp.calFrom.cloneNode(true);
      calArea.appendChild(calFrom);
    }
    if (!calArea.querySelector(':scope > [data-cal-to]')) {
      const calTo = this.constructor._pp.calTo.cloneNode(true);
      // Default the end pane to one month after the start pane so a fresh range
      // picker shows two consecutive months (not the same month twice). A `to`
      // value, once selected, overrides this hint inside the grid.
      const fromISO = parseRange(this.value)?.from;
      let y, mo; // mo: 0-based month
      if (fromISO) { const [yy, mm] = fromISO.split('-').map(Number); y = yy; mo = mm - 1; }
      else { const now = new Date(); y = now.getFullYear(); mo = now.getMonth(); }
      mo += 1; if (mo > 11) { mo = 0; y += 1; }
      calTo.setAttribute('month', `${y}-${pad(mo + 1)}`);
      calArea.appendChild(calTo);
    }
    this.#calFromRef = calArea.querySelector(':scope > [data-cal-from]');
    this.#calToRef = calArea.querySelector(':scope > [data-cal-to]');
    // Footer slot — author-supplied; lift into popover after calendar area.
    const authorFooter = logicalSlotted(this, 'footer')[0];
    if (authorFooter && authorFooter.parentElement !== this.#popoverRef) {
      this.#popoverRef.appendChild(authorFooter);
    }
  }

  render() {
    // Mirror reactive props onto the part nodes — runs in the element's
    // effect every time a tracked prop changes.
    if (!this.#triggerRef) return;
    const display = this.#displayText();
    this.#triggerRef.setAttribute('text', display);
    // Filled vs placeholder color (matches input/select) — #displayText()
    // returns the placeholder verbatim only when no range is committed.
    this.#triggerRef.toggleAttribute('data-has-value', display !== this.placeholder);
    this.#triggerRef.toggleAttribute('disabled', this.disabled);
    this.#triggerRef.setAttribute('aria-haspopup', 'dialog');
    this.#triggerRef.setAttribute('aria-expanded', this.open ? 'true' : 'false');
    this.setAttribute('aria-expanded', this.open ? 'true' : 'false');

    // Cascade min/max + selection state into the calendar panes.
    const range = parseRange(this.value);
    const pending = this.#pending;
    // Effective endpoints — pending (mid-click) takes precedence over
    // value. Push the SAME from/to onto BOTH grids so each independently
    // lights up its in-range cells via `[data-in-range]`. Without this,
    // only the two endpoint cells got `[data-selected]` and the days
    // between them rendered as plain background — the "fill the cells
    // between start and end dates" trap.
    const effFrom = pending?.from || range?.from || '';
    const effTo   = pending?.to   || range?.to   || '';
    const setRangeAttrs = (cal) => {
      if (effFrom) cal.setAttribute('range-start', effFrom);
      else cal.removeAttribute('range-start');
      if (effTo) cal.setAttribute('range-end', effTo);
      else cal.removeAttribute('range-end');
    };
    if (this.#calFromRef) {
      if (this.min) this.#calFromRef.setAttribute('min', this.min);
      if (this.max) this.#calFromRef.setAttribute('max', this.max);
      this.#calFromRef.value = pending?.from || range?.from || '';
      setRangeAttrs(this.#calFromRef);
    }
    if (this.#calToRef) {
      if (this.min) this.#calToRef.setAttribute('min', this.min);
      if (this.max) this.#calToRef.setAttribute('max', this.max);
      this.#calToRef.value = pending?.to || range?.to || '';
      setRangeAttrs(this.#calToRef);
    }

    // Render the preset rail (unless overridden by [slot="presets"] or
    // hidden via [no-presets]).
    if (!logicalSlotted(this, 'presets').length) {
      if (this.noPresets) {
        this.#presetRailRef?.remove();
        this.#presetRailRef = null;
      } else {
        if (!this.#presetRailRef) this.#ensureParts();
        this.#renderPresets();
      }
    }

    // Toggle the popover via the Popover API. We track open state
    // locally so the toggle works under happy-dom (where `:popover-open`
    // selector matching is unreliable) and in real browsers.
    if (this.#popoverRef) {
      if (this.open && !this.#popoverShown) {
        if (!this.disabled && !this.readonly) {
          this.#popoverShown = true;
          this.#popoverRef.showPopover?.();
          // §FB-Wave1-QA — anchor the popover to the trigger via the canonical
          // helper. Without this, the popover renders at viewport (0,0).
          // Matches select-ui + calendar-picker-ui's anchorPopover pattern.
          this.#anchorCleanup?.();
          this.#anchorCleanup = anchorPopover(this.#triggerRef, this.#popoverRef, {
            placement: this.getAttribute('placement') || 'bottom',
            gap: 4,
          });
          document.addEventListener('pointerdown', this.#onOutside);
          document.addEventListener('keydown', this.#onDocKey);
          // Defer focus to next microtask so the grid is paint-stable.
          queueMicrotask(() => {
            if (!this.open) return;
            const grid = this.#calFromRef?.querySelector?.('[data-cal-day]:not([disabled]):not([data-outside])');
            grid?.focus?.();
          });
        } else {
          // Disabled or readonly — refuse to open.
          this.open = false;
        }
      } else if (!this.open && this.#popoverShown) {
        this.#popoverShown = false;
        this.#anchorCleanup?.();
        this.#anchorCleanup = null;
        this.#popoverRef.hidePopover?.();
        document.removeEventListener('pointerdown', this.#onOutside);
        document.removeEventListener('keydown', this.#onDocKey);
        // Return focus to the trigger.
        this.#triggerRef?.focus?.();
      }
    }
  }

  // ── Trigger text formatter ────────────────────────────────────────

  #displayText() {
    const range = parseRange(this.value);
    if (!range) return this.placeholder;
    const from = formatDate(range.from, this.format);
    const to = formatDate(range.to, this.format);
    if (!from || !to) return this.placeholder;
    return `${from} → ${to}`;
  }

  // ── Preset rail rendering ─────────────────────────────────────────

  #renderPresets() {
    if (!this.#presetRailRef) return;
    // Wipe + re-stamp; preset cardinality is small (≤10).
    this.#presetRailRef.replaceChildren();
    for (const preset of this.presets) {
      const btn = document.createElement('button-ui');
      btn.setAttribute('variant', 'ghost');
      btn.setAttribute('type', 'button');
      btn.setAttribute('text', preset.label);
      btn.setAttribute('size', 'sm');
      btn.dataset.presetLabel = preset.label;
      this.#presetRailRef.appendChild(btn);
    }
    // Comparison cluster (when [comparison] is set).
    if (this.comparison) {
      const divider = document.createElement('divider-ui');
      divider.dataset.presetDivider = '';
      this.#presetRailRef.appendChild(divider);
      for (const preset of defaultComparisonPresets()) {
        const btn = document.createElement('button-ui');
        btn.setAttribute('variant', 'ghost');
        btn.setAttribute('type', 'button');
        btn.setAttribute('text', preset.label);
        btn.setAttribute('size', 'sm');
        btn.dataset.comparisonPresetLabel = preset.label;
        this.#presetRailRef.appendChild(btn);
      }
    }
  }

  // ── Event handlers ────────────────────────────────────────────────

  // Flag set by #onTriggerKey to mark the *next* click as keyboard-originated.
  // <button-ui> calls `this.click()` from its own Enter/Space keydown handler
  // (see button/button.class.js:148), so the click that follows a keyboard press is
  // synthetic. We use this flag to disambiguate the `trigger:` field on the
  // `open` event.
  #keyboardOpen = false;

  #onTriggerClick = (e) => {
    if (this.disabled) return;
    if (this.readonly) return;
    e.stopPropagation();
    const fromKeyboard = this.#keyboardOpen;
    this.#keyboardOpen = false;
    if (this.open) {
      this.closePopover('outside');
    } else {
      this.#previousFocus = document.activeElement;
      this.open = true;
      this.dispatchEvent(new CustomEvent('open', {
        bubbles: true,
        detail: { trigger: fromKeyboard ? 'keyboard' : 'click' },
      }));
    }
  };

  #onTriggerKey = (e) => {
    if (this.disabled) return;
    if (e.key === 'Enter' || e.key === ' ') {
      if (this.readonly) {
        e.preventDefault();
        return;
      }
      // Flag the synthetic click that <button-ui>'s own keydown handler
      // will fire via `this.click()` — see comment on #keyboardOpen.
      this.#keyboardOpen = true;
    }
  };

  #onPopoverClick = (e) => {
    if (this.readonly) {
      // Block preset / day clicks but allow the calendar widgets to
      // remain focusable for screen-reader inspection.
      const presetBtn = e.target.closest('[data-preset-label],[data-comparison-preset-label]');
      const dayBtn = e.target.closest('[data-cal-day]');
      if (presetBtn || dayBtn) {
        e.stopPropagation();
        e.preventDefault();
        return;
      }
    }
    const presetBtn = e.target.closest('[data-preset-label]');
    if (presetBtn) {
      e.stopPropagation();
      this.applyPreset(presetBtn.dataset.presetLabel);
      this.closePopover('apply');
      return;
    }
    const cmpPresetBtn = e.target.closest('[data-comparison-preset-label]');
    if (cmpPresetBtn) {
      e.stopPropagation();
      // For v1, comparison-presets are tagged but compute their range
      // from the current primary range. If no primary is set, no-op.
      const primary = parseRange(this.value);
      if (!primary) return;
      const cmp = this.#computeComparisonRange(cmpPresetBtn.dataset.comparisonPresetLabel, primary);
      if (cmp) this.rangeCompareValue = cmp;
      return;
    }
  };

  #onPopoverKey = (e) => {
    if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
      this.closePopover('escape');
      return;
    }
    if (e.key === 'Tab') {
      // Trap focus inside the popover. Build the tab-cycle by querying
      // focusable descendants of the popover; the trigger is *outside*
      // the popover and Tab cycles inside until Escape.
      const focusables = this.#getFocusables();
      if (focusables.length === 0) return;
      const idx = focusables.indexOf(document.activeElement);
      if (e.shiftKey) {
        if (idx <= 0) {
          e.preventDefault();
          focusables[focusables.length - 1].focus();
        }
      } else {
        if (idx === -1 || idx === focusables.length - 1) {
          e.preventDefault();
          focusables[0].focus();
        }
      }
    }
  };

  #onDocKey = (e) => {
    // Belt-and-braces: Escape closes even if focus is outside the popover
    // (e.g., the trigger).
    if (e.key === 'Escape' && this.open) {
      this.closePopover('escape');
    }
  };

  #onCalFromChange = (e) => {
    if (this.readonly) return;
    e.stopPropagation();
    const iso = e.detail?.value || this.#calFromRef?.value || '';
    if (!iso) return;
    const existing = parseRange(this.value);
    if (!this.#pending) this.#pending = { from: '', to: '' };
    this.#pending.from = iso;
    // Live emit `input` for pending state.
    this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: { from: iso, to: null } } }));
    // Attempt commit when both halves are set (in either order). The
    // commit path handles reversed-range invalidation.
    const toCandidate = this.#pending.to || existing?.to || null;
    if (toCandidate) {
      this.#commitRange({ from: iso, to: toCandidate });
    }
  };

  #onCalToChange = (e) => {
    if (this.readonly) return;
    e.stopPropagation();
    const iso = e.detail?.value || this.#calToRef?.value || '';
    if (!iso) return;
    const existing = parseRange(this.value);
    if (!this.#pending) this.#pending = { from: '', to: '' };
    this.#pending.to = iso;
    this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: { from: this.#pending.from || existing?.from || null, to: iso } } }));
    const fromCandidate = this.#pending.from || existing?.from || null;
    if (fromCandidate) {
      this.#commitRange({ from: fromCandidate, to: iso });
    }
  };

  #onOutside = (e) => {
    if (!this.open) return;
    // Click inside the host or the popover doesn't close.
    if (this.contains(e.target)) return;
    if (this.#popoverRef && e.composedPath?.().includes(this.#popoverRef)) return;
    this.closePopover('outside');
  };

  // ── Commit + invalid emission ─────────────────────────────────────

  #commitRange(range, extras = {}) {
    if (!range || !range.from || !range.to) return;
    if (!rangeIsOrdered(range)) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: range, reason: 'reversed' },
      }));
      return;
    }
    if (this.min && range.from < this.min) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: range, reason: 'below-min' },
      }));
      return;
    }
    if (this.max && range.to > this.max) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: range, reason: 'above-max' },
      }));
      return;
    }
    const serialized = JSON.stringify({ from: range.from, to: range.to });
    this.value = serialized;
    this.#pending = null;
    this.syncValue(serialized);
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: {
        value: { from: range.from, to: range.to },
        compareValue: parseRange(this.compareValue),
        presetLabel: extras.presetLabel || null,
      },
    }));
  }

  #computeComparisonRange(label, primary) {
    const from = parseISO(primary.from);
    const to = parseISO(primary.to);
    if (!from || !to) return null;
    const days = Math.round((to - from) / 86400000) + 1;
    switch (label) {
      case 'Previous period': {
        const cmpTo = new Date(from);
        cmpTo.setDate(cmpTo.getDate() - 1);
        const cmpFrom = new Date(cmpTo);
        cmpFrom.setDate(cmpFrom.getDate() - (days - 1));
        return { from: toISO(cmpFrom), to: toISO(cmpTo) };
      }
      case 'Same period last year': {
        const cmpFrom = new Date(from);
        cmpFrom.setFullYear(cmpFrom.getFullYear() - 1);
        const cmpTo = new Date(to);
        cmpTo.setFullYear(cmpTo.getFullYear() - 1);
        return { from: toISO(cmpFrom), to: toISO(cmpTo) };
      }
      case 'Same period last month': {
        const cmpFrom = new Date(from);
        cmpFrom.setMonth(cmpFrom.getMonth() - 1);
        const cmpTo = new Date(to);
        cmpTo.setMonth(cmpTo.getMonth() - 1);
        return { from: toISO(cmpFrom), to: toISO(cmpTo) };
      }
      default:
        return null;
    }
  }

  #getFocusables() {
    if (!this.#popoverRef) return [];
    const SEL = [
      'button-ui:not([disabled])',
      'button:not([disabled])',
      'calendar-grid-ui:not([disabled])',
      '[data-cal-day]:not([disabled]):not([data-outside])',
      '[tabindex="0"]',
    ].join(',');
    return Array.from(this.#popoverRef.querySelectorAll(SEL))
      .filter((el) => el.offsetParent !== null || el.matches(':popover-open *'));
  }
}
