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

/**
 * <feed-ui> + <feed-item-ui> — Shared top-layer feed channel.
 *
 * Per docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001).
 *
 * Phase 1 (skeleton) ships here:
 *   - Per-position singletons (`<feed-ui position="bottom-right">`)
 *     resolving OD-FEED-1; mounted lazily into document.body via
 *     Popover API for top-layer placement.
 *   - Auto-fade `<feed-item-ui>` policy. Sticky-dismissible and
 *     action-required policies are stubs that respect the contract
 *     (no auto-dismiss when `duration` is null/0; close button
 *     surfaces when `dismissible` is set) but the action-required
 *     focus-trap is deferred.
 *   - Static API:  UIFeed.post() / .get() / .clear() / .purge()
 *     — `purge()` directly addresses the audit's L-B4 container-leak
 *     finding by giving consumers a tear-down hook.
 *   - Global event channel:  window.dispatchEvent(new CustomEvent(
 *       'feed', { detail: { text, position, ... } })) — same shape as
 *     UIFeed.post() so any code can post without importing the
 *     module directly. Idempotent listener install (HMR-safe).
 *
 * Toast migration (spec § 2.5) is deferred — toast-ui keeps its own
 * container + API for now; the migration happens once the feed-ui
 * surface has soaked.
 */

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

/* ── Container — one per position, mounted into document.body ── */

export class UIFeedContainer extends UIElement {
  static properties = {
    position: { type: String, default: 'bottom-right', reflect: true },
    max:      { type: Number, default: 5,              reflect: true },
  };
  static template = () => html``;

  connected() {
    if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
    if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', 'Feed');
  }
}

export class UIFeedItem extends UIElement {
  #timer = null;
  #removing = false;
  #closeTimer = null;
  #openRaf = null;

  static properties = {
    text:        { type: String,  default: '',           reflect: true },
    heading:     { type: String,  default: '',           reflect: true },
    icon:        { type: String,  default: '',           reflect: true },
    variant:     { type: String,  default: 'default',    reflect: true },
    duration:    { type: Number,  default: 4000,         reflect: true },
    dismissible: { type: Boolean, default: false,        reflect: true },
    // Phase 2: action-required policy. When set, the item carries an
    // action button that the user MUST click (or dismiss) before the
    // item goes away. Spec §2.3 — action-required role=alertdialog,
    // focus is trapped until choice is made.
    action:      { type: String,  default: '',           reflect: true },
  };

  static parts = {
    body: '<div slot="body"></div>',
  };
  static template = () => html``;

  #onPress = (e) => {
    if (e.target.closest('[data-feed-close]')) { this.dismiss(); return; }
    // Phase 2: action button — fires the action callback (if registered
    // via FeedHandle.update with onAction) then dismisses. The handle's
    // .update({onAction: fn}) populates this.#onAction below.
    if (e.target.closest('[data-feed-action]')) {
      try { this.#onAction?.(); } catch (err) { console.warn('[feed-item] action handler threw:', err); }
      this.dismiss();
    }
  };

  // Phase 2 focus-trap state — only used when the policy is action-required.
  #onAction = null;
  #focusTrapPrev = null;
  #onTrapKeydown = null;

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

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

