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

/**
 * <drawer-ui> — Slide-out panel anchored to a viewport edge.
 *
 * Uses native <dialog> for focus trapping, Escape key, and ::backdrop.
 * Content panel slides in/out with CSS transitions.
 * Exit animation plays before dialog.close() via [data-closing].
 *
 * Authoring — card-ui-style tags (recommended):
 *   <drawer-ui side="right" size="md">
 *     <header>Title block</header>
 *     <section>Body content</section>
 *     <footer>Action buttons</footer>
 *   </drawer-ui>
 *
 * Authoring — explicit slots (also supported):
 *   <drawer-ui>
 *     <header slot="header">…</header>
 *     <section slot="body">…</section>
 *     <footer slot="footer">…</footer>
 *   </drawer-ui>
 *
 * Title-only (stamps an empty header with a close button):
 *   <drawer-ui text="Settings" side="right"></drawer-ui>
 *
 * API:
 *   drawer.show() · drawer.hide() · drawer.open = true|false
 *
 * Events:
 *   close  — fired after the drawer finishes closing
 *   opened — fired after the drawer finishes its open transition
 */

import { UIElement } from '../../core/element.js';

export class UIDrawer extends UIElement {
  #bound = false;
  #closing = false;
  #previousFocus = null;
  #closeTimer = null;
  #openTimer = null;
  #dialogRef = null;
  // §156 (v0.5.3): track the reason the drawer closed so the dispatched
  // `close` CustomEvent carries `detail.reason`. Set at each entry point
  // (escape → 'escape', backdrop → 'backdrop', close-button → 'close-button',
  // any other path defaults to 'programmatic'). Reset to 'programmatic'
  // after dispatch so a subsequent .open=false from consumer code
  // doesn't carry a stale reason. Per FEEDBACK-06 §2.
  #closeReason = 'programmatic';

  static properties = {
    text:      { type: String,  default: '', reflect: true },
    side:      { type: String,  default: 'right', reflect: true },
    size:      { type: String,  default: 'md', reflect: true },
    permanent: { type: Boolean, default: false, reflect: true },
    open:      { type: Boolean, default: false, reflect: true },
  };

  static parts = {
    dialog: '<dialog slot="dialog"></dialog>',
    panel:  '<div slot="panel"></div>',
    header: '<header slot="header"></header>',
    close:  '<button-ui slot="close" icon="x" variant="ghost" size="sm" aria-label="Close"></button-ui>',
    body:   '<section slot="body"></section>',
    footer: '<footer slot="footer"></footer>',
  };

  // No template — drawer composes from authored light-DOM children. An empty
  // html`` result would trigger stamp() → replaceChildren(), wiping authored
  // [slot=header|body|footer] before render() can migrate them into the panel.

  constructor() {
    super();
    // Safari requires <dialog>.showModal() to be invoked synchronously inside
    // the click handler. The reactive system schedules render() in a microtask
    // after the property change, which Safari treats as outside the user-gesture
    // window and silently no-ops the showModal. Wrap the auto-installed `open`
    // setter so dialog state syncs in the same synchronous frame as the
    // assignment. See docs/BROWSER-COMPAT.md §3a (Flavor C).
    const desc = Object.getOwnPropertyDescriptor(this, 'open');
    if (desc?.set) {
      const origSet = desc.set;
      Object.defineProperty(this, 'open', {
        get: desc.get,
        set: (v) => {
          origSet.call(this, v);
          this.#syncDialog();
        },
        configurable: true,
      });
    }
  }

