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

/**
 * <tree-ui> — Collapsible tree with nested items.
 *
 * Structure:
 *   <tree-ui>
 *     <tree-item-ui text="Page" icon="file">
 *       <tree-item-ui text="Header" icon="layout"></tree-item-ui>
 *       <tree-item-ui text="Hero" icon="image">
 *         <tree-item-ui text="Title" icon="text-aa"></tree-item-ui>
 *         <tree-item-ui text="CTA" icon="cursor-click"></tree-item-ui>
 *       </tree-item-ui>
 *       <tree-item-ui text="Footer" icon="layout"></tree-item-ui>
 *     </tree-item-ui>
 *   </tree-ui>
 *
 * tree-ui is the root container. tree-item-ui handles nesting, collapse, selection.
 *
 * Events:
 *   tree-select — fired on tree-ui when an item is selected (detail: { item, text, value })
 */

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

export class UITree extends UIElement {
  // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
  // consumer markup). Aggregated by installIconLoadersForRegistered()
  // across all defined elements. Audited by check-required-icons.mjs
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
  static requiredIcons = ['caret-right'];

  static template = () => null;

  connected() {
    this.setAttribute('role', 'tree');
    this.addEventListener('click', this.#onClick);
    this.addEventListener('keydown', this.#onKey);
  }

  get selectedItem() {
    return this.querySelector('tree-item-ui[selected]');
  }

  // §-TBD (v0.5.19, FB-46): originatingEvent forwards Mouse/KeyboardEvent
  // modifier state into the dispatched tree-select detail so consumers can
  // implement Ctrl/Cmd+click multi-select cleanly. Programmatic select()
  // calls (no event) surface ctrlKey/metaKey/shiftKey as false.
  select(item, originatingEvent = null) {
    const prev = this.selectedItem;
    if (prev && prev !== item) prev.removeAttribute('selected');
    item.setAttribute('selected', '');
    this.dispatchEvent(new CustomEvent('tree-select', {
      bubbles: true,
      detail: {
        item,
        text: item.text,
        value: item.value,
        ctrlKey:  !!originatingEvent?.ctrlKey,
        metaKey:  !!originatingEvent?.metaKey,
        shiftKey: !!originatingEvent?.shiftKey,
      },
    }));
  }

  // ──────────────────────────────────────────────────────────────────
  // §184 (v0.5.5, FEEDBACK-08 §2): programmatic expand/collapse API.
  // Pre-§184 consumers had to maintain local `_expanded[]` state +
  // manually sync `tree-item.open` on every `tree-select` event. These
  // five methods + the `auto-expand-selected` reflection let consumers
  // declare the open state directly. All methods accept either a
  // tree-item-ui element OR a value string (matched against [value]
  // first, then [text] for ergonomics).
  // ──────────────────────────────────────────────────────────────────

  /**
   * Resolve an `item` argument to a tree-item-ui element, or `null` if
   * no match. Accepts: HTMLElement (used as-is when it matches `tree-item-ui`)
   * OR a string (matched against [value] first, then [text]).
   */
  #resolveItem(arg) {
    if (!arg) return null;
    if (arg instanceof Element) {
      return arg.matches?.('tree-item-ui') ? arg : null;
    }
    if (typeof arg === 'string') {
      const escaped = arg.replace(/"/g, '\\"');
      return (
        this.querySelector(`tree-item-ui[value="${escaped}"]`) ||
        this.querySelector(`tree-item-ui[text="${escaped}"]`) ||
        null
      );
    }
    return null;
  }

  /** Expand the given item (no-op when already open or it has no children). */
  expand(itemOrValue) {
    const item = this.#resolveItem(itemOrValue);
    if (item && item.hasChildren && !item.open) item.open = true;
  }

  /** Collapse the given item (no-op when already collapsed). */
  collapse(itemOrValue) {
    const item = this.#resolveItem(itemOrValue);
    if (item && item.open) item.open = false;
  }

  /** Expand every item with children. */
  expandAll() {
    for (const it of this.querySelectorAll('tree-item-ui')) {
      if (it.hasChildren && !it.open) it.open = true;
    }
  }

  /** Collapse every item. */
  collapseAll() {
    for (const it of this.querySelectorAll('tree-item-ui')) {
      if (it.open) it.open = false;
    }
  }

  /**
   * Expand every ancestor of the given item so it becomes visible, then
   * scroll it into view. Useful for revealing a programmatically-selected
   * item nested inside collapsed parents.
   */
  expandTo(itemOrValue) {
    const item = this.#resolveItem(itemOrValue);
    if (!item) return;
    let parent = item.parentElement?.closest('tree-item-ui');
    while (parent) {
      if (parent.hasChildren && !parent.open) parent.open = true;
      parent = parent.parentElement?.closest('tree-item-ui');
    }
    item.querySelector(':scope > [slot="row"]')?.scrollIntoView?.({
      block: 'nearest',
      inline: 'nearest',
    });
  }

  #onClick = (e) => {
    const item = e.target.closest('tree-item-ui');
    if (!item || !this.contains(item)) return;

    // Ignore clicks on action buttons inside the row
    if (e.target.closest('[slot="actions"] *')) return;

    const row = item.querySelector(':scope > [slot="row"]');
    if (!row || !(e.target === row || row.contains(e.target))) return;

    // Single click: select + toggle if has children
    this.select(item, e);
    if (item.hasChildren) {
      item.open = !item.open;
    }
  };

  #onKey = (e) => {
    const item = e.target.closest('tree-item-ui');
    if (!item) return;

    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        if (item.hasChildren && !item.open) {
          item.open = true;
        } else {
          const first = item.querySelector(':scope > tree-item-ui');
          if (first) first.querySelector('[slot="row"]')?.focus();
        }
        break;

      case 'ArrowLeft':
        e.preventDefault();
        if (item.hasChildren && item.open) {
          item.open = false;
        } else {
          const parent = item.parentElement?.closest('tree-item-ui');
          if (parent) parent.querySelector(':scope > [slot="row"]')?.focus();
        }
        break;

      case 'ArrowDown': {
        e.preventDefault();
        const next = this.#nextVisible(item);
        if (next) next.querySelector(':scope > [slot="row"]')?.focus();
        break;
      }

      case 'ArrowUp': {
        e.preventDefault();
        const prev = this.#prevVisible(item);
        if (prev) prev.querySelector(':scope > [slot="row"]')?.focus();
        break;
      }

      case 'Enter':
      case ' ':
        e.preventDefault();
        this.select(item, e);
        break;
    }
  };

  #nextVisible(item) {
    // If has visible children, go into first child
    if (item.open && item.hasChildren) {
      return item.querySelector(':scope > tree-item-ui');
    }
    // Otherwise find next sibling or parent's next sibling
    let current = item;
    while (current) {
      const next = current.nextElementSibling;
      if (next && next.matches('tree-item-ui')) return next;
      current = current.parentElement?.closest('tree-item-ui');
    }
    return null;
  }

  #prevVisible(item) {
    const prev = item.previousElementSibling;
    if (prev && prev.matches('tree-item-ui')) {
      // Go to last visible descendant of previous sibling
      let last = prev;
      while (last.open && last.hasChildren) {
        const children = last.querySelectorAll(':scope > tree-item-ui');
        last = children[children.length - 1];
      }
      return last;
    }
    // Go to parent
    const parent = item.parentElement?.closest('tree-item-ui');
    return parent || null;
  }

  disconnected() {
    this.removeEventListener('click', this.#onClick);
    this.removeEventListener('keydown', this.#onKey);
  }
}

export class UITreeItem extends UIElement {
  static properties = {
    text:     { type: String,  default: '', reflect: true },
    icon:     { type: String,  default: '', reflect: true },
    value:    { type: String,  default: '', reflect: true },
    open:     { type: Boolean, default: false, reflect: true },
    selected: { type: Boolean, default: false, reflect: true },
    // §184 (v0.5.5, FEEDBACK-08 §1): optional trailing badge for counts
    // / labels (e.g. "Colors (7)" → text="Colors" badge="7"). Mirrors
    // nav-item-ui badge API; styled muted-and-small via tree.css.
    badge:    { type: String,  default: '', reflect: true },
  };

  static template = () => null;

  // FB-96: late-adoption of interpolated [slot="actions"]/[slot="caret"]
  // children — see connected() + #adoptSlotted().
  #slotObserver = null;
  #adopting = false;

  get hasChildren() {
    return this.querySelector(':scope > tree-item-ui') !== null;
  }

  get depth() {
    let d = 0;
    let el = this.parentElement;
    while (el) {
      if (el.matches('tree-item-ui')) d++;
      el = el.parentElement;
    }
    return d;
  }

  connected() {
    // The host is the structural GROUP container; the focusable [slot="row"]
    // is the treeitem (FEEDBACK-91). The WAI-ARIA tree pattern requires every
    // treeitem to be contained by a `group` or `tree`. Roling the host as
    // `group` — rather than wrapping nested children in a new element — keeps
    // the light-DOM structure intact, so it works for BOTH static markup AND
    // template-engine `.map()` children (whose display:contents wrappers must
    // not be re-homed) and sidesteps the parent-before-child upgrade race.
    // axe-core: `tree` accepts `group` as a required child, `group` has no
    // required parent, and the row's required `group` parent is the host.
    this.setAttribute('role', 'group');

    // [slot="row"] is a SINGLE per-item container — supplied statically or
    // component-stamped, never a `.map()` collection. A full wrapper-pierce would
    // have to migrate all 9 focus / keyboard-nav row reads in lockstep
    // (`item.querySelector(':scope > [slot="row"]')`), or nav breaks for a wrapped
    // row — high regression risk for a rare interpolated-single-row case (the
    // common path, actions / caret, is already FB-96-handled below).
    // wrapper-trap-ok: row is static / stamped, not an interpolated collection.
    if (!this.querySelector(':scope > [slot="row"]')) {
      this.#stamp();
    }

    // Mark the row as the treeitem (whether we stamped it or the consumer
    // supplied a declarative [slot="row"]) and ensure it stays focusable.
    const row = this.querySelector(':scope > [slot="row"]');
    if (row) {
      row.setAttribute('role', 'treeitem');
      if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '0');
    }

    // FB-96: declarative [slot="actions"]/[slot="caret"] children rendered via
    // the template engine (`.map()`/`repeat()`/conditional) arrive in a LATER
    // reactive tick — AFTER #stamp() — nested in the engine's display:contents
    // wrapper spans. So neither a connect-time read nor a `:scope >` direct-child
    // query sees them, and e.g. a `<button-ui slot="actions">` is left as a bare
    // role=button child of the group/tree (axe `aria-required-children`). Adopt
    // what's present now, then observe for the late arrivals.
    this.#adoptSlotted();
    this.#slotObserver = new MutationObserver(() => this.#adoptSlotted());
    this.#slotObserver.observe(this, { childList: true, subtree: true });
  }

  #stamp() {
    const row = document.createElement('div');
    row.setAttribute('slot', 'row');
    row.setAttribute('tabindex', '0');

    // Caret — adopt a declarative [slot="caret"] child if one is already present
    // (incl. inside the engine's display:contents wrappers, FB-96), else stamp
    // the default, marked so #adoptSlotted() can drop it if a declared caret
    // arrives late.
    const declaredCaret = this.#logicalSlotChildren('caret')[0];
    if (declaredCaret) {
      row.appendChild(declaredCaret);
    } else {
      const caret = document.createElement('icon-ui');
      caret.setAttribute('slot', 'caret');
      caret.setAttribute('name', 'caret-right');
      caret.dataset.treeCaretDefault = '1';
      row.appendChild(caret);
    }

    // Icon
    if (this.icon) {
      const iconEl = document.createElement('icon-ui');
      iconEl.setAttribute('name', this.icon);
      iconEl.setAttribute('slot', 'icon');
      row.appendChild(iconEl);
    }

    // Text
    const textEl = document.createElement('span');
    textEl.setAttribute('slot', 'text');
    textEl.textContent = this.text;
    row.appendChild(textEl);

    // §184: trailing badge (when [badge] is set). Stamped even when
    // empty so render() can populate without re-stamping the DOM.
    const badgeEl = document.createElement('span');
    badgeEl.setAttribute('slot', 'badge');
    if (this.badge) badgeEl.textContent = this.badge;
    row.appendChild(badgeEl);

    // Actions — stamp an empty placeholder; the real declarative [slot="actions"]
    // children (FEEDBACK-89) are moved into the row by #adoptSlotted(), which
    // runs in connected() AND on later mutations, so interpolated buttons that
    // arrive after #stamp (nested in the engine's display:contents wrappers,
    // FB-96) get adopted too. The placeholder is dropped once a real one lands.
    // (#onClick excludes [slot="actions"] * from row selection — adoption-safe.)
    const actions = document.createElement('span');
    actions.setAttribute('slot', 'actions');
    actions.dataset.treeActionsPlaceholder = '1';
    row.appendChild(actions);

    this.prepend(row);
  }

  render() {
    const row = this.querySelector(':scope > [slot="row"]');
    if (!row) return;

    // Update depth-based indent
    row.style.paddingInlineStart = `${this.depth * 16 + 4}px`;

    // Update aria — expanded/selected belong on the treeitem (the row), not
    // the host group (FEEDBACK-91).
    if (this.hasChildren) {
      row.setAttribute('aria-expanded', String(this.open));
    } else {
      row.removeAttribute('aria-expanded');
    }
    if (this.selected) row.setAttribute('aria-selected', 'true');
    else row.removeAttribute('aria-selected');

    // Update text
    const textEl = row.querySelector('[slot="text"]');
    if (textEl && this.text) textEl.textContent = this.text;

    // §184: keep badge slot synced with the [badge] attribute.
    const badgeEl = row.querySelector('[slot="badge"]');
    if (badgeEl) badgeEl.textContent = this.badge || '';
  }

  // ── FB-96: wrapper-piercing, late adoption of slotted row children ──

  /**
   * Collect this item's logical `[slot="<slot>"]` children — direct, or nested
   * inside the template engine's `display:contents` wrapper spans (which carry
   * `role="presentation"` since 0.7.2) — but NOT inside a nested `<tree-item-ui>`
   * (a plain descendant query would wrongly grab a child item's actions/caret).
   */
  #logicalSlotChildren(slot) {
    const out = [];
    const walk = (parent) => {
      for (const ch of parent.children) {
        if (ch.matches('tree-item-ui')) continue;                 // nested-item boundary
        if (ch.getAttribute('slot') === slot) { out.push(ch); continue; }
        // pierce the engine's transparent wrapper spans
        if (ch.matches('[role="presentation"]') || ch.style?.display === 'contents') walk(ch);
      }
    };
    walk(this);
    return out;
  }

  /**
   * Move declarative `[slot="actions"]`/`[slot="caret"]` children into the
   * stamped row. Idempotent + re-entrancy-guarded (it mutates `this`, which
   * re-fires #slotObserver). Drops the auto-stamped placeholder/default once a
   * real child is adopted. (FEEDBACK-89 + FB-96.)
   */
  #adoptSlotted() {
    if (this.#adopting) return;
    const row = this.querySelector(':scope > [slot="row"]');
    if (!row) return;
    this.#adopting = true;
    try {
      const actions = this.#logicalSlotChildren('actions')
        .filter((el) => !el.dataset.treeActionsPlaceholder && el.parentElement !== row);
      if (actions.length) {
        row.querySelector(':scope > [slot="actions"][data-tree-actions-placeholder]')?.remove();
        for (const el of actions) row.appendChild(el);
      }
      const caret = this.#logicalSlotChildren('caret')
        .find((el) => !el.dataset.treeCaretDefault && el.parentElement !== row);
      if (caret) {
        row.querySelector(':scope > [slot="caret"][data-tree-caret-default]')?.remove();
        row.prepend(caret);
      }
    } finally {
      this.#slotObserver?.takeRecords();   // drain self-mutations — no re-entrant pass
      this.#adopting = false;
    }
  }

  disconnected() {
    this.#slotObserver?.disconnect();
    this.#slotObserver = null;
  }
}
