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

/**
 * <modal-ui> — Centered dialog overlay.
 *
 * Uses native <dialog> for focus trapping, Escape key, and ::backdrop.
 * Content panel scales in/out with CSS transitions.
 * Exit animation plays before dialog.close() via [data-closing].
 *
 * Structure (stamped automatically):
 *   <modal-ui size="md">
 *     <dialog slot="dialog">
 *       <div slot="panel">
 *         <div slot="header">Title × </div>
 *         <div slot="body">...content...</div>
 *         <div slot="footer">...actions...</div>
 *       </div>
 *     </dialog>
 *   </modal-ui>
 *
 * Usage:
 *   <modal-ui text="Confirm" size="md">
 *     <section>Modal content here</section>
 *   </modal-ui>
 *
 *   modal.show();
 *   modal.hide();
 *   modal.open = true;
 *
 * Events:
 *   close — fired after the modal finishes closing
 */

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

export class UIModal extends UIElement {
  #bound = false;
  #closing = false;
  #previousFocus = null;
  #closeTimer = null;
  #dialogRef = null;

  static properties = {
    text:      { type: String,  default: '', 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 — modal composes from authored light-DOM children. An empty
  // html`` result would trigger stamp() → replaceChildren(), wiping authored
  // [slot=body|footer] before render() can migrate them into the panel.
  // (Same rationale as drawer.js — parallel pattern.)

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

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

  #onDialogClose = () => {
    this.open = false;
    this.#previousFocus?.focus();
    this.#previousFocus = null;
    this.dispatchEvent(new Event('close', { bubbles: true }));
  };

  #onDialogClick = (e) => {
    if (e.target === this.#dialogRef && !this.permanent) 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;
    }
    this.#bound = false;
    this.#dialogRef = null;
  }

  #getDuration() {
    // Read the computed `--modal-duration` (= --a-duration = 250ms unless a
    // consumer overrides) so the JS close timer matches the CSS exit animation
    // exactly — a stale/unset value clipped the animation ~50ms early
    // (bug class shared with swiper bug-29).
    const cs = getComputedStyle(this);
    const raw = cs.getPropertyValue('--modal-duration').trim();
    return parseFloat(raw) || 200;
  }

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

    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.ensure('header');
    if (header.parentElement !== panel) panel.prepend(header);
    // Only set `text=` when actually present — the CSS rule
    // `:scope [slot="header"][text]:not([text=""])::before { content: attr(text); flex: 1 }`
    // generates a flex:1 pseudo-element. Setting `text=""` unconditionally
    // creates a phantom flex slot that pushes slotted title content
    // (e.g., confirm-dialog-ui's stamped <text-ui slot="heading">) to the
    // trailing edge of the header row. Defense-in-depth alongside the
    // CSS gate.
    if (this.text) {
      header.setAttribute('text', this.text);
    } else {
      header.removeAttribute('text');
    }

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

    // Body — accept bare `<section>` tags, explicit `[slot="body"]`, OR
    // unslotted light-DOM children (so `<modal-ui><p>…</p></modal-ui>` works
    // with no markup ceremony). Mirrors drawer-ui's authoring contract.
    const authorBody = [...this.children].find(c =>
      c.getAttribute('slot') === 'body' || (!c.getAttribute('slot') && c.localName === 'section')
    );
    const looseChildren = [...this.children].filter(c =>
      !c.getAttribute('slot') && c.localName !== 'section' && c.localName !== 'dialog' && c.localName !== 'footer'
    );

    let body;
    if (authorBody) {
      if (authorBody.getAttribute('slot') !== 'body') authorBody.setAttribute('slot', 'body');
      if (authorBody.parentElement !== panel) panel.appendChild(authorBody);
      panel.querySelector(':scope > [slot="body"][data-stamped]')?.remove();
      body = authorBody;
    } else {
      body = this.ensure('body');
      body.setAttribute('data-stamped', '');
      if (body.parentElement !== panel) panel.appendChild(body);
    }

    // Migrate any remaining unslotted light-DOM children into the body so
    // `<modal-ui><p>…</p></modal-ui>` lands in the visible region.
    for (const child of looseChildren) {
      if (child !== body) body.appendChild(child);
    }

    // Footer — opt-in via [slot="footer"] or 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);
      panel.querySelector(':scope > [slot="footer"][data-stamped]')?.remove();
    } else {
      const footer = this.ensure('footer');
      footer.setAttribute('data-stamped', '');
      if (footer.parentElement !== panel) panel.appendChild(footer);
    }

    // Sync open state. Synchronous reflow instead of rAF — Safari throttles
    // requestAnimationFrame when a top-layer dialog is open, sometimes
    // delaying [data-open] (and the scale-in transition) by tens of seconds.
    // See docs/BROWSER-COMPAT.md §3a (Flavor C).
    if (this.open && !dialog.open) {
      this.#closing = false;
      this.#previousFocus = document.activeElement;
      dialog.showModal();
      void dialog.offsetHeight;
      dialog.setAttribute('data-open', '');
    } else if (!this.open && dialog.open && !this.#closing) {
      this.#animateClose(dialog);
    }
  }

  #animateClose(dialog) {
    this.#closing = true;
    // 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 fade-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; }
}
