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

/**
 * <alert-ui text="Something happened" variant="info" icon="info-circle"></alert-ui>
 * <alert-ui text="Deleted." variant="danger" closable></alert-ui>
 *
 * Inline alert banner with optional icon and close button.
 *
 * Variants: default, info, success, warning, danger, muted, neutral
 * Slots: leading (icon), content (text), close (dismiss button)
 *
 * Events:
 *   close — fired when the user dismisses the alert
 */

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

// One-time warn cache so the same alias hit doesn't spam the console
// across hundreds of components per render. We use warnings ONLY for
// genuine hallucinations the LLM should learn to stop emitting:
//   - variant="error" (canonical: "danger" — explicit in the enum)
//   - [closeable]      (canonical: "closable" — established spelling)
// First-class props ([title], [description], [dismissible]) do NOT warn —
// they're public, supported, documented in alert.yaml.
const _aliasWarned = new Set();
function _warnOnce(key, message) {
  if (_aliasWarned.has(key)) return;
  _aliasWarned.add(key);
  // eslint-disable-next-line no-console
  console.warn(`[alert-ui] ${message}`);
}

export class UIAlert extends UIElement {
  static properties = {
    text:        { type: String,  default: '', reflect: true },
    title:       { type: String,  default: '', reflect: true },
    description: { type: String,  default: '', reflect: true },
    variant:     { type: String,  default: 'default', reflect: true },
    closable:    { type: Boolean, default: false, reflect: true },
    dismissible: { type: Boolean, default: false, reflect: true },
    icon:        { type: String,  default: '', reflect: true },
    /* SPEC-006 dunning pattern \u2014 billing payment-failed mode. When
       `pattern="dunning"`, the alert stamps an Intl-formatted amount +
       due-date + decline-reason from the props below and re-dispatches
       descendant `[data-dunning-action]` button clicks as a unified
       `dunning-action` event. See SPEC-006 for the full rationale. */
    pattern:     { type: String,  default: '', reflect: true },
    amount:      { type: Number,  default: 0,  reflect: true },
    currency:    { type: String,  default: 'USD', reflect: true },
    dueAt:       { type: String,  default: '', reflect: true, attribute: 'due-at' },
    cardLast4:   { type: String,  default: '', reflect: true, attribute: 'card-last4' },
    reason:      { type: String,  default: '', reflect: true },
  };

  static parts = {
    leading: '<icon-ui slot="leading"></icon-ui>',
    content: '<span slot="content"></span>',
    /* Close affordance is a real button-ui \u2014 inherits the system focus
       ring, hover transition, and icon sizing automatically. Listen for
       the canonical `press` event (button-ui dispatches on click + Enter
       + Space, no extra keydown wiring needed). */
    close:   '<button-ui slot="close" icon="x" variant="ghost" size="sm" aria-label="Close"></button-ui>',
  };

