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

/**
 * <datetime-picker-ui> — SPEC-038
 *
 * Single-control date + time selection. Composes:
 *   • <calendar-picker-ui>  — the date pane (left)
 *   • <time-picker-ui>      — the time pane (right; SPEC-043)
 *   • <popover-ui>          — the floating panel surface (manual popover)
 *   • <button-ui>           — default trigger + optional footer actions
 *   • <divider-ui>          — visual separator between panes
 *
 * Form participation:
 *   ElementInternals serializes the picked ISO 8601 datetime ("YYYY-MM-
 *   DDTHH:mm" or "...:ss") as the form value under [name]. Per ADR-0025
 *   we never wrap a native <input type="datetime-local">; the calendar
 *   pane + time pane provide the affordance, ElementInternals provides
 *   the form contract.
 *
 * Keyboard model (composed from the underlying primitives):
 *   Enter/Space (trigger) — open popover; focus the calendar grid
 *   Escape (open)         — close popover (no commit) + return focus
 *   Tab (open)            — cycle trigger → calendar → time pane → footer
 *   Arrows (calendar)     — delegated to <calendar-picker-ui> APG model
 *   Arrows (time segment) — delegated to <time-picker-ui> Spinbutton model
 *
 * A11y:
 *   role=combobox on host + aria-haspopup=dialog + aria-expanded.
 *   role=dialog on the popover. Focus is trapped while open; returns to
 *   trigger on close. The calendar pane and time pane each carry their
 *   own inherited WAI-APG model (Date Picker Dialog + Spinbutton).
 *
 * Static parts:
 *   trigger / popover / calPane / timePane / divider — all stamped on
 *   first connect via this.ensure(). No HTML template (light-DOM authored
 *   slot="trigger" or slot="footer" must survive render()).
 */

import { UIFormElement } from '../../core/form.js';
import { anchorPopover } from '../../core/anchor.js';
import { untracked } from '../../core/signals.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 pad2(n) { return String(n).padStart(2, '0'); }

/** Parse "YYYY-MM-DDTHH:mm" or "YYYY-MM-DDTHH:mm:ss" → {date, time} or null. */
function parseDatetime(str) {
  if (!str) return null;
  const s = String(str).trim();
  // Tolerate space separator as a Generative-UI quality-of-life affordance;
  // canonical wire form is always "T".
  const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{1,2}:\d{2}(?::\d{2})?)$/.exec(s);
  if (!m) return null;
  return { date: m[1], time: m[2] };
}

/** Compose canonical "YYYY-MM-DDTHH:mm[:ss]" from a {date, time} pair. */
function formatDatetime(date, time, precision) {
  if (!date || !time) return '';
  // Coerce missing seconds when precision="second"; strip seconds otherwise.
  const tm = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time);
  if (!tm) return '';
  const hh = pad2(Number(tm[1]));
  const mm = tm[2];
  const ss = tm[3] || '00';
  return precision === 'second' ? `${date}T${hh}:${mm}:${ss}` : `${date}T${hh}:${mm}`;
}

function formatDatePart(iso, fmt) {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
  if (!m) return '';
  const y = +m[1];
  const mo = +m[2] - 1;
  const d = +m[3];
  switch (fmt) {
    case 'long': return `${MONTHS_LONG[mo]} ${d}, ${y}`;
    case 'iso':  return iso;
    case 'short':
    default:     return `${MONTHS_SHORT[mo]} ${d}, ${y}`;
  }
}

function formatTimePart(time, precision, hourcycle) {
  const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time || '');
  if (!m) return '';
  let h = +m[1];
  const mm = m[2];
  const ss = m[3];
  // Display in the requested cycle (h12 derives display from 24h storage).
  let hStr;
  let meridiem = '';
  if (hourcycle === 'h12') {
    const isPM = h >= 12;
    const dispH = (h % 12) || 12;
    hStr = pad2(dispH);
    meridiem = isPM ? ' PM' : ' AM';
  } else {
    hStr = pad2(h);
  }
  const sec = (precision === 'second' && ss != null) ? `:${ss}` : '';
  return `${hStr}:${mm}${sec}${meridiem}`;
}