  #onPress = (e) => {
    if (e.target.closest('[slot="close"]')) {
      this.#closeReason = 'close-button';
      this.open = false;
    }
  };

  #onDialogCancel = (e) => {
    e.preventDefault();
    this.#closeReason = 'escape';
    if (!this.permanent) this.open = false;
  };

  #onDialogClose = () => {
    this.open = false;
    this.#previousFocus?.focus();
    this.#previousFocus = null;
    // §156 (v0.5.3): emit a CustomEvent with typed detail.reason. Capture
    // and reset #closeReason so a subsequent programmatic close doesn't
    // inherit the prior reason. Per FEEDBACK-06 §2.
    const reason = this.#closeReason;
    this.#closeReason = 'programmatic';
    this.dispatchEvent(new CustomEvent('close', {
      detail: { reason },
      bubbles: true,
    }));
  };

  #onDialogClick = (e) => {
    if (e.target === this.#dialogRef && !this.permanent) {
      this.#closeReason = 'backdrop';
      this.open = false;
    }
  };

  connected() {
    this.addEventListener('press', this.#onPress);

    // Bind dialog listeners as soon as the dialog part is available.
    const dialog = this.ensure('dialog');
    if (dialog && !this.#bound) {
      this.#bound = true;
      this.#dialogRef = dialog;
      dialog.addEventListener('cancel', this.#onDialogCancel);
      dialog.addEventListener('close', this.#onDialogClose);
      dialog.addEventListener('click', this.#onDialogClick);
    }
  }

  disconnected() {
    this.removeEventListener('press', this.#onPress);
    if (this.#dialogRef) {
      this.#dialogRef.removeEventListener('cancel', this.#onDialogCancel);
      this.#dialogRef.removeEventListener('close', this.#onDialogClose);
      this.#dialogRef.removeEventListener('click', this.#onDialogClick);
    }
    if (this.#closeTimer != null) {
      clearTimeout(this.#closeTimer);
      this.#closeTimer = null;
    }
    if (this.#openTimer != null) {
      clearTimeout(this.#openTimer);
      this.#openTimer = null;
    }
    this.#bound = false;
    this.#dialogRef = null;
  }

  #getDuration() {
    const raw = getComputedStyle(this).getPropertyValue('--drawer-duration').trim();
    return parseFloat(raw) || 200;
  }

  /**
   * Adopt a slotted part into the panel. Accepts either an explicit
   * [slot="X"] element OR a bare tag matching the card-ui convention
   * (<header> → header, <section> → body, <footer> → footer). The tag is only
   * adopted when no slot attribute is set, so authors can still opt into
   * explicit slotting. The slot attribute is normalized onto the adopted
   * element so downstream CSS selectors (`[slot="header"]`) match in both
   * cases.
   */
  #adoptSlot(slot, panel, mode = 'append') {
    const fallbackTag = { header: 'header', body: 'section', footer: 'footer' }[slot];
    const isMatch = (c) => c.getAttribute('slot') === slot
      || (fallbackTag && !c.getAttribute('slot') && c.localName === fallbackTag);

    const inDirect = [...this.children].find(isMatch);
    const inPanel  = [...panel.children].find(c => c.getAttribute('slot') === slot);

    if (inDirect && inPanel && inDirect !== inPanel) {
      // User provided their own after we stamped a default — drop the stamp.
      inPanel.remove();
    }

    const el = inDirect || inPanel || (() => {
      const blueprint = this.constructor._pp?.[slot];
      if (!blueprint) return null;
      const clone = blueprint.cloneNode(true);
      clone.setAttribute('data-stamped', '');
      return clone;
    })();
    if (!el) return null;

    if (el.getAttribute('slot') !== slot) el.setAttribute('slot', slot);

    if (el.parentElement !== panel) {
      if (mode === 'prepend') panel.prepend(el);
      else panel.appendChild(el);
    }
    return el;
  }

  render() {
    const dialog = this.ensure('dialog');

    // §FB-30 (P0): render() is the single source of truth for the dialog
    // binding. When a consumer sets `drawer.innerHTML` on a mounted drawer
    // the internal <dialog> is wiped; ensure('dialog') re-stamps a fresh
    // one, but #dialogRef (set once in connected()) still points at the
    // detached old dialog. Re-bind the cancel/close/click listeners onto
    // the new dialog so #syncDialog() never calls showModal() on a
    // detached node. On first render after mount `dialog` already equals
    // #dialogRef (set in connected()), so the listeners are NOT
    // double-added. Per FEEDBACK-30.
    if (dialog !== this.#dialogRef) {
      if (this.#dialogRef) {
        this.#dialogRef.removeEventListener('cancel', this.#onDialogCancel);
        this.#dialogRef.removeEventListener('close', this.#onDialogClose);
        this.#dialogRef.removeEventListener('click', this.#onDialogClick);
      }
      this.#dialogRef = dialog;
      dialog.addEventListener('cancel', this.#onDialogCancel);
      dialog.addEventListener('close', this.#onDialogClose);
      dialog.addEventListener('click', this.#onDialogClick);
    }

    if (this.text) {
      dialog.setAttribute('aria-label', this.text);
      this.setAttribute('aria-label', this.text);
    } else {
      dialog.removeAttribute('aria-label');
      this.removeAttribute('aria-label');
    }

    const panel = this.ensure('panel');
    if (panel.parentElement !== dialog) dialog.appendChild(panel);

    const header = this.#adoptSlot('header', panel, 'prepend');

    // text="…" shorthand: stamp a [slot="heading"] into the header so the
    // card-ui grid picks it up. Any author-supplied heading wins.
    if (header) {
      let stampedHeading = header.querySelector(':scope > [slot="heading"][data-stamped]');
      const hasAuthorHeading = [...header.children].some(c =>
        !c.hasAttribute('data-stamped') &&
        (c.getAttribute('slot') === 'heading' || /^h[1-6]$/.test(c.localName))
      );
      if (this.text && !hasAuthorHeading) {
        if (!stampedHeading) {
          stampedHeading = document.createElement('span');
          stampedHeading.setAttribute('slot', 'heading');
          stampedHeading.setAttribute('data-stamped', '');
          header.prepend(stampedHeading);
        }
        if (stampedHeading.textContent !== this.text) stampedHeading.textContent = this.text;
      } else if (stampedHeading) {
        stampedHeading.remove();
      }
    }

    if (!this.permanent) {
      const close = this.ensure('close');
      if (header && close.parentElement !== header) header.appendChild(close);
    } else {
      this.drop('close');
    }

    // Body — multiple sections allowed (card-ui parity). Accepts either
    // bare <section> tags or explicit [slot="body"]. If the author provides
    // none, stamp an empty default so title-only drawers still lay out.
    const authorSections = [...this.children].filter(c =>
      c.getAttribute('slot') === 'body'
      || (c.localName === 'section' && !c.getAttribute('slot'))
    );
    if (authorSections.length === 0) {
      this.#adoptSlot('body', panel, 'append');
    } else {
      for (const s of authorSections) {
        if (s.getAttribute('slot') !== 'body') s.setAttribute('slot', 'body');
        if (s.parentElement !== panel) panel.appendChild(s);
      }
    }

    // Footer is opt-in — only mounted if the author supplied one (either
    // [slot="footer"] or a bare <footer> tag, card-ui style).
    const userFooter = [...this.children].find(c =>
      c.getAttribute('slot') === 'footer' || (!c.getAttribute('slot') && c.localName === 'footer')
    );
    if (userFooter) {
      if (userFooter.getAttribute('slot') !== 'footer') userFooter.setAttribute('slot', 'footer');
      if (userFooter.parentElement !== panel) panel.appendChild(userFooter);
    }

    // Sync open state — also syncs synchronously from the `open` setter
    // (see constructor) so showModal() runs in the click handler's gesture
    // frame on Safari.
    this.#syncDialog();
  }

  #syncDialog() {
    const dialog = this.#dialogRef;
    if (!dialog) return;
    // §FB-30 (P0): guard against a detached dialog. If a consumer wiped
    // the host's children via innerHTML between render() passes, #dialogRef
    // could momentarily point at a node outside the document — showModal()
    // on a detached <dialog> throws InvalidStateError. Per FEEDBACK-30.
    if (!dialog.isConnected) return;
    if (this.open && !dialog.open) {
      this.#closing = false;
      this.#previousFocus = document.activeElement;
      // §FB-30 (P0): wrap showModal() so a detached-dialog InvalidStateError
      // surfaces synchronously instead of being swallowed by the effect
      // runner and rethrown as an unhandled microtask rejection. Per
      // FEEDBACK-30.
      try {
        dialog.showModal();
      } catch (e) {
        console.error('[drawer-ui] showModal() failed — dialog may be detached:', e);
      }
      // Synchronous reflow instead of rAF — Safari throttles
      // requestAnimationFrame when a top-layer dialog is open, sometimes
      // delaying [data-open] (and the slide-in transition) by tens of
      // seconds. Forcing a reflow keeps the animation start in the same
      // synchronous frame. See docs/BROWSER-COMPAT.md §3a (Flavor C).
      void dialog.offsetHeight;
      dialog.setAttribute('data-open', '');
      // §FB-27 (P2): emit `opened` after the slide-in transition completes
      // so Playwright (and any consumer waiting for "drawer fully open and
      // interactive") can listen for it instead of guessing with
      // waitForTimeout. Mirrors #closeTimer — cleared in #animateClose and
      // disconnected() so a rapid close→open never fires a stale `opened`.
      // Per FEEDBACK-27.
      if (this.#openTimer != null) clearTimeout(this.#openTimer);
      this.#openTimer = setTimeout(() => {
        this.#openTimer = null;
        this.dispatchEvent(new CustomEvent('opened', { bubbles: true }));
      }, this.#getDuration());
    } else if (!this.open && dialog.open && !this.#closing) {
      this.#animateClose(dialog);
    }
  }

  #animateClose(dialog) {
    this.#closing = true;
    // §FB-27 (P2): a close cancels any pending `opened` — clear the open
    // timer so a rapid open→close never fires a stale `opened` event after
    // the drawer is already sliding out. Per FEEDBACK-27.
    if (this.#openTimer != null) {
      clearTimeout(this.#openTimer);
      this.#openTimer = null;
    }
    // Set [data-closing] FIRST (carries the transition spec), force a
    // reflow, THEN remove [data-open]. If both attribute changes batch
    // into a single style update, Safari can skip the slide-out animation
    // entirely.
    dialog.setAttribute('data-closing', '');
    void dialog.offsetHeight;
    dialog.removeAttribute('data-open');

    this.#closeTimer = setTimeout(() => {
      this.#closeTimer = null;
      this.#closing = false;
      dialog.removeAttribute('data-closing');
      if (dialog.open) dialog.close();
    }, this.#getDuration());
  }

  show() { this.open = true; }
  hide() { this.open = false; }
}
