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

/**
 * <time-picker-ui> — Standalone time-of-day picker.
 *
 * Per-segment Spinbutton model (WAI-APG): hour : minute : (second?) :
 * (meridiem?). Each segment is a contenteditable span with role=spinbutton,
 * aria-valuemin / -valuemax / -valuenow / -valuetext, and Arrow Up/Down
 * to step. Form-associated via UIFormElement + ElementInternals; the form
 * value is ISO 8601 time string ("HH:mm" or "HH:mm:ss") under [name].
 *
 *   <time-picker-ui name="start-time" value="09:30"
 *                   min="08:00" max="18:00" step="900"></time-picker-ui>
 *
 *   <time-picker-ui name="cursor" precision="second" step="1"
 *                   value="14:30:45"></time-picker-ui>
 *
 *   <time-picker-ui name="alarm" hour-cycle="h12" value="07:00"></time-picker-ui>
 *
 * SPEC-043. Per ADR-0025 no native form controls — never wraps
 * <input type="time">.
 */

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

const HOUR_MAX_24 = 23;
const HOUR_MAX_12 = 12;
const HOUR_MIN_12 = 1;
const MINUTE_MAX = 59;
const SECOND_MAX = 59;

const SEG_ORDER_BASE = ['hour', 'minute'];

/** Parse "HH:mm" or "HH:mm:ss" → {h, m, s} (or null if unparseable). */
function parseISOTime(str) {
  if (!str) return null;
  const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(str).trim());
  if (!m) return null;
  const h = Number(m[1]);
  const min = Number(m[2]);
  const s = m[3] != null ? Number(m[3]) : 0;
  if (!Number.isFinite(h) || h < 0 || h > 23) return null;
  if (!Number.isFinite(min) || min < 0 || min > 59) return null;
  if (!Number.isFinite(s) || s < 0 || s > 59) return null;
  return { h, m: min, s };
}

/** Format {h, m, s} as ISO 8601. `precision="second"` includes seconds. */
function formatISOTime({ h, m, s }, precision) {
  const pad = (n) => String(n).padStart(2, '0');
  return precision === 'second'
    ? `${pad(h)}:${pad(m)}:${pad(s)}`
    : `${pad(h)}:${pad(m)}`;
}

/** Total seconds since 00:00 for comparison against min/max. */
function toSeconds({ h, m, s }) { return h * 3600 + m * 60 + s; }

/** Derive hour-cycle from BCP-47 locale. Returns 'h12' or 'h23'. */
function deriveHourCycle(localeTag) {
  try {
    const tag = localeTag || document?.documentElement?.lang || undefined;
    const f = new Intl.DateTimeFormat(tag, { hour: 'numeric' });
    const opts = f.resolvedOptions();
    // resolvedOptions().hourCycle is one of h11/h12/h23/h24; collapse to
    // h12 vs h23 (we ignore the midnight-as-12 vs 0 distinction inside
    // each family).
    const c = opts.hourCycle || (opts.hour12 ? 'h12' : 'h23');
    return c === 'h11' || c === 'h12' ? 'h12' : 'h23';
  } catch { return 'h23'; }
}

export class UITimePicker extends UIFormElement {
  // Per-segment role announces independently; no inert above-the-field
  // label rendering — group `aria-label` instead. Field-ui wrap is the
  // canonical labelling path.
  static labelDeprecated = false;

  static properties = {
    ...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' },
    placeholder:{ type: String,  default: '--',     reflect: false },
    locale:     { type: String,  default: '',       reflect: false },
  };

  static template = () => null;

  // Stamped segment refs (re-bound after each (re)stamp)
  #segs = null;
  // The keys of #segs in DOM-order (e.g. ['hour','minute','second','meridiem'])
  #segOrder = [];
  // Last stamped shape signature so we know whether to re-stamp on attr change.
  #lastShape = '';
  // Currently focused segment key (or null).
  #focusedKey = null;