export class UIDatetimePicker extends UIFormElement {
  // Per AGENTS.md / FEEDBACK guidance: label is NOT first-class here —
  // use <field-ui label> wrap. Mark deprecated so the base warns once.
  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', 'clock', 'caret-down'];

  static get properties() {
    return {
      ...UIFormElement.properties,
      min:         { type: String,  default: '',       reflect: true },
      max:         { type: String,  default: '',       reflect: true },
      step:        { type: Number,  default: 60,       reflect: true },
      precision:   { type: String,  default: 'minute', reflect: true },
      hourcycle:   { type: String,  default: '',       reflect: true, attribute: 'hour-cycle' },
      open:        { type: Boolean, default: false,    reflect: true },
      placeholder: { type: String,  default: 'Select date and time', reflect: false },
      format:      { type: String,  default: 'short',  reflect: true },
      locale:      { type: String,  default: '',       reflect: false },
      // 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. Mirrors
  // date-range-picker's shape (the closest sibling).
  static parts = {
    trigger:  '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" trailing-icon="caret-down" aria-label="Open date and time picker"></button-ui>',
    popover:  '<div slot="popover" popover="manual" role="dialog" aria-label="Date and time picker"></div>',
    // §FB-Wave1-QA — substrate primitive (extracted from calendar-picker-ui).
    calPane:  '<calendar-grid-ui data-cal-pane aria-label="Date"></calendar-grid-ui>',
    divider:  '<divider-ui data-pane-divider vertical></divider-ui>',
    timePane: '<time-picker-ui data-time-pane></time-picker-ui>',
  };

  // No html`` template — light-DOM authored [slot="trigger"|"footer"]
  // must survive render() (an empty html`` result triggers stamp() →
  // replaceChildren() which would wipe authored slots).
  static template = () => null;

  // ── State ─────────────────────────────────────────────────────────
  #pending = null;        // partial {date, time} during selection
  #previousFocus = null;  // focus restoration target on close
  #bound = false;
  #popoverShown = false;
  #triggerRef = null;
  #popoverRef = null;
  #calPaneRef = null;
  #timePaneRef = null;
  #dividerRef = null;
  #anchorCleanup = null;  // §FB-Wave1-QA — popover anchor positioning cleanup
  // Flag set by #onTriggerKey to mark the *next* click as keyboard-origin.
  // <button-ui> calls `this.click()` from its own keydown handler, so a
  // click after Enter/Space is synthetic. Mirrors date-range-picker.
  #keyboardOpen = false;

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

  /** Parsed `{date, time}` object form of the value attribute (or null). */
  get datetimeValue() {
    return parseDatetime(this.value);
  }
  set datetimeValue(v) {
    untracked(() => {
      if (v == null) {
        this.value = '';
      } else if (typeof v === 'string') {
        this.value = v;
      } else if (v.date && v.time) {
        this.value = formatDatetime(v.date, v.time, this.precision);
      } else {
        this.value = '';
      }
    });
  }

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

  /** Programmatically open the popover. Mirrors date-range-picker. */
  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 to empty string. */
  clear() {
    this.value = '';
    this.#pending = null;
    this.syncValue('');
  }

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

  /**
   * Override `syncValue` to run datetime-specific constraints. The base
   * UIFormElement::syncValue writes raw `value` to the form; we
   * additionally validate against parseability + min / max.
   */
  syncValue(val) {
    const baseVal = val ?? this.value ?? '';
    this.internals.setFormValue(baseVal);
    this.#runDatetimeConstraints(baseVal);
  }

  #runDatetimeConstraints(val) {
    if (this.required && !val) {
      this.internals.setValidity(
        { valueMissing: true },
        this.getAttribute('data-msg-required') || 'Please select a date and time.',
        this,
      );
      return false;
    }
    if (!val) {
      this.internals.setValidity({});
      return true;
    }
    const parsed = parseDatetime(val);
    if (!parsed) {
      this.internals.setValidity(
        { badInput: true },
        'Invalid date or time format.',
        this,
      );
      return false;
    }
    if (this.min && val < this.min) {
      this.internals.setValidity(
        { rangeUnderflow: true },
        `Earliest selectable datetime is ${this.min}.`,
        this,
      );
      return false;
    }
    if (this.max && val > this.max) {
      this.internals.setValidity(
        { rangeOverflow: true },
        `Latest selectable datetime 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');

    if (!this.#bound) {
      this.#bound = true;
      this.#ensureParts();
      this.#triggerRef.addEventListener('click', this.#onTriggerClick);
      // Capture-phase so we flag keyboard-origin BEFORE <button-ui>'s
      // own keydown synthesizes the click (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.#calPaneRef.addEventListener('change', this.#onCalPaneChange);
      this.#timePaneRef.addEventListener('change', this.#onTimePaneChange);
      this.#timePaneRef.addEventListener('input', this.#onTimePaneInput);
      this.#timePaneRef.addEventListener('invalid', this.#onPaneInvalid);
    }
    this.syncValue();
  }

  disconnected() {
    super.disconnected();
    if (this.#triggerRef) {
      this.#triggerRef.removeEventListener('click', this.#onTriggerClick);
      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.#calPaneRef) this.#calPaneRef.removeEventListener('change', this.#onCalPaneChange);
    if (this.#timePaneRef) {
      this.#timePaneRef.removeEventListener('change', this.#onTimePaneChange);
      this.#timePaneRef.removeEventListener('input', this.#onTimePaneInput);
      this.#timePaneRef.removeEventListener('invalid', this.#onPaneInvalid);
    }
    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.#calPaneRef = null;
    this.#timePaneRef = null;
    this.#dividerRef = null;
    this.#pending = null;
  }

  #ensureParts() {
    // Trigger — light DOM. Default <button-ui> stamped unless consumer
    // 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);

    // Stamp the two panes + divider inside the popover (idempotent).
    if (!this.#popoverRef.querySelector(':scope > [data-cal-pane]')) {
      const cal = this.constructor._pp.calPane.cloneNode(true);
      this.#popoverRef.appendChild(cal);
    }
    if (!this.#popoverRef.querySelector(':scope > [data-pane-divider]')) {
      const div = this.constructor._pp.divider.cloneNode(true);
      this.#popoverRef.appendChild(div);
    }
    if (!this.#popoverRef.querySelector(':scope > [data-time-pane]')) {
      const time = this.constructor._pp.timePane.cloneNode(true);
      this.#popoverRef.appendChild(time);
    }
    this.#calPaneRef = this.#popoverRef.querySelector(':scope > [data-cal-pane]');
    this.#dividerRef = this.#popoverRef.querySelector(':scope > [data-pane-divider]');
    this.#timePaneRef = this.#popoverRef.querySelector(':scope > [data-time-pane]');

    // Footer slot — consumer-supplied; lift into popover after the panes.
    const authorFooter = this.querySelector(':scope > [slot="footer"]');
    if (authorFooter && authorFooter.parentElement !== this.#popoverRef) {
      this.#popoverRef.appendChild(authorFooter);
    }
  }

  render() {
    if (!this.#triggerRef) return;
    // Re-stamp parts if they were wiped by a consumer innerHTML write
    // between the last connect and this render (FEEDBACK-31 invariant).
    if (!this.#calPaneRef || !this.#timePaneRef || !this.#calPaneRef.isConnected) {
      this.#ensureParts();
    }

    // Trigger reflection — text, disabled, ARIA expansion.
    this.#triggerRef.setAttribute('text', this.#displayText());
    // Mark a committed value so the trigger brightens to --a-ui-text (the
    // empty placeholder stays muted) — mirrors date-range-picker.
    this.#triggerRef.toggleAttribute('data-has-value', this.#displayText() !== 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 value + constraints into the two panes.
    const parsed = parseDatetime(this.value);
    const pending = this.#pending;
    const datePart = pending?.date ?? parsed?.date ?? '';
    const timePart = pending?.time ?? parsed?.time ?? '';

    if (this.#calPaneRef) {
      // Reflect min/max date portions onto the calendar pane.
      const minDate = this.min ? this.min.slice(0, 10) : '';
      const maxDate = this.max ? this.max.slice(0, 10) : '';
      if (minDate) this.#calPaneRef.setAttribute('min', minDate);
      else this.#calPaneRef.removeAttribute('min');
      if (maxDate) this.#calPaneRef.setAttribute('max', maxDate);
      else this.#calPaneRef.removeAttribute('max');
      if (this.#calPaneRef.value !== datePart) {
        this.#calPaneRef.value = datePart;
      }
      this.#calPaneRef.toggleAttribute('disabled', !!this.disabled);
      this.#calPaneRef.toggleAttribute('readonly', !!this.readonly);
    }

    if (this.#timePaneRef) {
      this.#timePaneRef.setAttribute('precision', this.precision);
      this.#timePaneRef.setAttribute('step', String(this.step));
      if (this.hourcycle) this.#timePaneRef.setAttribute('hour-cycle', this.hourcycle);
      else this.#timePaneRef.removeAttribute('hour-cycle');
      if (this.locale) this.#timePaneRef.setAttribute('locale', this.locale);
      else this.#timePaneRef.removeAttribute('locale');
      // Min/max time portions only apply when date matches the bound's
      // date. For v1 we cascade the time-of-day portion of the bound
      // unconditionally; the host-level invalid check rejects out-of-
      // range commits, so the time pane bound is an advisory affordance.
      const minTime = this.min ? this.min.slice(11) : '';
      const maxTime = this.max ? this.max.slice(11) : '';
      if (minTime && datePart === this.min.slice(0, 10)) this.#timePaneRef.setAttribute('min', minTime);
      else this.#timePaneRef.removeAttribute('min');
      if (maxTime && datePart === this.max.slice(0, 10)) this.#timePaneRef.setAttribute('max', maxTime);
      else this.#timePaneRef.removeAttribute('max');
      if (this.#timePaneRef.value !== timePart) {
        this.#timePaneRef.value = timePart;
      }
      this.#timePaneRef.toggleAttribute('disabled', !!this.disabled);
      this.#timePaneRef.toggleAttribute('readonly', !!this.readonly);
    }

    // Toggle popover via the Popover API. Mirrors date-range-picker.
    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).
          this.#anchorCleanup?.();
          this.#anchorCleanup = anchorPopover(this.#triggerRef, this.#popoverRef, {
            placement: this.getAttribute('placement') || 'bottom', // ADR-0034 Rule 2: panel >> trigger
            gap: 4,
          });
          document.addEventListener('pointerdown', this.#onOutside);
          document.addEventListener('keydown', this.#onDocKey);
          queueMicrotask(() => {
            if (!this.open) return;
            // Focus the calendar grid first (date-pending state).
            const grid = this.#calPaneRef?.querySelector?.('[data-cal-day]:not([disabled]):not([data-outside])');
            grid?.focus?.();
          });
        } else {
          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);
        this.#triggerRef?.focus?.();
      }
    }
  }

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

  #displayText() {
    const parsed = parseDatetime(this.value);
    if (!parsed) return this.placeholder;
    const dPart = formatDatePart(parsed.date, this.format);
    const tPart = formatTimePart(parsed.time, this.precision, this.hourcycle);
    if (!dPart || !tPart) return this.placeholder;
    return `${dPart} ${tPart}`;
  }

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

  #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;
      }
      this.#keyboardOpen = true;
    }
  };

  #onPopoverClick = (e) => {
    // Footer button handling — Apply / Cancel semantics if the consumer
    // wires data-action attrs onto authored slot="footer" buttons.
    const actionBtn = e.target.closest('[data-action]');
    if (actionBtn && this.contains(actionBtn)) {
      const action = actionBtn.dataset.action;
      if (action === 'apply') {
        this.#commitFromPanes('click');
        this.closePopover('apply');
      } else if (action === 'cancel') {
        // Discard pending; restore from value.
        this.#pending = null;
        this.closePopover('cancel');
      }
    }
  };

  #onPopoverKey = (e) => {
    if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
      this.closePopover('escape');
      return;
    }
    if (e.key === 'Tab') {
      // Focus trap — cycle inside the popover. Trigger is outside the
      // popover; Tab cycles inside until Escape closes.
      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 when focus is outside (e.g.,
    // when the trigger absorbs it after some interaction).
    if (e.key === 'Escape' && this.open) {
      this.closePopover('escape');
    }
  };

  #onCalPaneChange = (e) => {
    if (this.readonly) return;
    e.stopPropagation();
    const iso = e.detail?.value || this.#calPaneRef?.value || '';
    if (!iso) return;
    if (!this.#pending) this.#pending = { date: '', time: '' };
    this.#pending.date = iso;
    // Live emit `input` for partial state.
    const existing = parseDatetime(this.value);
    const timeCandidate = this.#pending.time || existing?.time || '';
    const partial = timeCandidate
      ? formatDatetime(iso, timeCandidate, this.precision)
      : `${iso}T`;
    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { value: partial },
    }));
    if (timeCandidate) {
      this.#commitFromParts({ date: iso, time: timeCandidate });
    } else {
      // Advance focus to the time pane's first segment for the
      // pending-time state (a11y model: date → time → commit).
      queueMicrotask(() => {
        if (!this.open) return;
        const hourSeg = this.#timePaneRef?.querySelector?.('[data-segment="hour"]');
        hourSeg?.focus?.();
      });
    }
  };

  #onTimePaneChange = (e) => {
    if (this.readonly) return;
    e.stopPropagation();
    const time = e.detail?.value || this.#timePaneRef?.value || '';
    if (!time) return;
    if (!this.#pending) this.#pending = { date: '', time: '' };
    this.#pending.time = time;
    const existing = parseDatetime(this.value);
    const dateCandidate = this.#pending.date || existing?.date || '';
    if (dateCandidate) {
      this.#commitFromParts({ date: dateCandidate, time });
    } else {
      // Time chosen without a date — partial state.
      this.dispatchEvent(new CustomEvent('input', {
        bubbles: true,
        detail: { value: `T${time}` },
      }));
    }
  };

  #onTimePaneInput = (e) => {
    if (this.readonly) return;
    e.stopPropagation();
    // Surface the time-pane's intermediate edits as host `input` events
    // so consumers see a unified stream.
    const time = e.detail?.value || '';
    const existing = parseDatetime(this.value);
    const date = this.#pending?.date || existing?.date || '';
    const partial = (date && time) ? formatDatetime(date, time, this.precision) : (time ? `T${time}` : '');
    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { value: partial },
    }));
  };

  #onPaneInvalid = (e) => {
    e.stopPropagation();
    this.dispatchEvent(new CustomEvent('invalid', {
      bubbles: true,
      detail: { value: this.value, reason: e.detail?.reason || 'pane' },
    }));
  };

  #onOutside = (e) => {
    if (!this.open) return;
    if (this.contains(e.target)) return;
    if (this.#popoverRef && e.composedPath?.().includes(this.#popoverRef)) return;
    this.closePopover('outside');
  };

  // ── Commit ────────────────────────────────────────────────────────

  /**
   * Commit a candidate {date, time} pair as the new value if it passes
   * constraint validation. Mirrors date-range-picker's #commitRange but
   * for a single datetime string.
   */
  #commitFromParts(parts, source = 'click') {
    if (!parts || !parts.date || !parts.time) return;
    const next = formatDatetime(parts.date, parts.time, this.precision);
    if (!next) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: next, reason: 'parse' },
      }));
      return;
    }
    if (this.min && next < this.min) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: next, reason: 'below-min' },
      }));
      return;
    }
    if (this.max && next > this.max) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: next, reason: 'above-max' },
      }));
      return;
    }
    this.value = next;
    this.#pending = null;
    this.syncValue(next);
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: next, source },
    }));
  }

  /**
   * Commit using the panes' current values (Apply path).
   */
  #commitFromPanes(source = 'click') {
    const date = this.#calPaneRef?.value || '';
    const time = this.#timePaneRef?.value || '';
    if (!date || !time) return;
    this.#commitFromParts({ date, time }, source);
  }

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