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

/**
 * <list-window-ui> — Virtualized / windowed list primitive.
 *
 *   <list-window-ui id="chat" item-size="56" overscan="5" pin-bottom
 *                   style="height:480px">
 *     <template>
 *       <row-ui>
 *         <avatar-ui alt="${item.author}" src="${item.avatar}"></avatar-ui>
 *         <col-ui>
 *           <text-ui weight="medium">${item.author}</text-ui>
 *           <text-ui>${item.body}</text-ui>
 *         </col-ui>
 *       </row-ui>
 *     </template>
 *     <div slot="empty">No messages yet.</div>
 *   </list-window-ui>
 *   <script>
 *     document.getElementById('chat').items = bigArray;  // 10k+ rows; ~12 rendered
 *   </script>
 *
 * Stamping strategy: imperative document.createElement for row materialization
 * (per component-implementation-patterns.md Strategy 3 — dynamic-length lists,
 * state-preserving diff for scroll position + focus). `static parts` carries
 * the wrapper shell (phantom spacer + window region + sentinels).
 *
 * Per ADR-0033 (light-DOM substrate): no shadow root, no ::part, no
 * ::slotted. Slots are decorative `slot=""` attributes; positioning is by
 * CSS rules matching tag + ancestor + DOM order inside @scope (list-window-ui).
 *
 * See SPEC-022 for the full contract.
 */

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

export class UIListWindow extends UIElement {
  static properties = {
    items:         { type: Array,   default: [],        reflect: false },
    itemSize:      { type: Number,  default: 0,         reflect: true,  attribute: 'item-size' },
    itemSizeRem:   { type: Number,  default: 0,         reflect: true,  attribute: 'item-size-rem' },
    estimatedSize: { type: Number,  default: 48,        reflect: true,  attribute: 'estimated-size' },
    overscan:      { type: Number,  default: 3,         reflect: true },
    direction:     { type: String,  default: 'vertical', reflect: true },
    pinBottom:     { type: Boolean, default: false,     reflect: true,  attribute: 'pin-bottom' },
    startIndex:    { type: Number,  default: 0,         reflect: false, attribute: 'start-index' },
    loading:       { type: Boolean, default: false,     reflect: true },
  };

  static parts = {
    phantom:        '<div data-phantom></div>',
    window:         '<div data-window></div>',
    sentinelTop:    '<div data-sentinel="top"></div>',
    sentinelBottom: '<div data-sentinel="bottom"></div>',
  };

  static template = () => null;

  // — Instance fields ———————————————————————————————————————————

  #phantom = null;
  #window = null;
  #sentinelTop = null;
  #sentinelBottom = null;

  // Render function (set via .render = fn). Mutually exclusive with the
  // <template> child path. Property name shadows the lifecycle render()
  // hook intentionally — the base class invokes render() via prototype
  // dispatch, while the consumer-set `render` field is read off the
  // instance in #materialize(). Guarded with a typeof check there.
  #renderFn = null;

  // Optional key-fn for DOM reconciliation. Defaults to identity.
  // (`items[i] || i` — array indices are stable when items is stable.)
  #keyFn = (item, idx) => (item && typeof item === 'object' && 'id' in item ? item.id : idx);

  // Row DOM cache keyed by row index. Persists across re-renders so we
  // can detach/reattach without losing focus state on stable rows.
  #rowCache = new Map();

  // Offset cache for variable-height mode. Map<index, height>.
  // Capped (LRU eviction) at 100_000 entries per SPEC-022 §OD-002 lean.
  #measurementCache = new Map();
  #MEASURE_CACHE_CAP = 100_000;

  // Last known visible range — emitted with `range-change`.
  #lastStart = -1;
  #lastEnd = -1;

  // Last scroll position; tracked so #onScroll can compare deltas and
  // suppress the `scroll-end` event during programmatic scrolls.
  #lastScrollPos = 0;

  // Whether the previous render saw the host scrolled to the bottom.
  // Captured pre-mutation so a subsequent `render()` (triggered by
  // items[] reassignment) knows to re-pin even though totalSize has
  // already grown. Updated by both `#onScroll` and post-render below.
  #lastWasAtBottom = true;

