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

/**
 * field-ui — Labeled field wrapper.
 *
 *   <field-ui label="Email" hint="We'll never share" required>
 *     <input-ui value="you@example.com" type="email"></input-ui>
 *     <span slot="trailing">Optional</span>
 *     <button-ui slot="action" icon="x" aria-label="Clear"></button-ui>
 *   </field-ui>
 *
 * Wraps a form control with its label + optional hint, error, trailing,
 * and action regions. The host renders a real `<label for="…">` bound
 * to the slotted control's id (auto-minted when missing) so clicking
 * the label focuses the control — an affordance the previous
 * `<input-ui label="…">` pattern lacked.
 *
 * ### Layout — single grid, slot-positioned
 *
 * field-ui is a CSS grid; children are placed by `[slot]` attribute:
 *
 *   slot="label"    → label cell      (mounted by field-ui)
 *   slot="trailing" → row 1, right
 *   (no slot)       → control cell
 *   slot="action"   → adjacent to control
 *   slot="hint"     → message row     (mounted by field-ui)
 *   slot="error"    → message row     (mounted by field-ui)
 *
 * The template adapts (via `:has()`) to which slots are present so empty
 * tracks don't leak column-gap. Inline mode flattens the content slots
 * onto a single row by retuning columns; message stays below.
 *
 * No DOM reparenting; CSS positions children directly. A small
 * MutationObserver watches childList so a swapped-in control gets its
 * `for=` and `aria-describedby` rebound.
 */
import { UIElement } from '../../core/element.js';

export class UIField extends UIElement {
  static properties = {
    label:    { type: String,  default: '',      reflect: true },
    hint:     { type: String,  default: '',      reflect: true },
    required: { type: Boolean, default: false,   reflect: true },
    inline:   { type: Boolean, default: false,   reflect: true },
    align:    { type: String,  default: 'start', reflect: true },
  };

  // The validation message lives on the child control (UIFormElement.error
  // is the single source of truth — validity is a property of the value, not
  // of the layout wrapper). field-ui mirrors the child's error string into
  // its own [data-field-error] slot so the validation message renders below
  // the control. See docs/specs/component-token-contract.md "Toggle state
  // naming" / open decisions resolution log.
  get error() {
    const ctrl = this.#findControl();
    return ctrl?.error ?? '';
  }

  static template = () => null;

  #labelEl = null;
  #labelMark = null;
  #hintEl = null;
  #errorEl = null;
  #mo = null;