  render() {
    const body = this.ensure('body');
    body.textContent = '';
    if (this.heading) {
      const h = document.createElement('strong');
      h.textContent = this.heading;
      h.style.display = 'block';
      body.appendChild(h);
    }
    if (this.text) {
      const t = document.createElement('span');
      t.textContent = this.text;
      body.appendChild(t);
    }

    /* Inferred policy roles per spec §2.4. Auto-fade gets `status`;
       sticky danger/warning gets `alert`; action-required (sticky +
       has [action]) gets `alertdialog` and a focus trap. */
    const isSticky = !this.duration || this.duration <= 0;
    const isLoud   = this.variant === 'danger' || this.variant === 'warning';
    const isActionRequired = isSticky && !!this.action;
    let role = 'status';
    if (isActionRequired) role = 'alertdialog';
    else if (isSticky && isLoud) role = 'alert';
    this.setAttribute('role', role);
    this.setAttribute('aria-live', role === 'alert' || role === 'alertdialog' ? 'assertive' : 'polite');
    if (isActionRequired) this.setAttribute('aria-modal', 'false'); // not page-blocking; focus-trap only

    /* Render dismiss button for sticky items (spec §2.2 — default true
       for sticky, false for auto-fade). Action-required items still
       allow Escape to dismiss for safety; the action button is not the
       only escape. */
    const wantsClose = this.dismissible || isSticky;
    let close = this.querySelector(':scope > [data-feed-close]');
    if (wantsClose && !close) {
      close = document.createElement('button-ui');
      close.setAttribute('data-feed-close', '');
      close.setAttribute('icon', 'x');
      close.setAttribute('variant', 'ghost');
      close.setAttribute('size', 'sm');
      close.setAttribute('aria-label', 'Dismiss');
      this.appendChild(close);
    } else if (!wantsClose && close) {
      close.remove();
    }

    /* Phase 2 — render the action button when [action] is set. Sits
       between the body and the close button. */
    let actionBtn = this.querySelector(':scope > [data-feed-action]');
    if (this.action) {
      if (!actionBtn) {
        actionBtn = document.createElement('button-ui');
        actionBtn.setAttribute('data-feed-action', '');
        actionBtn.setAttribute('variant', 'ghost');
        actionBtn.setAttribute('size', 'sm');
        if (close && close.parentElement === this) this.insertBefore(actionBtn, close);
        else                                       this.appendChild(actionBtn);
      }
      actionBtn.setAttribute('text', this.action);
    } else if (actionBtn) {
      actionBtn.remove();
    }

    /* Focus-trap activation for action-required. */
    if (isActionRequired) this.#installFocusTrap();
    else                  this.#uninstallFocusTrap();

    if (!this.hasAttribute('data-open') && !this.#removing) {
      this.#openRaf = requestAnimationFrame(() => {
        this.#openRaf = null;
        this.setAttribute('data-open', '');
      });
    }
    this.#scheduleAutoDismiss();
  }

  #scheduleAutoDismiss() {
    if (this.#timer) clearTimeout(this.#timer);
    if (this.duration && this.duration > 0) {
      this.#timer = setTimeout(() => this.dismiss(), this.duration);
    }
  }

  dismiss() {
    if (this.#removing) return;
    this.#removing = true;
    if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
    this.removeAttribute('data-open');
    this.setAttribute('data-closing', '');
    this.#closeTimer = setTimeout(() => {
      this.#closeTimer = null;
      const container = this.parentElement;
      this.dispatchEvent(new Event('close', { bubbles: true }));
      this.remove();
      if (container?.matches?.('feed-ui')) UIFeed.releaseContainerIfEmpty(container);
    }, this.#getDuration());
  }

  update(patch) {
    if (!patch || typeof patch !== 'object') return;
    for (const k of Object.keys(patch)) {
      if (k in UIFeedItem.properties) this[k] = patch[k];
    }
    // Phase 2: handle is set via the FeedHandle's update(); not a
    // reflected attribute, so it lives outside the property loop.
    if (typeof patch.onAction === 'function') this.#onAction = patch.onAction;
    this.#scheduleAutoDismiss();
  }

  // Phase 2 focus-trap. Installs on action-required items; saves the
  // previously-focused element so we can return focus on dismiss; cycles
  // Tab/Shift-Tab between focusable descendants; Escape dismisses.
  #installFocusTrap() {
    if (this.#onTrapKeydown) return; // already installed
    this.#focusTrapPrev = document.activeElement;
    const focusables = () => [...this.querySelectorAll('button, button-ui, [tabindex]:not([tabindex="-1"]), input, [data-feed-action], [data-feed-close]')]
      .filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
    this.#onTrapKeydown = (e) => {
      if (e.key === 'Escape') { e.stopPropagation(); this.dismiss(); return; }
      if (e.key !== 'Tab') return;
      const els = focusables();
      if (!els.length) return;
      const first = els[0];
      const last  = els[els.length - 1];
      const active = document.activeElement;
      if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
    };
    this.addEventListener('keydown', this.#onTrapKeydown, true);
    // Move focus into the item — prefer the action button.
    queueMicrotask(() => {
      const els = focusables();
      const action = this.querySelector('[data-feed-action]');
      (action || els[0])?.focus?.();
    });
  }

  #uninstallFocusTrap() {
    if (!this.#onTrapKeydown) return;
    this.removeEventListener('keydown', this.#onTrapKeydown, true);
    this.#onTrapKeydown = null;
    // Restore focus to whatever had it before the trap engaged.
    try { this.#focusTrapPrev?.focus?.(); } catch { /* element may have been removed */ }
    this.#focusTrapPrev = null;
  }

  disconnected() {
    this.removeEventListener('press', this.#onPress);
    if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
    if (this.#closeTimer) { clearTimeout(this.#closeTimer); this.#closeTimer = null; }
    if (this.#openRaf != null) { cancelAnimationFrame(this.#openRaf); this.#openRaf = null; }
    this.#uninstallFocusTrap();
  }
}

/* ── Static API — UIFeed ── */

export class UIFeed {
  static #containers = new Map();

  /**
   * Get (or lazily create) the per-position lane. Each lane is a
   * manual Popover-API container — `[popover="manual"]` puts it in
   * the browser's top-layer with no z-index wars and the native
   * popover stack lets multiple lanes coexist (e.g. one feed in
   * top-right + another in bottom-center) without collision.
   */
  static get(position = 'bottom-right') {
    let el = UIFeed.#containers.get(position);
    if (el && el.isConnected) return el;
    el = document.createElement('feed-ui');
    el.setAttribute('position', position);
    if ('popover' in HTMLElement.prototype) el.setAttribute('popover', 'manual');
    document.body.appendChild(el);
    try { el.showPopover?.(); } catch { /* graceful fallback */ }
    UIFeed.#containers.set(position, el);
    return el;
  }

  /**
   * Post a feed item. Returns a `FeedHandle` (`{id, dismiss, update}`).
   *
   * @param {Object} opts
   * @param {string} [opts.text]
   * @param {string} [opts.heading]
   * @param {string} [opts.icon]
   * @param {string} [opts.variant='default']  default | info | success | warning | danger
   * @param {number|null} [opts.duration=4000]  ms; null/0 = sticky (requires close click)
   * @param {string} [opts.position='bottom-right']
   * @param {boolean} [opts.dismissible]  override default (true for sticky, false for auto)
   * @param {string} [opts.id]
   * @param {string} [opts.action]  Phase 2 — action button label. When set
   *                                without an explicit duration, the item is
   *                                sticky (role=alertdialog + focus trap).
   *                                Pass an explicit numeric `duration` to
   *                                build the "undo" pattern: the action
   *                                button shows, but the item auto-dismisses
   *                                after the timeout. Negative-action UX
   *                                (delete/archive) typically uses 6000-8000.
   * @param {function} [opts.onAction]  Phase 2 — callback invoked when the
   *                                action button is pressed. Item dismisses
   *                                after the callback returns.
   */
  static post(opts = {}) {
    const {
      text = '',
      heading = '',
      icon = '',
      variant = 'default',
      duration = 4000,
      position = 'bottom-right',
      dismissible,
      id,
      action = '',
      onAction,
    } = opts;
    const v = variant === 'error' ? 'danger' : variant;  // documented alias
    const container = UIFeed.get(position);
    // Phase 2 max-queue: items above [max] queue with [data-queued] and
    // become visible as visible items dismiss.
    const max = parseInt(container.getAttribute('max') || '5', 10);
    const visibleCount = [...container.children].filter((c) =>
      c.tagName === 'FEED-ITEM-UI' && !c.hasAttribute('data-queued')
    ).length;
    const item = document.createElement('feed-item-ui');
    if (id) item.setAttribute('data-id', id);
    item.text = text;
    item.heading = heading;
    item.icon = icon;
    item.variant = v;
    // Action-required default is sticky; explicit duration honored so callers
    // can build the "undo" pattern (action + timed auto-dismiss). Default
    // (duration unspecified) preserves the original alertdialog sticky behavior.
    const explicitDuration = 'duration' in opts;
    item.duration = action && !explicitDuration ? 0 : duration;
    if (dismissible != null) item.dismissible = !!dismissible;
    if (action) item.action = action;
    if (visibleCount >= max) item.setAttribute('data-queued', '');
    container.appendChild(item);
    if (typeof onAction === 'function') item.update({ onAction });
    return {
      id: id ?? null,
      dismiss: () => item.dismiss(),
      update:  (patch) => item.update(patch),
    };
  }

  /** Clear all items in a single lane. */
  static clear(position = 'bottom-right') {
    const el = UIFeed.#containers.get(position);
    if (!el) return;
    for (const item of [...el.children]) {
      if (item.tagName === 'FEED-ITEM-UI') item.dismiss?.();
    }
  }

  /** Tear down ALL containers. Test cleanup; addresses audit L-B4. */
  static purge() {
    for (const el of UIFeed.#containers.values()) {
      try { el.hidePopover?.(); } catch { /* noop */ }
      el.remove();
    }
    UIFeed.#containers.clear();
  }

  /** Internal: drop a lane when its last item exits. Also rotates the
   *  Phase 2 max-queue: when a visible item leaves, promote the oldest
   *  queued sibling so the visible-count returns to [max]. */
  static releaseContainerIfEmpty(container) {
    if (!container) return;
    // Promote the oldest queued sibling (if any) before deciding whether
    // to release. The queue is FIFO; queued items live at the tail.
    const queued = container.querySelector('feed-item-ui[data-queued]');
    if (queued) queued.removeAttribute('data-queued');
    if (container.children.length > 0) return;
    try { container.hidePopover?.(); } catch { /* noop */ }
    container.remove();
    for (const [pos, el] of UIFeed.#containers) {
      if (el === container) UIFeed.#containers.delete(pos);
    }
  }
}