  /* SPEC-006 \u2014 decline-reason \u2192 leading icon map. Used when
     `pattern="dunning"` and no explicit [icon] override is set. */
  static #DUNNING_ICONS = {
    declined: 'x-circle',
    expired: 'clock',
    insufficient: 'wallet',
    network: 'wifi-slash',
  };

  static template = () => null;

  /**
   * Normalize alias attrs that the LLM / corpus occasionally emits in
   * non-canonical forms. Runs once at connected() before render(). Two
   * categories:
   *
   *   FIRST-CLASS ALIASES (public, supported, no warn):
   *     - [dismissible] ↔ [closable]  (same semantics; either spelling
   *       maps to the same close-button affordance)
   *
   *   HALLUCINATION ALIASES (warn-once, encourage canonical form):
   *     - variant="error" → variant="danger"  (not in the canonical
   *       enum [default, info, success, warning, danger, muted, neutral])
   *     - [closeable] → [closable]  (alternate spelling, less standard
   *       than dismissible/closable; warn to discourage)
   */
  #normalizeAliases() {
    // variant=error → danger (hallucination; warn)
    if (this.getAttribute('variant') === 'error') {
      _warnOnce('variant-error', 'variant="error" is not in the canonical enum [default, info, success, warning, danger, muted, neutral]. Mapping to "danger". Fix the source (LLM prompt / corpus pattern) to emit "danger" directly.');
      this.setAttribute('variant', 'danger');
    }

    // closeable → closable (typo-class; warn)
    if (this.hasAttribute('closeable') && !this.hasAttribute('closable') && !this.hasAttribute('dismissible')) {
      _warnOnce('alias-closeable', 'attribute [closeable] is a misspelled alias of canonical [closable]. Mapping. Fix the source to use [closable] or [dismissible].');
      this.setAttribute('closable', '');
      this.removeAttribute('closeable');
    }

    // dismissible ↔ closable (first-class alias; no warn)
    if (this.hasAttribute('dismissible') && !this.hasAttribute('closable')) {
      this.setAttribute('closable', '');
    }
  }

  #onPress = (e) => {
    if (e.target.closest('[slot="close"]')) {
      this.#close();
      return;
    }
    // SPEC-006 — dunning action delegation. Re-dispatch as a unified
    // `dunning-action` event so downstream telemetry only listens once.
    if (this.pattern === 'dunning') {
      const trigger = e.target.closest('[data-dunning-action]');
      if (trigger && this.contains(trigger)) {
        const action = trigger.getAttribute('data-dunning-action') || '';
        this.dispatchEvent(new CustomEvent('dunning-action', {
          bubbles: true,
          composed: true,
          detail: {
            action,
            amount: this.amount,
            currency: this.currency,
            dueAt: this.dueAt,
          },
        }));
      }
    }
  };

  connected() {
    this.#normalizeAliases();
    this.#updateRole();

    // Stamp default DOM if nothing was provided
    if (this.icon || (this.pattern === 'dunning' && this.reason)) this.ensure('leading');
    this.ensure('content');
    if (this.closable) this.ensure('close');

    this.addEventListener('press', this.#onPress);
  }

  disconnected() {
    this.removeEventListener('press', this.#onPress);
  }

  render() {
    // SPEC-006 — dunning pattern: branch into a separate render path
    // that stamps Intl-formatted amount + due-date + reason into the
    // content slot. The standard alert render path below is skipped.
    if (this.pattern === 'dunning') {
      this.#renderDunning();
      this.#updateRole();
      return;
    }

    // Icon
    if (this.icon) {
      const leading = this.ensure('leading');
      if (leading) leading.setAttribute('name', this.icon);
    } else {
      this.drop('leading');
    }

    // Content rendering — three modes, in precedence order:
    //   1. Author-provided <span slot="content">…</span> with content
    //      already inside wins (rich content path)
    //   2. [title] and/or [description] — bolded headline + body paragraph
    //   3. [text] — single-line message
    //
    // Detection of "author content" is by checking whether the slot
    // element has its `data-alert-auto` flag (set by us when we stamp
    // content). If the flag is absent AND the element has any content,
    // the author provided it; leave it alone.
    const content = this.ensure('content');
    if (content) {
      const wasAutoStamped = content.hasAttribute('data-alert-auto');
      const hasContent = content.childNodes.length > 0;
      if (!wasAutoStamped && hasContent) {
        // Author-provided rich content. Mirror title/description to
        // aria-label if they were set, but don't touch the markup.
        if (this.title || this.description) {
          const aria = [this.title, this.description].filter(Boolean).join('. ');
          this.setAttribute('aria-label', aria);
        }
      } else if (this.title || this.description) {
        // Mode 2: title + description composed
        content.setAttribute('data-alert-auto', 'title-desc');
        content.replaceChildren();
        if (this.title) {
          const strong = document.createElement('strong');
          strong.textContent = this.title;
          content.appendChild(strong);
          if (this.description) content.appendChild(document.createTextNode(' '));
        }
        if (this.description) {
          content.appendChild(document.createTextNode(this.description));
        }
        const aria = [this.title, this.description].filter(Boolean).join('. ');
        this.setAttribute('aria-label', aria);
      } else if (this.text) {
        // Mode 3: single-line text
        content.setAttribute('data-alert-auto', 'text');
        content.textContent = this.text;
      }
    }

    // Close button
    if (this.closable || this.dismissible) {
      this.ensure('close');
    } else {
      this.drop('close');
    }

    // Role
    this.#updateRole();
  }

  /**
   * SPEC-006 — dunning render path.
   *
   * Stamps the content slot with a bolded title (per-reason copy) +
   * a metadata line ("Card ending 4242 · declined May 20, 2026") +
   * an Intl-formatted amount. The leading icon is selected from
   * `#DUNNING_ICONS` keyed by the [reason] attribute (or [icon] if
   * the consumer explicitly overrode).
   *
   * Re-runs on every render — formatting is stable + idempotent so a
   * re-stamp produces the same DOM. We rebuild the content subtree
   * each render to keep the auto-stamp markers from drifting between
   * the dunning path and the standard path if [pattern] is toggled
   * at runtime.
   */
  #renderDunning() {
    // Leading icon — explicit [icon] beats the reason map
    const iconName = this.icon || UIAlert.#DUNNING_ICONS[this.reason] || 'x-circle';
    const leading = this.ensure('leading');
    if (leading) leading.setAttribute('name', iconName);

    // Content
    const content = this.ensure('content');
    if (!content) return;
    content.setAttribute('data-alert-auto', 'dunning');
    content.replaceChildren();

    // Title line — explicit [title] beats the per-reason default
    const titleText = this.title || this.#defaultDunningTitle();
    if (titleText) {
      const strong = document.createElement('strong');
      strong.setAttribute('data-dunning-title', '');
      strong.textContent = titleText;
      content.appendChild(strong);
    }

    // Body line — formatted amount + metadata. We put amount first
    // (it's the most-scanned datum) followed by the meta string.
    const amountText = this.#formatAmount();
    const metaText = this.#dunningMeta();
    if (amountText || metaText) {
      content.appendChild(document.createTextNode(' '));
      if (amountText) {
        const amt = document.createElement('span');
        amt.setAttribute('data-dunning-amount', '');
        amt.textContent = amountText;
        content.appendChild(amt);
      }
      if (amountText && metaText) {
        content.appendChild(document.createTextNode(' '));
      }
      if (metaText) {
        const meta = document.createElement('span');
        meta.setAttribute('data-dunning-meta', '');
        meta.textContent = metaText;
        content.appendChild(meta);
      }
    }

    // Composed accessible name — title + amount + meta in DOM order
    const aria = [titleText, amountText, metaText].filter(Boolean).join('. ');
    if (aria) this.setAttribute('aria-label', aria);

    // Close button — dismissible-on-dunning is advisory-not-blocked
    // (SPEC-006 OD-002 lean A). Pass through.
    if (this.closable || this.dismissible) {
      this.ensure('close');
    } else {
      this.drop('close');
    }
  }

  #defaultDunningTitle() {
    switch (this.reason) {
      case 'expired': return 'Card expired';
      case 'insufficient': return 'Insufficient funds';
      case 'network': return 'Network error';
      case 'declined': return 'Payment failed';
      default: return this.variant === 'warning' ? 'Payment due soon' : 'Payment failed';
    }
  }

  #formatAmount() {
    if (!this.amount && this.amount !== 0) return '';
    if (this.amount === 0) return '';
    try {
      const lang = this.getAttribute('lang') || this.lang || undefined;
      return new Intl.NumberFormat(lang, {
        style: 'currency',
        currency: this.currency || 'USD',
      }).format(this.amount);
    } catch {
      // Fallback if Intl rejects the currency code
      return `${this.currency || ''} ${this.amount}`.trim();
    }
  }

  #formatDueDate() {
    if (!this.dueAt) return '';
    // A date-only ISO string ("2026-05-20") parses as UTC midnight, which a
    // behind-UTC locale then formats as the PREVIOUS calendar day. Consumers
    // author these as plain calendar dates, so pin them to local midnight to
    // preserve the authored day. Full timestamps (with time / offset) keep
    // their exact instant.
    const d = /^\d{4}-\d{2}-\d{2}$/.test(this.dueAt)
      ? new Date(`${this.dueAt}T00:00:00`)
      : new Date(this.dueAt);
    if (Number.isNaN(d.getTime())) return '';
    try {
      const lang = this.getAttribute('lang') || this.lang || undefined;
      return new Intl.DateTimeFormat(lang, {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      }).format(d);
    } catch {
      return d.toDateString();
    }
  }

  #dunningMeta() {
    const parts = [];
    if (this.cardLast4) parts.push(`Card ending ${this.cardLast4}`);
    if (this.reason && this.reason !== 'declined') {
      parts.push(this.#reasonLabel());
    }
    const date = this.#formatDueDate();
    if (date) {
      const prefix = this.variant === 'warning' ? 'due' : 'failed';
      parts.push(`${prefix} ${date}`);
    }
    return parts.join(' · ');
  }

  #reasonLabel() {
    switch (this.reason) {
      case 'expired': return 'expired';
      case 'insufficient': return 'insufficient funds';
      case 'network': return 'network error';
      case 'declined': return 'declined';
      default: return '';
    }
  }

  #updateRole() {
    const role = (this.variant === 'danger' || this.variant === 'warning') ? 'alert' : 'status';
    this.setAttribute('role', role);
  }

  #close() {
    this.dispatchEvent(new Event('close', { bubbles: true }));
    this.remove();
  }
}