  // ResizeObserver for variable-height rows.
  #resizeObserver = null;

  // IntersectionObserver for sentinels (scroll-start / scroll-end).
  #intersectionObserver = null;

  // Stable scroll handler reference (Lifecycle Axis 4 contract).
  #onScroll = () => {
    this.#materialize();
    const pos = this.#scrollPos();
    this.#lastScrollPos = pos;
    this.#lastWasAtBottom = this.#isAtBottom();
  };

  // Stable click handler reference for `item-click` dispatch.
  #onClick = (e) => {
    const row = e.target.closest?.('[data-row]');
    if (!row || row.parentElement !== this.#window) return;
    const index = Number(row.dataset.index);
    if (!Number.isInteger(index) || index < 0) return;
    const item = this.items?.[index];
    if (item == null) return;
    this.dispatchEvent(new CustomEvent('item-click', {
      detail: { item, index },
      bubbles: true,
    }));
  };

  // Stable keydown handler for keyboard scroll.
  #onKeydown = (e) => {
    // Only handle when the host itself is focused (not when focus is
    // inside a row's interactive descendant — the row's keydown takes
    // priority then).
    if (e.target !== this) return;
    const rowH = this.#fixedRowSize() || this.estimatedSize || 48;
    const viewport = this.#viewportSize();
    let delta = 0;
    switch (e.key) {
      case 'ArrowDown':  delta = +rowH; break;
      case 'ArrowUp':    delta = -rowH; break;
      case 'PageDown':   delta = +viewport; break;
      case 'PageUp':     delta = -viewport; break;
      case ' ':          delta = +viewport; break;
      case 'Home':       e.preventDefault(); this.scrollToTop(); return;
      case 'End':        e.preventDefault(); this.scrollToBottom(); return;
      default: return;
    }
    e.preventDefault();
    const pos = this.#scrollPos();
    this.#setScrollPos(pos + delta);
  };

  // Sentinel observer callback.
  #onSentinel = (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const which = entry.target?.dataset?.sentinel;
      if (which === 'top') {
        const idx = this.#lastStart >= 0 ? this.#lastStart : 0;
        this.dispatchEvent(new CustomEvent('scroll-start', {
          detail: { index: idx },
          bubbles: true,
        }));
      } else if (which === 'bottom') {
        const items = this.items || [];
        const idx = this.#lastEnd >= 0 ? this.#lastEnd : items.length - 1;
        this.dispatchEvent(new CustomEvent('scroll-end', {
          detail: { index: idx },
          bubbles: true,
        }));
      }
    }
  };

  // Per-row resize observer — refines variable-height offset cache.
  #onResize = (entries) => {
    if (this.itemSize > 0) return;  // fixed-size mode skips measurement
    let changed = false;
    for (const entry of entries) {
      const row = entry.target;
      if (!row || row.parentElement !== this.#window) continue;
      const idx = Number(row.dataset.index);
      if (!Number.isInteger(idx)) continue;
      const dim = this.direction === 'horizontal' ? entry.contentRect.width : entry.contentRect.height;
      const next = Math.max(1, Math.round(dim));
      const prev = this.#measurementCache.get(idx);
      if (prev === next) continue;
      this.#measurementCache.set(idx, next);
      this.dispatchEvent(new CustomEvent('measure', {
        detail: { index: idx, height: next },
        bubbles: true,
      }));
      changed = true;
    }
    if (changed) {
      this.#evictMeasurementCache();
      this.#updatePhantomSize();
      this.#materialize();
    }
  };

  // — Lifecycle ——————————————————————————————————————————————

  connected() {
    this.setAttribute('role', this.getAttribute('role') || 'list');
    if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');

    this.#phantom        = this.ensure('phantom');
    this.#window         = this.ensure('window');
    this.#sentinelTop    = this.ensure('sentinelTop');
    this.#sentinelBottom = this.ensure('sentinelBottom');

    // Observer setup — both are isolated to non-self targets so they
    // need explicit teardown in disconnected().
    if (typeof IntersectionObserver !== 'undefined') {
      this.#intersectionObserver = new IntersectionObserver(this.#onSentinel, {
        root: this,
        rootMargin: '0px',
        threshold: 0,
      });
      this.#intersectionObserver.observe(this.#sentinelTop);
      this.#intersectionObserver.observe(this.#sentinelBottom);
    }
    if (typeof ResizeObserver !== 'undefined') {
      this.#resizeObserver = new ResizeObserver(this.#onResize);
    }

    this.addEventListener('scroll', this.#onScroll, { passive: true });
    this.addEventListener('click', this.#onClick);
    this.addEventListener('keydown', this.#onKeydown);

    // Initial scroll-to-index, if requested.
    if (this.startIndex > 0) {
      // Defer to next frame — phantom needs to be sized first.
      queueMicrotask(() => this.scrollToIndex(this.startIndex));
    }
  }

  disconnected() {
    this.removeEventListener('scroll', this.#onScroll);
    this.removeEventListener('click', this.#onClick);
    this.removeEventListener('keydown', this.#onKeydown);
    this.#intersectionObserver?.disconnect();
    this.#intersectionObserver = null;
    this.#resizeObserver?.disconnect();
    this.#resizeObserver = null;
    this.#rowCache.clear();
  }

  // The base-class `render()` hook re-runs on every signal change
  // (items, itemSize, overscan, direction, loading, etc). Each pass:
  //
  //   1. Surface state attrs ([empty], aria-* counts, aria-busy).
  //   2. Resize the phantom spacer to reflect total scroll height.
  //   3. Maintain pin-bottom invariant before re-materializing.
  //   4. Re-materialize the visible window.
  render() {
    if (!this.#window) return;  // pre-connect; render() can fire from
                                // setup-time signal touch.

    const items = Array.isArray(this.items) ? this.items : [];

    // Pin-bottom check uses the LAST-seen "at bottom" state (tracked on
    // scroll) so we still re-pin after an items[] append even though
    // totalSize has already grown by the time we get here. The very
    // first render uses the live position because there's no history
    // yet — and isAtBottom returns true for empty/zero-total lists,
    // which is what we want (pin a freshly-mounted thread).
    const wasPinned = this.pinBottom && (this.#lastWasAtBottom || this.#isAtBottom());

    if (items.length === 0) {
      this.setAttribute('empty', '');
    } else {
      this.removeAttribute('empty');
    }

    this.setAttribute('aria-rowcount', String(items.length));
    if (this.loading) this.setAttribute('aria-busy', 'true');
    else              this.removeAttribute('aria-busy');

    this.#updatePhantomSize();
    this.#materialize();

    if (wasPinned) this.scrollToBottom();
    this.#lastWasAtBottom = this.#isAtBottom();
  }

  // — Public API ———————————————————————————————————————————————

  /** Get-or-set the consumer's row render function. Setting triggers
   *  a re-materialization. When unset, falls back to the <template>
   *  child path or a minimal default row.
   *
   *  NB — this prop SHADOWS the base-class render() lifecycle hook by
   *  name. `host.render = fn` writes to an instance field that the
   *  internal #materialize path reads; the base class still invokes
   *  the prototype `render()` method (defined above) via reactive
   *  effect. The two paths are completely separate.
   */
  set renderRow(fn) {
    this.#renderFn = typeof fn === 'function' ? fn : null;
    this.#rowCache.clear();
    this.#materialize();
  }
  get renderRow() {
    return this.#renderFn;
  }

  /** Stable key-fn for DOM reconciliation. */
  set keyFn(fn) {
    this.#keyFn = typeof fn === 'function' ? fn : ((item, idx) => idx);
    this.#rowCache.clear();
    this.#materialize();
  }
  get keyFn() { return this.#keyFn; }

  /** Scroll to a specific item index. */
  scrollToIndex(idx, { behavior = 'auto', align = 'start' } = {}) {
    const items = this.items || [];
    if (!items.length) return;
    const i = Math.max(0, Math.min(items.length - 1, idx | 0));
    const offset = this.#offsetForIndex(i);
    const viewport = this.#viewportSize();
    const rowSize = this.#rowSizeForIndex(i);
    let target = offset;
    if (align === 'center') target = offset - (viewport / 2) + (rowSize / 2);
    else if (align === 'end') target = offset - viewport + rowSize;
    this.#setScrollPos(Math.max(0, target), behavior);
  }

  scrollToTop()    { this.#setScrollPos(0); }
  scrollToBottom() {
    const total = this.#totalSize();
    this.#setScrollPos(Math.max(0, total));
  }

  /** Currently rendered window. */
  getVisibleRange() {
    return { startIndex: this.#lastStart, endIndex: this.#lastEnd };
  }

  /** Reset measurement cache from a given index (use when item content
   *  mutates and the previous measurements are stale). */
  invalidateMeasurements(fromIndex = 0) {
    if (fromIndex <= 0) {
      this.#measurementCache.clear();
    } else {
      for (const key of [...this.#measurementCache.keys()]) {
        if (key >= fromIndex) this.#measurementCache.delete(key);
      }
    }
    this.#updatePhantomSize();
    this.#materialize();
  }

  // — Internals ——————————————————————————————————————————————

  #fixedRowSize() {
    if (this.itemSize > 0) return this.itemSize;
    if (this.itemSizeRem > 0) {
      const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
      return this.itemSizeRem * root;
    }
    return 0;
  }

  #rowSizeForIndex(idx) {
    const fixed = this.#fixedRowSize();
    if (fixed > 0) return fixed;
    const measured = this.#measurementCache.get(idx);
    if (measured != null) return measured;
    return this.estimatedSize || 48;
  }

  #offsetForIndex(idx) {
    const fixed = this.#fixedRowSize();
    if (fixed > 0) return idx * fixed;
    let off = 0;
    for (let i = 0; i < idx; i++) off += this.#rowSizeForIndex(i);
    return off;
  }

  #totalSize() {
    const items = Array.isArray(this.items) ? this.items : [];
    const n = items.length;
    if (n === 0) return 0;
    const fixed = this.#fixedRowSize();
    if (fixed > 0) return n * fixed;
    // Variable-height: sum measured + estimated for unmeasured.
    let total = 0;
    const est = this.estimatedSize || 48;
    for (let i = 0; i < n; i++) {
      total += this.#measurementCache.get(i) ?? est;
    }
    return total;
  }

  #viewportSize() {
    if (this.direction === 'horizontal') return this.clientWidth || 0;
    return this.clientHeight || 0;
  }

  #scrollPos() {
    return this.direction === 'horizontal' ? this.scrollLeft : this.scrollTop;
  }

  #setScrollPos(pos, behavior = 'auto') {
    if (this.direction === 'horizontal') {
      this.scrollTo({ left: pos, behavior });
    } else {
      this.scrollTo({ top: pos, behavior });
    }
  }

  #isAtBottom() {
    const pos = this.#scrollPos();
    const viewport = this.#viewportSize();
    const total = this.#totalSize();
    // Consider "at bottom" within 1px of total to handle sub-pixel rounding.
    return pos + viewport >= total - 1;
  }

  #updatePhantomSize() {
    if (!this.#phantom) return;
    const total = this.#totalSize();
    if (this.direction === 'horizontal') {
      this.#phantom.style.width = `${total}px`;
      this.#phantom.style.height = '1px';
    } else {
      this.#phantom.style.height = `${total}px`;
      this.#phantom.style.width = '1px';
    }
  }

  /** Compute the visible-row range given current scroll position. */
  #computeRange() {
    const items = Array.isArray(this.items) ? this.items : [];
    const n = items.length;
    if (n === 0) return { start: 0, end: 0 };
    const viewport = this.#viewportSize();
    const pos = this.#scrollPos();
    const fixed = this.#fixedRowSize();
    let start, end;
    if (fixed > 0) {
      start = Math.floor(pos / fixed);
      end = Math.ceil((pos + viewport) / fixed);
    } else {
      // Variable-height: walk the offset cache.
      const est = this.estimatedSize || 48;
      let off = 0;
      start = 0;
      for (let i = 0; i < n; i++) {
        const h = this.#measurementCache.get(i) ?? est;
        if (off + h > pos) { start = i; break; }
        off += h;
      }
      end = start;
      let visible = 0;
      for (let i = start; i < n; i++) {
        const h = this.#measurementCache.get(i) ?? est;
        visible += h;
        end = i + 1;
        if (visible >= viewport) break;
      }
    }
    const over = Math.max(0, this.overscan | 0);
    start = Math.max(0, start - over);
    end   = Math.min(n, end + over);
    return { start, end };
  }

  /** Acquire / re-position / release row DOM for the current range. */
  #materialize() {
    if (!this.#window) return;
    const items = Array.isArray(this.items) ? this.items : [];
    const n = items.length;
    const { start, end } = this.#computeRange();

    // Position the window region at the current scroll-offset.
    const offset = this.#offsetForIndex(start);
    if (this.direction === 'horizontal') {
      this.#window.style.transform = `translateX(${offset}px)`;
    } else {
      this.#window.style.transform = `translateY(${offset}px)`;
    }

    // Loading state — emit N skeleton rows in lieu of real data.
    if (this.loading) {
      this.#renderLoadingState(end - start);
    } else {
      this.#reconcileRows(items, start, end);
    }

    // Emit range-change when the visible window shifts.
    if (start !== this.#lastStart || end !== this.#lastEnd) {
      this.#lastStart = start;
      this.#lastEnd = end;
      this.dispatchEvent(new CustomEvent('range-change', {
        detail: {
          startIndex: start,
          endIndex: end,
          items: items.slice(start, end),
        },
        bubbles: true,
      }));
    }

    // Sentinel positioning — top sentinel sits at the start of the
    // window region; bottom sentinel sits at the end. Both inside the
    // phantom so the IntersectionObserver fires when they enter the
    // host viewport.
    void n; // sentinels are static-positioned in CSS; this is a no-op
            // hook in case we later move them imperatively.
  }

  #reconcileRows(items, start, end) {
    if (!this.#window) return;

    // Detach all current row nodes — we'll re-append in order. (We
    // unobserve from the resize observer first to avoid spurious
    // measurement events during detach.)
    const surviving = new Set();
    const renderFn = this.#renderFn ?? this.#templateRenderer();
    for (let i = start; i < end; i++) {
      const item = items[i];
      const key = this.#keyFn(item, i);
      surviving.add(key);
      let row = this.#rowCache.get(key);
      if (!row) {
        row = this.#stampRow(item, i, renderFn);
        this.#rowCache.set(key, row);
      } else {
        // Update index dataset + ARIA on re-use; don't re-stamp content.
        row.dataset.index = String(i);
        row.setAttribute('aria-rowindex', String(i + 1));
      }
    }

    // Build the in-order rendered list.
    const desired = [];
    for (let i = start; i < end; i++) {
      const item = items[i];
      const key = this.#keyFn(item, i);
      const row = this.#rowCache.get(key);
      if (row) desired.push(row);
    }

    // Drop cached rows that are no longer in the visible window.
    for (const [key, row] of this.#rowCache) {
      if (!surviving.has(key)) {
        this.#resizeObserver?.unobserve(row);
        if (row.parentElement === this.#window) row.remove();
        this.#rowCache.delete(key);
      }
    }

    // Re-attach surviving rows in order (in-place diff).
    let cursor = this.#window.firstChild;
    for (const row of desired) {
      if (row !== cursor) this.#window.insertBefore(row, cursor);
      cursor = row.nextSibling;
    }
    // Trim anything beyond the desired list (paranoia; should be empty
    // after the cache-eviction pass above).
    while (cursor) {
      const next = cursor.nextSibling;
      cursor.remove();
      cursor = next;
    }

    // (Re-)observe rows for variable-height measurement.
    if (this.itemSize <= 0 && this.itemSizeRem <= 0 && this.#resizeObserver) {
      for (const row of desired) this.#resizeObserver.observe(row);
    }
  }

  #renderLoadingState(count) {
    if (!this.#window) return;
    this.#window.replaceChildren();
    this.#rowCache.clear();
    const max = Math.max(1, Math.min(20, count | 0 || 5));
    const userLoading = this.querySelector(':scope > template[slot="loading"]');
    for (let i = 0; i < max; i++) {
      const row = document.createElement('div');
      row.setAttribute('data-row', '');
      row.setAttribute('data-skeleton-row', '');
      row.dataset.index = String(i);
      if (userLoading?.content) {
        row.appendChild(userLoading.content.cloneNode(true));
      } else {
        const sk = document.createElement('skeleton-ui');
        row.appendChild(sk);
      }
      this.#window.appendChild(row);
    }
  }

  #stampRow(item, index, renderFn) {
    const row = document.createElement('div');
    row.setAttribute('data-row', '');
    row.setAttribute('role', 'listitem');
    row.dataset.index = String(index);
    row.setAttribute('aria-rowindex', String(index + 1));
    let content = null;
    if (typeof renderFn === 'function') {
      try { content = renderFn(item, index); }
      catch (err) { console.warn('[list-window-ui] render() threw:', err); }
    }
    if (content instanceof Node) {
      row.appendChild(content);
    } else if (content != null) {
      row.textContent = String(content);
    } else if (item != null && typeof item === 'object' && 'text' in item) {
      // Sensible default — render an objects-with-a-text-field item via
      // <list-item-ui> (matches the A2UI catalog default-renderer rule).
      const li = document.createElement('list-item-ui');
      li.setAttribute('text', String(item.text));
      if ('description' in item) li.setAttribute('description', String(item.description));
      if ('icon' in item) li.setAttribute('icon', String(item.icon));
      row.appendChild(li);
    } else if (item != null) {
      row.textContent = String(item);
    }
    return row;
  }

  /** When the consumer authored a <template> child (no [slot]), return a
   *  function that clones the template with naive ${item.field}
   *  substitution. The expression syntax is intentionally minimal —
   *  consumers who need richer rendering should set host.renderRow = fn. */
  #templateRenderer() {
    const tpl = this.querySelector(':scope > template:not([slot])');
    if (!tpl) return null;
    // Cache the parsed template string between calls.
    if (this.#cachedTemplateText == null) {
      this.#cachedTemplateText = tpl.innerHTML;
    }
    const text = this.#cachedTemplateText;
    return (item, _idx) => {
      const rendered = text.replace(/\$\{item\.([a-zA-Z0-9_]+)\}/g, (_, key) => {
        const v = item && typeof item === 'object' ? item[key] : '';
        return v == null ? '' : escapeHTML(String(v));
      });
      const frag = document.createElement('template');
      frag.innerHTML = rendered;
      return frag.content.cloneNode(true);
    };
  }
  #cachedTemplateText = null;

  /** LRU-cap the measurement cache. Per SPEC-022 §OD-002 lean: 100_000
   *  entries ≈ 1.6 MB on a 1M list — bounded, but acceptable on
   *  mid-tier devices. */
  #evictMeasurementCache() {
    if (this.#measurementCache.size <= this.#MEASURE_CACHE_CAP) return;
    const drop = this.#measurementCache.size - this.#MEASURE_CACHE_CAP;
    let dropped = 0;
    for (const key of this.#measurementCache.keys()) {
      this.#measurementCache.delete(key);
      if (++dropped >= drop) break;
    }
  }
}

// Tiny HTML-escape — the template literal renderer (#templateRenderer
// above) is the only call site; it interpolates string values from the
// consumer's items[] into raw HTML, so we escape to keep XSS surface
// area small. Consumers needing rich markup should set `host.renderRow`
// (function path) which receives the raw item and can return Nodes.
function escapeHTML(s) {
  return s.replace(/[&<>"']/g, (c) => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  })[c]);
}