  // Label click → focus the first focusable descendant of the slotted
  // control. The native `<label for="x">` affordance focuses the host
  // by id, which for a custom-element control falls through to body
  // (host typically tabindex=-1).
  #onLabelClick = (e) => {
    const ctrl = this.#findControl();
    if (!ctrl) return;
    const focusable = this.#firstFocusable(ctrl);
    if (!focusable) return;
    e.preventDefault();
    focusable.focus();
  };

  connected() {
    this.#ensureLabelElement();
    this.#ensureHintElement();
    this.#ensureErrorElement();
    this.#bindForToControl();
    this.#bindErrorListener();
    this.#labelEl?.addEventListener('click', this.#onLabelClick);
    this.#mo = new MutationObserver(() => {
      this.#bindForToControl();
      this.#bindErrorListener();
      this.#wireAriaDescribedBy();
      this.#syncErrorMessage();
    });
    this.#mo.observe(this, { childList: true });
    this.#syncErrorMessage();
  }

  disconnected() {
    this.#mo?.disconnect();
    this.#mo = null;
    this.#unbindErrorListener();
    this.#labelEl?.removeEventListener('click', this.#onLabelClick);
    this.#labelEl = null;
    this.#labelMark = null;
    this.#hintEl = null;
    this.#errorEl = null;
  }

  render() {
    if (this.#labelEl) {
      this.#labelEl.childNodes[0] && (this.#labelEl.childNodes[0].nodeValue = this.label || '');
    }
    if (this.#labelMark) this.#labelMark.hidden = !this.required;
    if (this.#hintEl) {
      this.#hintEl.textContent = this.hint || '';
      this.#hintEl.hidden = !this.hint || Boolean(this.error);
    }
    this.#syncErrorMessage();
    this.#wireAriaDescribedBy();
  }

  // Mirror child.error → #errorEl. Called on render, MutationObserver
  // changes, and the child's `invalid`/`change`/`input` events.
  #syncErrorMessage() {
    if (!this.#errorEl) return;
    const msg = this.error;
    this.#errorEl.textContent = msg || '';
    this.#errorEl.hidden = !msg;
  }

  #onChildEvent = () => {
    this.#syncErrorMessage();
    this.#wireAriaDescribedBy();
  };

  #boundErrorTarget = null;

  #bindErrorListener() {
    const ctrl = this.#findControl();
    if (ctrl === this.#boundErrorTarget) return;
    this.#unbindErrorListener();
    if (!ctrl) return;
    this.#boundErrorTarget = ctrl;
    ctrl.addEventListener('invalid', this.#onChildEvent, true);
    ctrl.addEventListener('change',  this.#onChildEvent);
    ctrl.addEventListener('input',   this.#onChildEvent);
  }

  #unbindErrorListener() {
    const ctrl = this.#boundErrorTarget;
    if (!ctrl) return;
    ctrl.removeEventListener('invalid', this.#onChildEvent, true);
    ctrl.removeEventListener('change',  this.#onChildEvent);
    ctrl.removeEventListener('input',   this.#onChildEvent);
    this.#boundErrorTarget = null;
  }

  // ── Private ───────────────────────────────────────────────────────

  #ensureLabelElement() {
    this.#labelEl = this.querySelector(':scope > [data-field-label]');
    if (!this.#labelEl) {
      const el = document.createElement('label');
      el.setAttribute('slot', 'label');
      el.setAttribute('data-field-label', '');
      el.appendChild(document.createTextNode(this.label || ''));
      // Prepend so source-order matches visual order (label before
      // control). Not strictly required since CSS uses grid-area, but
      // it keeps DevTools / a11y tree readings sensible.
      this.prepend(el);
      this.#labelEl = el;
    }
    this.#labelMark = this.#labelEl.querySelector('[data-field-required]');
    if (!this.#labelMark) {
      const mark = document.createElement('span');
      mark.setAttribute('data-field-required', '');
      mark.setAttribute('aria-hidden', 'true');
      mark.textContent = '*';
      mark.hidden = true;
      this.#labelEl.appendChild(mark);
      this.#labelMark = mark;
    }
  }

  #ensureHintElement() {
    this.#hintEl = this.querySelector(':scope > [data-field-hint]');
    if (this.#hintEl) return;
    const el = document.createElement('div');
    el.setAttribute('slot', 'hint');
    el.setAttribute('data-field-hint', '');
    el.setAttribute('id', UIField.#nextMsgId('hint'));
    el.hidden = true;
    this.appendChild(el);
    this.#hintEl = el;
  }

  #ensureErrorElement() {
    this.#errorEl = this.querySelector(':scope > [data-field-error]');
    if (this.#errorEl) return;
    const el = document.createElement('div');
    el.setAttribute('slot', 'error');
    el.setAttribute('data-field-error', '');
    el.setAttribute('id', UIField.#nextMsgId('err'));
    el.setAttribute('role', 'alert');
    el.hidden = true;
    this.appendChild(el);
    this.#errorEl = el;
  }

  #bindForToControl() {
    if (!this.#labelEl) return;
    const control = this.#findControl();
    if (!control) {
      this.#labelEl.removeAttribute('for');
      return;
    }
    if (!control.id) control.id = UIField.#nextId();
    this.#labelEl.setAttribute('for', control.id);
  }

  /** Wire `aria-describedby` on the slotted control to the hint/error
   *  ids. Keeps any consumer-set describedby ids; appends ours. */
  #wireAriaDescribedBy() {
    const ctrl = this.#findControl();
    if (!ctrl) return;
    const ours = new Set();
    if (this.#hintEl && !this.#hintEl.hidden) ours.add(this.#hintEl.id);
    if (this.#errorEl && !this.#errorEl.hidden) ours.add(this.#errorEl.id);
    const existing = (ctrl.getAttribute('aria-describedby') || '')
      .split(/\s+/).filter((s) => s && !s.startsWith('field-hint-') && !s.startsWith('field-err-'));
    const merged = [...existing, ...ours].join(' ').trim();
    if (merged) ctrl.setAttribute('aria-describedby', merged);
    else ctrl.removeAttribute('aria-describedby');
  }

  /** The default-slot control — first child without a `slot` attribute. */
  #findControl() {
    // wrapper-trap-ok: field control is always a single literal child (one <input>/<textarea>/<select>); interpolating the control via .map() is not a documented field pattern
    for (const ch of this.children) {
      if (!ch.hasAttribute('slot')) return ch;
    }
    return null;
  }

  /** First focusable descendant (or self) of `root`. Looks for native
   *  `<input>`, `<textarea>`, `<select>`, `<button>`, `[contenteditable]`,
   *  and any `[tabindex]` ≥ 0. Light DOM only. */
  #firstFocusable(root) {
    const SEL = 'input, textarea, select, button, [contenteditable], [tabindex]:not([tabindex="-1"])';
    if (root.matches?.(SEL)) return root;
    return root.querySelector?.(SEL) ?? null;
  }

  static #idSeq = 0;
  static #nextId() { return `field-ctl-${++UIField.#idSeq}`; }
  static #msgSeq = 0;
  static #nextMsgId(kind) { return `field-${kind}-${++UIField.#msgSeq}`; }
}