  // Effective hour cycle ('h12' or 'h23') resolved from props/locale.
  get #cycle() {
    if (this.hourcycle === 'h12' || this.hourcycle === 'h23') return this.hourcycle;
    return deriveHourCycle(this.locale);
  }

  get #showsMeridiem() { return this.#cycle === 'h12'; }
  get #showsSeconds()  { return this.precision === 'second'; }

  // Shape signature drives re-stamp decisions. Change → re-stamp segments.
  get #shape() {
    return `${this.#cycle}|${this.precision}`;
  }

  // ── Lifecycle ──

  connected() {
    super.connected();
    // Host is the group; first segment is the keyboard entry point.
    this.setAttribute('role', 'group');
    if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
      // Provide a sensible default label so SRs don't read just "group".
      this.setAttribute('aria-label', 'Time');
    }
    if (this.required) this.setAttribute('aria-required', 'true');

    this.#stampSegments();

    this.addEventListener('keydown', this.#onKeydown);
    this.addEventListener('focusin', this.#onFocusIn);
    this.addEventListener('focusout', this.#onFocusOut);
  }

  render() {
    // Re-stamp if precision / hour-cycle changed (segment set differs).
    if (this.#lastShape && this.#lastShape !== this.#shape) {
      this.#stampSegments();
    }
    // Sync segment text + ARIA from current value.
    this.#syncSegmentsFromValue();
    // Disabled / readonly state propagation onto segments.
    this.#applyEditableState();
  }

  disconnected() {
    super.disconnected();
    this.removeEventListener('keydown', this.#onKeydown);
    this.removeEventListener('focusin', this.#onFocusIn);
    this.removeEventListener('focusout', this.#onFocusOut);
    this.#segs = null;
    this.#segOrder = [];
    this.#focusedKey = null;
  }

  // ── Public methods ──

  clear() {
    this.value = '';
    this.syncValue('');
    this.#syncSegmentsFromValue();
  }

  /**
   * Increment a segment programmatically.
   * @param {'hour'|'minute'|'second'|'meridiem'} segment
   * @param {number} [n] step count (default 1)
   */
  stepUp(segment, n = 1) {
    this.#stepSegment(segment, +1 * (n | 0 || 1));
  }

  stepDown(segment, n = 1) {
    this.#stepSegment(segment, -1 * (n | 0 || 1));
  }

  // ── Stamping ──

  #stampSegments() {
    const cycle = this.#cycle;
    const showSeconds = this.#showsSeconds;
    const showMeridiem = this.#showsMeridiem;

    // Build the segment order for this shape.
    const order = [...SEG_ORDER_BASE];
    if (showSeconds) order.push('second');
    if (showMeridiem) order.push('meridiem');
    this.#segOrder = order;

    // Compose markup. We rebuild the inner shell on a shape change; prefix
    // / suffix slotted content survives because consumers place it OUTSIDE
    // any [slot="field"] container — at the host level — and we move it
    // back on re-stamp. (Simpler than diffing.)
    const prefix = this.querySelector(':scope > [slot="prefix"]');
    const suffix = this.querySelector(':scope > [slot="suffix"]');

    const segHTML = (key, label, max) =>
      `<span data-segment="${key}" role="spinbutton" tabindex="0"
             aria-label="${label}" aria-valuemin="0" aria-valuemax="${max}"
             aria-valuenow=""></span>`;

    const sep = `<span data-separator aria-hidden="true">:</span>`;

    let inner = '';
    inner += segHTML('hour', 'Hours', showMeridiem ? HOUR_MAX_12 : HOUR_MAX_24);
    inner += sep;
    inner += segHTML('minute', 'Minutes', MINUTE_MAX);
    if (showSeconds) {
      inner += sep;
      inner += segHTML('second', 'Seconds', SECOND_MAX);
    }
    if (showMeridiem) {
      // Meridiem is a toggle (AM/PM); reflect via aria-valuetext only.
      inner += `<span data-segment="meridiem" role="spinbutton" tabindex="0"
                       aria-label="AM/PM" aria-valuemin="0" aria-valuemax="1"
                       aria-valuenow="" data-meridiem></span>`;
    }

    // Replace host content; re-attach prefix/suffix.
    this.innerHTML = inner;
    if (prefix) this.insertBefore(prefix, this.firstChild);
    if (suffix) this.appendChild(suffix);

    // Re-bind refs.
    this.#segs = {};
    for (const key of order) {
      const el = this.querySelector(`[data-segment="${key}"]`);
      if (el) this.#segs[key] = el;
    }

    // Bind per-segment input listeners (digit typing path).
    for (const key of order) {
      const seg = this.#segs[key];
      if (!seg) continue;
      seg.contentEditable = 'plaintext-only';
      seg.addEventListener('beforeinput', this.#onSegBeforeInput);
      seg.addEventListener('input', this.#onSegInput);
      seg.addEventListener('blur', this.#onSegBlur);
      seg.addEventListener('focus', this.#onSegFocus);
    }

    this.#lastShape = this.#shape;
  }

  #applyEditableState() {
    if (!this.#segs) return;
    const editable = !(this.disabled || this.readonly);
    for (const key of this.#segOrder) {
      const seg = this.#segs[key];
      if (!seg) continue;
      seg.contentEditable = editable ? 'plaintext-only' : 'false';
    }
  }

  // ── Value ↔ segment sync ──

  #parts() {
    const parsed = parseISOTime(this.value);
    if (parsed) return parsed;
    return null;
  }

  #syncSegmentsFromValue() {
    if (!this.#segs) return;
    const parts = this.#parts();
    const cycle = this.#cycle;

    if (!parts) {
      // Empty value → empty segments + placeholder via :empty CSS.
      for (const key of this.#segOrder) {
        const seg = this.#segs[key];
        if (!seg) continue;
        // Skip the segment the user is mid-typing on.
        if (document.activeElement === seg && seg.textContent !== '') continue;
        seg.textContent = '';
        seg.setAttribute('aria-valuenow', '');
        seg.setAttribute('aria-valuetext', this.placeholder);
      }
      return;
    }

    // Compute display hour for the chosen cycle.
    const dispHour = this.#showsMeridiem
      ? ((parts.h % 12) || 12)
      : parts.h;

    const display = {
      hour: pad2(dispHour),
      minute: pad2(parts.m),
      second: pad2(parts.s),
      meridiem: cycle === 'h12' ? (parts.h >= 12 ? 'PM' : 'AM') : '',
    };

    const ariaNow = {
      hour: String(dispHour),
      minute: String(parts.m),
      second: String(parts.s),
      meridiem: cycle === 'h12' ? (parts.h >= 12 ? '1' : '0') : '',
    };

    const ariaText = {
      hour: `${dispHour} hour${dispHour === 1 ? '' : 's'}`,
      minute: `${parts.m} minute${parts.m === 1 ? '' : 's'}`,
      second: `${parts.s} second${parts.s === 1 ? '' : 's'}`,
      meridiem: cycle === 'h12' ? (parts.h >= 12 ? 'PM' : 'AM') : '',
    };

    for (const key of this.#segOrder) {
      const seg = this.#segs[key];
      if (!seg) continue;
      // Don't clobber a segment the user is currently typing in.
      if (document.activeElement === seg) continue;
      seg.textContent = display[key];
      seg.setAttribute('aria-valuenow', ariaNow[key]);
      seg.setAttribute('aria-valuetext', ariaText[key]);
    }
  }

  /** Read all segments → re-emit as ISO 8601 value, validate, commit. */
  #commitFromSegments(triggerSegment) {
    if (!this.#segs) return;
    const cycle = this.#cycle;

    // Read raw text from each segment; missing → bail with empty.
    const raw = {};
    for (const key of this.#segOrder) {
      raw[key] = (this.#segs[key]?.textContent || '').trim();
    }
    // Hours / minutes / seconds need numeric content; meridiem is AM/PM.
    if (!raw.hour && !raw.minute) {
      // All empty — store empty value.
      this.value = '';
      this.syncValue('');
      this.dispatchEvent(new CustomEvent('change', {
        bubbles: true,
        detail: { value: '', segment: triggerSegment || null },
      }));
      return;
    }

    let h = clampInt(raw.hour, 0, cycle === 'h12' ? HOUR_MAX_12 : HOUR_MAX_24);
    let m = clampInt(raw.minute, 0, MINUTE_MAX);
    let s = this.#showsSeconds ? clampInt(raw.second, 0, SECOND_MAX) : 0;

    if (h == null || m == null || s == null) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: this.value, reason: 'parse' },
      }));
      return;
    }

    if (cycle === 'h12') {
      // Map 12h display → 24h storage.
      const ampm = String(raw.meridiem || '').trim().toUpperCase();
      const isPM = ampm === 'PM';
      if (h === 12) h = isPM ? 12 : 0;
      else if (isPM) h += 12;
    }

    const parts = { h, m, s };
    const isoSeconds = toSeconds(parts);
    const minP = parseISOTime(this.min);
    const maxP = parseISOTime(this.max);
    if (minP && isoSeconds < toSeconds(minP)) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: this.value, reason: 'min' },
      }));
      return;
    }
    if (maxP && isoSeconds > toSeconds(maxP)) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: this.value, reason: 'max' },
      }));
      return;
    }

    const next = formatISOTime(parts, this.precision);
    if (next === this.value) {
      // Still sync display in case raw was unpadded (e.g. "9" → "09").
      this.#syncSegmentsFromValue();
      return;
    }
    this.value = next;
    this.syncValue(next);
    this.#syncSegmentsFromValue();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: next, segment: triggerSegment || null },
    }));
  }

  // ── Per-segment Arrow / Page / Home / End ──

  #stepSegment(key, direction) {
    if (this.disabled || this.readonly) return;
    if (!this.#segs?.[key]) return;
    const cycle = this.#cycle;
    const stepSecs = Math.max(1, this.step | 0);

    // Default to "now" when value is empty so steps start from a sane base.
    let parts = this.#parts();
    if (!parts) {
      parts = { h: 0, m: 0, s: 0 };
    }

    if (key === 'hour') {
      parts.h = wrap(parts.h + direction, 0, 24);
    } else if (key === 'minute') {
      const delta = Math.max(1, Math.floor(stepSecs / 60)) * direction;
      parts.m = parts.m + delta;
      while (parts.m < 0)  { parts.m += 60; parts.h = wrap(parts.h - 1, 0, 24); }
      while (parts.m > 59) { parts.m -= 60; parts.h = wrap(parts.h + 1, 0, 24); }
    } else if (key === 'second') {
      const delta = Math.max(1, stepSecs) * direction;
      parts.s = parts.s + delta;
      while (parts.s < 0)  { parts.s += 60; parts.m -= 1; }
      while (parts.s > 59) { parts.s -= 60; parts.m += 1; }
      while (parts.m < 0)  { parts.m += 60; parts.h = wrap(parts.h - 1, 0, 24); }
      while (parts.m > 59) { parts.m -= 60; parts.h = wrap(parts.h + 1, 0, 24); }
    } else if (key === 'meridiem') {
      parts.h = wrap(parts.h + 12, 0, 24);
    }

    // Clamp by min/max — refuse the step if it would cross a bound.
    const isoSeconds = toSeconds(parts);
    const minP = parseISOTime(this.min);
    const maxP = parseISOTime(this.max);
    if (minP && isoSeconds < toSeconds(minP)) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: this.value, reason: 'min' },
      }));
      return;
    }
    if (maxP && isoSeconds > toSeconds(maxP)) {
      this.dispatchEvent(new CustomEvent('invalid', {
        bubbles: true,
        detail: { value: this.value, reason: 'max' },
      }));
      return;
    }

    const next = formatISOTime(parts, this.precision);
    this.value = next;
    this.syncValue(next);
    this.#syncSegmentsFromValue();
    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { value: next },
    }));
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: next, segment: key },
    }));
  }

  #jumpSegment(key, where) {
    if (this.disabled || this.readonly) return;
    const cycle = this.#cycle;
    let parts = this.#parts() || { h: 0, m: 0, s: 0 };
    if (key === 'hour') {
      parts.h = where === 'min' ? 0 : 23;
    } else if (key === 'minute') {
      parts.m = where === 'min' ? 0 : 59;
    } else if (key === 'second') {
      parts.s = where === 'min' ? 0 : 59;
    } else if (key === 'meridiem') {
      parts.h = where === 'min' ? (parts.h % 12) : ((parts.h % 12) + 12);
    }
    const next = formatISOTime(parts, this.precision);
    this.value = next;
    this.syncValue(next);
    this.#syncSegmentsFromValue();
    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      detail: { value: next, segment: key },
    }));
  }

  #moveFocus(currentKey, delta) {
    const idx = this.#segOrder.indexOf(currentKey);
    if (idx < 0) return;
    const next = idx + delta;
    if (next < 0 || next >= this.#segOrder.length) return;
    const target = this.#segs?.[this.#segOrder[next]];
    target?.focus();
  }

  // ── Event handlers ──

  #segmentOf(target) {
    if (!(target instanceof Element)) return null;
    const el = target.closest('[data-segment]');
    if (!el || !this.contains(el)) return null;
    return el.getAttribute('data-segment');
  }

  #onKeydown = (e) => {
    if (this.disabled) return;
    const key = this.#segmentOf(e.target);
    if (!key) return;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.#stepSegment(key, +1);
        return;
      case 'ArrowDown':
        e.preventDefault();
        this.#stepSegment(key, -1);
        return;
      case 'PageUp':
        e.preventDefault();
        this.#stepSegment(key, +10);
        return;
      case 'PageDown':
        e.preventDefault();
        this.#stepSegment(key, -10);
        return;
      case 'Home':
        e.preventDefault();
        this.#jumpSegment(key, 'min');
        return;
      case 'End':
        e.preventDefault();
        this.#jumpSegment(key, 'max');
        return;
      case 'ArrowLeft':
        e.preventDefault();
        this.#moveFocus(key, -1);
        return;
      case 'ArrowRight':
        e.preventDefault();
        this.#moveFocus(key, +1);
        return;
      case 'Enter':
        e.preventDefault();
        this.#commitFromSegments(key);
        return;
    }
  };

  #onFocusIn = (e) => {
    const key = this.#segmentOf(e.target);
    if (!key) return;
    this.#focusedKey = key;
    this.setAttribute('editing', '');
    this.setAttribute('segment', key);
  };

  #onFocusOut = (e) => {
    // Defer to next tick — focusout fires before the new focusin lands; if
    // it's another segment we want to stay in editing state. We rely on
    // focusin to overwrite [segment="…"].
    queueMicrotask(() => {
      const active = document.activeElement;
      if (!active || !this.contains(active)) {
        this.removeAttribute('editing');
        this.removeAttribute('segment');
        this.#focusedKey = null;
      }
    });
  };

  // ── Per-segment digit entry ──

  #onSegBeforeInput = (e) => {
    // Allow deletions, formatting, composition — gate insertions.
    const t = e.inputType;
    if (!t || !t.startsWith('insert')) return;
    if (t === 'insertCompositionText') return;
    const data = e.data ?? '';
    if (!data) return;
    const key = this.#segmentOf(e.target);
    if (key === 'meridiem') {
      // Allow only A/P/M letters; cap meridiem segment at 2 chars (AM/PM).
      if (!/^[apmAPM]$/.test(data)) { e.preventDefault(); return; }
      const cur = e.target.textContent || '';
      if (cur.length >= 2) e.preventDefault();
      return;
    }
    if (!/^\d$/.test(data)) { e.preventDefault(); return; }
    const cur = e.target.textContent || '';
    if (cur.length >= 2) e.preventDefault();
  };

  #onSegInput = (e) => {
    const key = this.#segmentOf(e.target);
    if (!key) return;
    const raw = (e.target.textContent || '').trim();

    // Emit input as we go (without committing — change fires on blur/Enter).
    this.dispatchEvent(new CustomEvent('input', {
      bubbles: true,
      detail: { value: this.value },
    }));

    // Auto-advance: when the segment is "full" (e.g. typing "14" on a 23-max
    // hour segment when the value can't take any more digits), jump to the
    // next segment.
    if (key === 'meridiem') {
      const upper = raw.toUpperCase();
      if (upper === 'AM' || upper === 'PM') {
        e.target.textContent = upper;
        // After meridiem set, commit.
        this.#commitFromSegments(key);
      }
      return;
    }

    if (raw.length < 2) {
      // First digit — auto-advance if a second digit can't possibly fit.
      const max = parseInt(e.target.getAttribute('aria-valuemax') || '0', 10);
      const n = parseInt(raw, 10);
      if (!Number.isNaN(n)) {
        // If adding a second digit would exceed max no matter what, advance.
        // For hour h23: max 23 — first digit 3..9 forces single-digit.
        // For minute: max 59 — first digit 6..9 forces single-digit.
        if (n * 10 > max) {
          // Single-digit value can't be extended; commit now + advance.
          this.#commitFromSegments(key);
          this.#moveFocus(key, +1);
        }
      }
    } else {
      // Two digits — segment full; commit + advance.
      this.#commitFromSegments(key);
      this.#moveFocus(key, +1);
    }
  };

  #onSegBlur = (e) => {
    const key = this.#segmentOf(e.target);
    if (!key) return;
    // Commit on blur so manually-typed values land in `value`.
    this.#commitFromSegments(key);
  };

  #onSegFocus = (e) => {
    // Select-all so the next digit overwrites cleanly.
    const target = e.target;
    if (!(target instanceof HTMLElement)) return;
    requestAnimationFrame(() => {
      if (document.activeElement !== target) return;
      const sel = window.getSelection?.();
      if (!sel) return;
      const range = document.createRange();
      range.selectNodeContents(target);
      sel.removeAllRanges();
      sel.addRange(range);
    });
  };

  // ── Form lifecycle ──

  onFormReset() {
    this.value = '';
    this.#syncSegmentsFromValue();
  }
}

// ── Helpers ──

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

function clampInt(str, min, max) {
  if (str === '' || str == null) return null;
  const n = Number(str);
  if (!Number.isFinite(n) || !Number.isInteger(n)) return null;
  if (n < min || n > max) return null;
  return n;
}

function wrap(n, min, modulus) {
  // wraparound for hours (0..23). modulus = 24.
  let r = n - min;
  const span = modulus - min;
  r = ((r % span) + span) % span;
  return r + min;
}
