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

/**
 * <table-ui> — Data table with sorting, selection, pagination, search,
 *             column resize, keyboard navigation, cell types, and CSV export.
 *
 * Renders as CSS grid + subgrid rows with ARIA grid roles (no <table> element).
 *
 * Attributes:
 *   sortable   — enable column sorting on header click
 *   selectable — enable row selection via checkboxes
 *   striped    — alternate row background
 *   density    — 'compact' | 'standard' | 'comfortable'
 *   paginate   — rows per page (0 = no pagination)
 *   loading    — show loading overlay
 *   search     — global search filter string
 *
 * Declarative columns via <col-def> children:
 *   <col-def key="name" label="Name" type="text" width="200"
 *            min-width="100" max-width="400" flex="2" pinned="left"
 *            sortable hidden filter-type="text"></col-def>
 *
 * JS API:
 *   .columns = [{key, label, type, width, minWidth, maxWidth, flex,
 *                sortable, filterable, resizable, pinned, hidden,
 *                accessor, format, render, sortFn, sortDescFirst,
 *                filterType, filterFn, aggregate, meta}]
 *   .data    = [{...}, ...]
 *   .selected   → [...indices] (read-only)
 *   .sortState  → [{key, dir}] (read-only)
 *   .exportCSV(filename?)
 *
 * Events:
 *   sort       — { detail: { key, dir, sortState } }
 *   select     — { detail: { selected: [...indices] } }
 *   page       — { detail: { page } }
 *   resize     — { detail: { key, width } }
 *   cell-click — { detail: { key, row, value, dataIndex } }
 *   row-click  — { detail: { row, dataIndex } }  — row-level companion to cell-click
 */

import { UIElement } from '../../core/element.js';
import { cellTypes, sortFns } from './cell-types.js';

// ── Helpers ──────────────────────────────────────────────────────────────────

/**
 * Resolve a dot-notation path on an object.
 * getNestedValue({user: {name: 'Jo'}}, 'user.name') → 'Jo'
 */
function getNestedValue(obj, path) {
  if (!path || !obj) return undefined;
  const parts = path.split('.');
  let cur = obj;
  for (const p of parts) {
    if (cur == null) return undefined;
    cur = cur[p];
  }
  return cur;
}

/**
 * Get cell value for a column from a row, respecting accessor and dot notation.
 */
function getCellValue(row, col) {
  if (typeof col.accessor === 'function') return col.accessor(row);
  return getNestedValue(row, col.key);
}

/**
 * Escape a value for CSV output.
 */
function csvEscape(val) {
  const str = String(val ?? '');
  if (str.includes(',') || str.includes('"') || str.includes('\n')) {
    return '"' + str.replace(/"/g, '""') + '"';
  }
  return str;
}

// ── Component ────────────────────────────────────────────────────────────────

export class UITable 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',
    'caret-up-down',
    'table',
    'arrow-up',
    'arrow-down',
    'funnel-simple',
    'funnel-simple-fill',
  ];

  static properties = {
    sortable:   { type: Boolean, default: false, reflect: true },
    selectable: { type: Boolean, default: false, reflect: true },
    expandable: { type: Boolean, default: false, reflect: true },
    striped:    { type: Boolean, default: false, reflect: true },
    wrap:       { type: Boolean, default: false, reflect: true },
    raw:        { type: Boolean, default: false, reflect: true },
    density:    { type: String,  default: 'standard', reflect: true },
    paginate:   { type: Number,  default: 0,     reflect: true },
    loading:    { type: Boolean, default: false, reflect: true },
    search:     { type: String,  default: '',    reflect: true },
  };

  static template = () => null;

  // ── Private state ──

  #columns = [];
  #data = [];
  #sortState = [];          // [{key, dir}] for multi-sort
  #filters = new Map();     // key → { op, value }
  #expanded = new Set();    // row indices
  #editingCell = null;      // {rowIndex, colKey} or null
  #page = 0;
  #selected = new Set();
  #columnWidths = new Map(); // key → current width in px
  #focusedCell = null;       // {row, col}
  #bound = false;
  #lastSelectedIndex = -1;   // for shift-click range select
  #openFilter = null;        // column key of open filter dropdown
  #filterFocusRaf = null;

  // ── Public API: columns ──

  set columns(arr) {
    this.#columns = Array.isArray(arr) ? arr : [];
    this.#requestRender();
  }

  get columns() { return this.#columns; }

  // ── Public API: data ──

  set data(arr) {
    this.#data = Array.isArray(arr) ? arr : [];
    this.#selected.clear();
    this.#page = 0;
    this.#requestRender();
  }

  get data() { return this.#data; }

  // ── Public API: selection ──

  /**
   * Indices of currently-selected rows, ascending.
   * @returns {number[]}
   */
  get selected() { return [...this.#selected].sort((a, b) => a - b); }

  /**
   * Replace the selection set programmatically. Out-of-range indices are
   * silently dropped. Pair with `selectable` mode; on a non-selectable
   * table the indices are stored but not rendered as checked rows.
   * @param {Iterable<number>} indices
   */
  set selected(indices) {
    this.#selected.clear();
    if (indices) {
      for (const i of indices) {
        if (Number.isInteger(i) && i >= 0 && i < this.#data.length) {
          this.#selected.add(i);
        }
      }
    }
    this.#requestRender();
    this.dispatchEvent(new CustomEvent('select', {
      detail: { selected: this.selected },
      bubbles: true,
    }));
  }

  /**
   * Empty the selection set. Equivalent to `el.selected = []` but doesn't
   * require constructing an empty array; the most common selection-write
   * call by far (after a "delete" or "archive" bulk action), so worth
   * having a one-token verb form.
   */
  clearSelection() {
    if (this.#selected.size === 0) return;
    this.#selected.clear();
    this.#requestRender();
    this.dispatchEvent(new CustomEvent('select', {
      detail: { selected: [] },
      bubbles: true,
    }));
  }

  // ── Public API: read-only getters ──

  get sortState() { return this.#sortState.map(s => ({ ...s })); }

  // ── Public API: filters ──

  setFilter(key, value, op = 'contains') {
    if (value === null || value === undefined || value === '') {
      this.#filters.delete(key);
    } else {
      this.#filters.set(key, { op, value });
    }
    this.#page = 0;
    this.#requestRender();
    this.dispatchEvent(new CustomEvent('filter-change', {
      detail: { filters: Object.fromEntries(this.#filters) },
      bubbles: true,
    }));
  }

  clearFilters() {
    this.#filters.clear();
    this.#openFilter = null;
    this.#page = 0;
    this.#requestRender();
    this.dispatchEvent(new CustomEvent('filter-change', {
      detail: { filters: {} },
      bubbles: true,
    }));
  }

  get filters() { return Object.fromEntries(this.#filters); }

  // ── Public API: expansion ──

  toggleExpand(index) {
    if (this.#expanded.has(index)) {
      this.#expanded.delete(index);
      this.dispatchEvent(new CustomEvent('row-collapse', { detail: { index, row: this.#data[index] }, bubbles: true }));
    } else {
      this.#expanded.add(index);
      this.dispatchEvent(new CustomEvent('row-expand', { detail: { index, row: this.#data[index] }, bubbles: true }));
    }
    this.#requestRender();
  }

  get expanded() { return [...this.#expanded]; }

  /** @type {((row: object, index: number) => HTMLElement)|null} */
  expandRenderer = null;

  // ── Public API: state persistence ──

  getState() {
    return {
      sort: this.#sortState.map(s => ({ ...s })),
      filters: Object.fromEntries(this.#filters),
      columnWidths: Object.fromEntries(this.#columnWidths),
      hiddenColumns: this.#columns.filter(c => c.hidden).map(c => c.key),
      page: this.#page,
      density: this.density,
    };
  }

  setState(state) {
    if (!state) return;
    if (state.sort) this.#sortState = state.sort;
    if (state.filters) {
      this.#filters.clear();
      for (const [key, val] of Object.entries(state.filters)) {
        this.#filters.set(key, val);
      }
    }
    if (state.columnWidths) {
      this.#columnWidths.clear();
      for (const [key, val] of Object.entries(state.columnWidths)) {
        this.#columnWidths.set(key, val);
      }
    }
    if (state.hiddenColumns) {
      for (const col of this.#columns) {
        col.hidden = state.hiddenColumns.includes(col.key);
      }
    }
    if (state.page != null) this.#page = state.page;
    if (state.density) this.density = state.density;
    this.#requestRender();
  }

  #persistState() {
    const key = this.getAttribute('state-key');
    if (!key) return;
    try {
      localStorage.setItem(`table-state:${key}`, JSON.stringify(this.getState()));
    } catch {}
  }

  #restoreState() {
    const key = this.getAttribute('state-key');
    if (!key) return;
    try {
      const saved = localStorage.getItem(`table-state:${key}`);
      if (saved) this.setState(JSON.parse(saved));
    } catch {}
  }

  // ── Lifecycle ──────────────────────────────────────────────────────────────

  connected() {
    // Parse declarative <col-def> children
    this.#parseColDefs();

    this.setAttribute('role', 'grid');
    this.setAttribute('tabindex', '0');

    // Restore persisted state
    this.#restoreState();

    if (!this.#bound) {
      this.#bound = true;
      this.addEventListener('click', this.#onClick);
      this.addEventListener('keydown', this.#onKeydown);
      // Column resize needs pointerdown (drag start) — NOT click. click fires
      // after pointerup, so by the time #startResize runs from a click
      // handler, the user has already released and the document-level
      // move/up listeners never trigger. pointerdown also covers touch +
      // pen automatically; Playwright's page.mouse.* synthesizes pointer
      // events (not mouse events), so a mousedown-only handler tests dead.
      this.addEventListener('pointerdown', this.#onPointerdown);
    }
  }

  disconnected() {
    this.removeEventListener('click', this.#onClick);
    this.removeEventListener('keydown', this.#onKeydown);
    this.removeEventListener('pointerdown', this.#onPointerdown);
    this.#bound = false;
    this.#cleanupResize();
    if (this.#renderRaf) {
      cancelAnimationFrame(this.#renderRaf);
      this.#renderRaf = null;
    }
    if (this.#filterFocusRaf != null) {
      cancelAnimationFrame(this.#filterFocusRaf);
      this.#filterFocusRaf = null;
    }
  }

  // ── <col-def> Parsing ──────────────────────────────────────────────────────

  #parseColDefs() {
    const defs = this.querySelectorAll(':scope > col-def');
    if (!defs.length) return;

    const cols = [];
    for (const el of defs) {
      const col = {
        key:       el.getAttribute('key') || '',
        label:     el.getAttribute('label') || el.getAttribute('key') || '',
        type:      el.getAttribute('type') || 'text',
        pinned:    el.getAttribute('pinned') || null,       // 'left' | 'right' | null
        hidden:    el.hasAttribute('hidden'),
        sortable:  el.hasAttribute('sortable'),
        resizable: el.hasAttribute('resizable'),
        filterable: el.hasAttribute('filterable'),
        filterType: el.getAttribute('filter-type') || null,
      };

      const w = el.getAttribute('width');
      if (w) col.width = Number(w);

      const min = el.getAttribute('min-width');
      if (min) col.minWidth = Number(min);

      const max = el.getAttribute('max-width');
      if (max) col.maxWidth = Number(max);

      const flex = el.getAttribute('flex');
      if (flex) col.flex = Number(flex);

      cols.push(col);
      el.remove();
    }

    // Only set if we actually found col-defs and columns aren't already set via JS
    if (cols.length && !this.#columns.length) {
      this.#columns = cols;
    }
  }

  // ── Render Batching ────────────────────────────────────────────────────────

  #renderRaf = null;

  #requestRender() {
    if (!this.isConnected || this.#renderRaf) return;
    this.#renderRaf = requestAnimationFrame(() => {
      this.#renderRaf = null;
      this.render();
      this.#persistState();
    });
  }

  // ── Visible Columns Helper ─────────────────────────────────────────────────

  get #visibleColumns() {
    return this.#columns.filter(c => !c.hidden);
  }

  // ── Grid Template Columns ──────────────────────────────────────────────────

  #buildGridTemplate() {
    const parts = [];

    if (this.expandable) {
      parts.push('2.5rem');
    }

    if (this.selectable) {
      parts.push('2.5rem');
    }

    for (const col of this.#visibleColumns) {
      // User-resized width takes priority
      if (this.#columnWidths.has(col.key)) {
        parts.push(`${this.#columnWidths.get(col.key)}px`);
      } else if (col.width) {
        parts.push(`${col.width}px`);
      } else if (col.flex) {
        parts.push(`minmax(6rem, ${col.flex}fr)`);
      } else {
        parts.push('minmax(6rem, 1fr)');
      }
    }

    return parts.join(' ');
  }

  // ── Data Processing Pipeline ───────────────────────────────────────────────

  /**
   * Apply search → column filters → sort → return array of original data indices.
   */
  #getProcessedIndices() {
    let indices = this.#data.map((_, i) => i);

    // 1. Global search filter
    if (this.search) {
      const q = this.search.toLowerCase();
      indices = indices.filter(i => {
        const row = this.#data[i];
        return this.#visibleColumns.some(col => {
          const val = getCellValue(row, col);
          return val != null && String(val).toLowerCase().includes(q);
        });
      });
    }

    // 2. Column filters
    if (this.#filters.size) {
      indices = indices.filter(i => {
        const row = this.#data[i];
        for (const [key, { op, value }] of this.#filters) {
          const col = this.#columns.find(c => c.key === key);
          const cellVal = getCellValue(row, col || { key });
          const str = String(cellVal ?? '').toLowerCase();
          const target = String(value).toLowerCase();
          switch (op) {
            case 'contains': if (!str.includes(target)) return false; break;
            case 'equals': if (str !== target) return false; break;
            case 'startsWith': if (!str.startsWith(target)) return false; break;
            case 'gt': if (Number(cellVal) <= Number(value)) return false; break;
            case 'lt': if (Number(cellVal) >= Number(value)) return false; break;
            case 'gte': if (Number(cellVal) < Number(value)) return false; break;
            case 'lte': if (Number(cellVal) > Number(value)) return false; break;
            case 'between': {
              const [lo, hi] = String(value).split(',').map(Number);
              const n = Number(cellVal);
              if (n < lo || n > hi) return false;
              break;
            }
            case 'select': {
              const selected = target.split(',').map(s => s.trim());
              if (!selected.includes(str)) return false;
              break;
            }
            default: if (!str.includes(target)) return false;
          }
        }
        return true;
      });
    }

    // 3. Multi-sort
    if (this.#sortState.length) {
      indices.sort((a, b) => {
        for (const { key, dir } of this.#sortState) {
          const col = this.#columns.find(c => c.key === key);
          const va = getCellValue(this.#data[a], col || { key });
          const vb = getCellValue(this.#data[b], col || { key });

          // Determine sort function
          let fn;
          if (typeof col?.sortFn === 'function') {
            fn = col.sortFn;
          } else {
            const typeDef = cellTypes[col?.type || 'text'];
            const fnName = col?.sortFn || typeDef?.sortFn || 'alphanumeric';
            fn = sortFns[fnName] || sortFns.alphanumeric;
          }

          // Nulls always sort last
          if (va == null && vb == null) continue;
          if (va == null) return 1;
          if (vb == null) return -1;

          const cmp = fn(va, vb);
          if (cmp !== 0) return dir === 'asc' ? cmp : -cmp;
        }
        return 0;
      });
    }

    return indices;
  }

  /**
   * Paginate a set of indices.
   */
  #getPageSlice(indices) {
    if (!this.paginate || this.paginate <= 0) return indices;
    const start = this.#page * this.paginate;
    return indices.slice(start, start + this.paginate);
  }

  get #pageCount() {
    if (!this.paginate || this.paginate <= 0) return 1;
    // pageCount should reflect filtered data, not raw data
    const filteredCount = this.#getProcessedIndices().length;
    return Math.max(1, Math.ceil(filteredCount / this.paginate));
  }

  // ── Render ─────────────────────────────────────────────────────────────────

  render() {
    // Raw mode: visual-only passthrough. The consumer authored the entire
    // body shape (native <table>, custom row rendering, etc.) and only wants
    // table-ui's CSS tokens (chrome reset via :scope[raw]). The data
    // lifecycle (header injection, body reconciliation, overlays, aggregation,
    // pagination) MUST NOT run — otherwise `.data === []` injects a
    // [data-empty] overlay above the consumer's content (FB-53 §2). Raw is
    // the explicit "consumer owns layout" mode.
    if (this.raw) return;

    const visCols = this.#visibleColumns;

    // Set grid template
    this.style.gridTemplateColumns = this.#buildGridTemplate();

    // ── Header row ──

    let header = this.querySelector(':scope > [data-header]');
    if (!header) {
      header = document.createElement('div');
      header.setAttribute('role', 'row');
      header.setAttribute('data-header', '');
      this.prepend(header);
    }

    this.#renderHeader(header, visCols);

    // ── Filter chips bar ──

    this.#renderFilterChips();

    // ── Body rowgroup ──

    let body = this.querySelector(':scope > [data-body]');
    if (!body) {
      body = document.createElement('div');
      body.setAttribute('role', 'rowgroup');
      body.setAttribute('data-body', '');
      header.after(body);
    }

    // Process data: search → sort → paginate
    const allProcessed = this.#getProcessedIndices();
    const pageIndices = this.#getPageSlice(allProcessed);

    // Reconcile body rows (with expansion support)
    const bodyChildren = [];
    for (const idx of pageIndices) {
      const existing = body.querySelector(`:scope > [role="row"][data-index="${idx}"]`);
      const row = existing || this.#createRow(idx, visCols);
      if (existing) this.#updateRow(existing, idx, visCols);

      // Expand toggle in first cell
      if (this.expandable) {
        const isExpanded = this.#expanded.has(idx);
        if (isExpanded) row.setAttribute('data-expanded', '');
        else row.removeAttribute('data-expanded');
      }

      bodyChildren.push(row);

      // Detail row (expansion)
      if (this.expandable && this.#expanded.has(idx)) {
        let detail = body.querySelector(`:scope > [data-detail-row][data-for="${idx}"]`);
        if (!detail) {
          detail = document.createElement('div');
          detail.setAttribute('data-detail-row', '');
          detail.dataset.for = idx;
          if (this.expandRenderer) {
            const content = this.expandRenderer(this.#data[idx], idx);
            if (content instanceof Node) detail.appendChild(content);
            else detail.textContent = String(content);
          }
        }
        bodyChildren.push(detail);
      }
    }

    // Reconcile: replace body contents
    const existingChildren = [...body.children];
    for (const child of existingChildren) {
      if (!bodyChildren.includes(child)) child.remove();
    }
    for (let i = 0; i < bodyChildren.length; i++) {
      if (body.children[i] !== bodyChildren[i]) {
        if (body.children[i]) body.insertBefore(bodyChildren[i], body.children[i]);
        else body.appendChild(bodyChildren[i]);
      }
    }

    // ── Overlays ──

    this.#renderOverlays(body);

    // ── Aggregation footer ──

    this.#renderAggregation(body, allProcessed, visCols);

    // ── Pagination footer ──

    const showPagination = this.paginate > 0 && this.#data.length > 0;
    let footer = this.querySelector(':scope > [data-footer]');

    if (showPagination) {
      if (!footer) {
        footer = document.createElement('div');
        footer.setAttribute('data-footer', '');
        this.appendChild(footer);
      }
      this.#renderPagination(footer, allProcessed.length);
    } else if (footer) {
      footer.remove();
    }
  }

  // ── Header Rendering ───────────────────────────────────────────────────────

  #renderHeader(header, visCols) {
    const allSelected = this.#data.length > 0 && this.#selected.size === this.#data.length;
    const cells = [];

    // Checkbox column header
    if (this.selectable) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'columnheader');
      cell.setAttribute('data-check-col', '');
      const check = document.createElement('check-ui');
      if (allSelected) check.setAttribute('checked', '');
      check.setAttribute('aria-label', 'Select all rows');
      cell.appendChild(check);
      cells.push(cell);
    }

    // Data columns
    for (const col of visCols) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'columnheader');

      // Label
      const label = document.createElement('span');
      label.textContent = col.label || col.key;
      cell.appendChild(label);

      // Sortable
      const colSortable = col.sortable !== false && this.sortable;
      if (colSortable) {
        cell.dataset.sortKey = col.key;
        cell.setAttribute('aria-label', `Sort by ${col.label || col.key}`);

        const icon = document.createElement('icon-ui');
        icon.setAttribute('data-sort-icon', '');

        const sortEntry = this.#sortState.find(s => s.key === col.key);
        if (sortEntry) {
          cell.setAttribute('aria-sort', sortEntry.dir === 'asc' ? 'ascending' : 'descending');
          icon.setAttribute('name', sortEntry.dir === 'asc' ? 'arrow-up' : 'arrow-down');
        } else {
          icon.setAttribute('name', 'caret-up-down');
        }

        cell.appendChild(icon);
      }

      // Pinned
      if (col.pinned) {
        cell.setAttribute('data-pinned', col.pinned);
      }

      // Resize handle
      if (col.resizable !== false) {
        const handle = document.createElement('div');
        handle.setAttribute('data-resize-handle', '');
        handle.dataset.resizeKey = col.key;
        cell.appendChild(handle);
      }

      // Filter button
      if (col.filter) {
        const filterBtn = document.createElement('button');
        filterBtn.setAttribute('data-filter-btn', '');
        filterBtn.dataset.filterKey = col.key;
        filterBtn.setAttribute('aria-label', `Filter ${col.label || col.key}`);
        const filterIcon = document.createElement('icon-ui');
        filterIcon.setAttribute('name', this.#filters.has(col.key) ? 'funnel-simple-fill' : 'funnel-simple');
        filterIcon.setAttribute('size', 'xs');
        filterBtn.appendChild(filterIcon);
        cell.appendChild(filterBtn);

        // Open filter dropdown
        if (this.#openFilter === col.key) {
          const dropdown = this.#buildFilterDropdown(col);
          cell.appendChild(dropdown);
          cell.setAttribute('data-filter-open', '');
        }
      }

      cells.push(cell);
    }

    // Reconcile header cells
    while (header.children.length > cells.length) header.lastChild.remove();
    for (let i = 0; i < cells.length; i++) {
      if (header.children[i]) header.replaceChild(cells[i], header.children[i]);
      else header.appendChild(cells[i]);
    }
  }

  // ── Row Builders ───────────────────────────────────────────────────────────

  #createRow(dataIndex, visCols) {
    const row = document.createElement('div');
    row.setAttribute('role', 'row');
    this.#updateRow(row, dataIndex, visCols);
    return row;
  }

  #updateRow(row, dataIndex, visCols) {
    const data = this.#data[dataIndex];
    const isSelected = this.#selected.has(dataIndex);
    row.dataset.index = dataIndex;

    if (isSelected) {
      row.setAttribute('data-selected', '');
      row.setAttribute('aria-selected', 'true');
    } else {
      row.removeAttribute('data-selected');
      row.removeAttribute('aria-selected');
    }

    const cells = [];

    // Expand toggle cell
    if (this.expandable) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'gridcell');
      cell.setAttribute('data-expand-col', '');
      const btn = document.createElement('button');
      btn.setAttribute('data-expand-toggle', '');
      btn.setAttribute('aria-label', 'Expand row');
      const icon = document.createElement('icon-ui');
      icon.setAttribute('name', 'caret-right');
      icon.setAttribute('size', 'xs');
      btn.appendChild(icon);
      cell.appendChild(btn);
      cells.push(cell);
    }

    // Checkbox cell
    if (this.selectable) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'gridcell');
      cell.setAttribute('data-check-col', '');
      const check = document.createElement('check-ui');
      if (isSelected) check.setAttribute('checked', '');
      check.setAttribute('aria-label', `Select row ${dataIndex + 1}`);
      cell.appendChild(check);
      cells.push(cell);
    }

    // Data cells
    for (const col of visCols) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'gridcell');
      cell.dataset.key = col.key;

      const value = getCellValue(data, col);

      // Render priority: column.render > column.format > cellType.render > text
      if (typeof col.render === 'function') {
        const result = col.render(value, data, cell, dataIndex);
        if (result instanceof Node) {
          cell.replaceChildren(result);
        } else if (typeof result === 'string') {
          cell.innerHTML = result;
        }
      } else if (typeof col.format === 'function') {
        const t = col.format(value, data);
        cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
        cell.title = t;
      } else {
        const typeDef = cellTypes[col.type || 'text'];
        if (typeDef?.render) {
          typeDef.render(value, data, cell, col.meta);
        } else {
          const t = value != null ? String(value) : '';
          cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
          cell.title = t;
        }
      }

      // Alignment from cell type
      const typeDef = cellTypes[col.type || 'text'];
      if (typeDef?.align) {
        cell.dataset.align = typeDef.align;
      }

      // Pinned
      if (col.pinned) {
        cell.setAttribute('data-pinned', col.pinned);
      }

      // Wrap (per-column opt-in to multi-line cell content)
      if (col.wrap) {
        cell.setAttribute('data-wrap', '');
      }

      cells.push(cell);
    }

    // Reconcile cells in row
    while (row.children.length > cells.length) row.lastChild.remove();
    for (let i = 0; i < cells.length; i++) {
      if (row.children[i]) row.replaceChild(cells[i], row.children[i]);
      else row.appendChild(cells[i]);
    }
  }

  // ── Overlays ───────────────────────────────────────────────────────────────

  #renderOverlays(body) {
    let emptyEl = this.querySelector(':scope > [data-empty]');
    let loadingEl = this.querySelector(':scope > [data-loading]');

    if (this.loading) {
      // Skeleton rows: render N ghost rows of <skeleton-ui> cells inside the
      // body rowgroup. Preserves table layout (header + columns intact) while
      // signalling pending data. aria-busy on host announces the busy state.
      // Old behavior (progress-ui spinner overlay) hid the table layout — the
      // yaml description always promised "skeleton rows", impl shipped overlay
      // (yaml-vs-impl drift fixed in v0.6.18 per FB-12 P2).
      this.setAttribute('aria-busy', 'true');
      // Real rows reconciled above already cleared if data is empty; if data
      // is present, leave it but layer skeletons on top of the body. Simpler:
      // always replace body children with skeleton rows when loading.
      const visCols = this.#visibleColumns;
      const skeletonRowCount = this.paginate > 0 ? Math.min(this.paginate, 8) : 5;
      const totalCellCount =
        (this.expandable ? 1 : 0) +
        (this.selectable ? 1 : 0) +
        visCols.length;

      // Remove all existing body children (real rows + detail rows) — they're
      // replaced by skeleton rows while loading.
      while (body.firstChild) body.firstChild.remove();

      for (let r = 0; r < skeletonRowCount; r++) {
        const row = document.createElement('div');
        row.setAttribute('role', 'row');
        row.setAttribute('data-skeleton-row', '');
        for (let c = 0; c < totalCellCount; c++) {
          const cell = document.createElement('div');
          cell.setAttribute('role', 'gridcell');
          const sk = document.createElement('skeleton-ui');
          // Vary width across cells so the row reads as natural data rows,
          // not a uniform bar. Pattern: 60% / 80% / 70% / 50% / 90%, cycling.
          const widths = ['60%', '80%', '70%', '50%', '90%'];
          sk.setAttribute('width', widths[c % widths.length]);
          sk.setAttribute('height', '1em');
          sk.setAttribute('radius', 'sm');
          cell.appendChild(sk);
          row.appendChild(cell);
        }
        body.appendChild(row);
      }
      // Remove legacy overlay if it lingers from a prior render.
      if (loadingEl) loadingEl.remove();
      if (emptyEl) emptyEl.remove();
    } else if (this.#data.length === 0) {
      // Show empty state
      this.removeAttribute('aria-busy');
      if (!emptyEl) {
        emptyEl = document.createElement('div');
        emptyEl.setAttribute('data-empty', '');
        const icon = document.createElement('icon-ui');
        icon.setAttribute('name', 'table');
        const span = document.createElement('span');
        span.textContent = 'No data';
        emptyEl.appendChild(icon);
        emptyEl.appendChild(span);
        body.after(emptyEl);
      }
      if (loadingEl) loadingEl.remove();
    } else {
      // Remove both overlays
      this.removeAttribute('aria-busy');
      if (emptyEl) emptyEl.remove();
      if (loadingEl) loadingEl.remove();
    }
  }

  // ── Aggregation ────────────────────────────────────────────────────────────

  #renderAggregation(body, processedIndices, visCols) {
    const hasAgg = visCols.some(c => c.aggregate);
    let aggRow = this.querySelector(':scope > [data-agg-row]');

    if (!hasAgg || this.#data.length === 0) {
      if (aggRow) aggRow.remove();
      return;
    }

    if (!aggRow) {
      aggRow = document.createElement('div');
      aggRow.setAttribute('role', 'row');
      aggRow.setAttribute('data-agg-row', '');
      body.after(aggRow);
    }

    const cells = [];

    // Expand column spacer
    if (this.expandable) {
      const spacer = document.createElement('div');
      spacer.setAttribute('role', 'gridcell');
      cells.push(spacer);
    }

    // Checkbox column spacer
    if (this.selectable) {
      const spacer = document.createElement('div');
      spacer.setAttribute('role', 'gridcell');
      cells.push(spacer);
    }

    for (const col of visCols) {
      const cell = document.createElement('div');
      cell.setAttribute('role', 'gridcell');

      if (col.aggregate) {
        const values = processedIndices.map(i => {
          const val = getCellValue(this.#data[i], col);
          return val != null ? Number(val) : NaN;
        }).filter(n => !isNaN(n));

        let result;
        switch (col.aggregate) {
          case 'sum': result = values.reduce((a, b) => a + b, 0); break;
          case 'avg': result = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
          case 'min': result = values.length ? Math.min(...values) : 0; break;
          case 'max': result = values.length ? Math.max(...values) : 0; break;
          case 'count': result = processedIndices.length; break;
          default: result = '';
        }

        // Format using cell type if available
        const typeDef = cellTypes[col.type || 'text'];
        if (typeof result === 'number' && typeDef?.render) {
          typeDef.render(result, {}, cell, col.meta);
        } else {
          cell.textContent = typeof result === 'number' ? result.toLocaleString() : result;
        }
      }

      cells.push(cell);
    }

    // Reconcile cells
    while (aggRow.children.length > cells.length) aggRow.lastChild.remove();
    for (let i = 0; i < cells.length; i++) {
      if (aggRow.children[i]) aggRow.replaceChild(cells[i], aggRow.children[i]);
      else aggRow.appendChild(cells[i]);
    }
  }

  // ── Pagination ─────────────────────────────────────────────────────────────

  #renderPagination(footer, filteredTotal) {
    let pag = footer.querySelector('pagination-ui');
    if (!pag) {
      footer.innerHTML = '';
      pag = document.createElement('pagination-ui');
      pag.addEventListener('page-change', (e) => {
        this.#page = e.detail.page - 1; // pagination-ui is 1-based
        this.render();
        this.dispatchEvent(new CustomEvent('page', {
          bubbles: true,
          detail: { page: this.#page },
        }));
      });
      footer.appendChild(pag);
    }
    const pageCount = this.paginate > 0
      ? Math.max(1, Math.ceil(filteredTotal / this.paginate))
      : 1;
    pag.setAttribute('page', String(this.#page + 1));
    pag.setAttribute('total', String(pageCount));
  }

  // ── Filter UI ──────────────────────────────────────────────────────────────

  #buildFilterDropdown(col) {
    const dropdown = document.createElement('div');
    dropdown.setAttribute('data-filter-dropdown', '');

    const currentFilter = this.#filters.get(col.key);

    if (col.filter === 'select') {
      // Build checkbox list from unique values
      const uniqueVals = [...new Set(this.#data.map(row => {
        const val = getCellValue(row, col);
        return val != null ? String(val) : '';
      }))].filter(Boolean).sort();

      const selectedSet = new Set(
        currentFilter?.op === 'select' ? String(currentFilter.value).split(',') : []
      );

      for (const val of uniqueVals) {
        const label = document.createElement('label');
        label.setAttribute('data-filter-option', '');
        const check = document.createElement('input');
        check.type = 'checkbox';
        check.value = val;
        check.checked = selectedSet.has(val.toLowerCase());
        check.addEventListener('change', () => {
          const checked = [...dropdown.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
          if (checked.length) {
            this.setFilter(col.key, checked.join(','), 'select');
          } else {
            this.setFilter(col.key, null);
          }
        });
        label.appendChild(check);
        label.appendChild(document.createTextNode(` ${val}`));
        dropdown.appendChild(label);
      }
    } else if (col.filter === 'number') {
      const input = document.createElement('input');
      input.type = 'number';
      input.placeholder = 'Filter...';
      input.value = currentFilter?.value ?? '';
      input.setAttribute('data-filter-input', '');

      const opSelect = document.createElement('select');
      opSelect.setAttribute('data-filter-op', '');
      for (const [val, label] of [['gte', '≥'], ['lte', '≤'], ['gt', '>'], ['lt', '<'], ['equals', '=']]) {
        const opt = document.createElement('option');
        opt.value = val;
        opt.textContent = label;
        if (currentFilter?.op === val) opt.selected = true;
        opSelect.appendChild(opt);
      }

      const apply = () => {
        if (input.value) this.setFilter(col.key, input.value, opSelect.value);
        else this.setFilter(col.key, null);
      };
      input.addEventListener('input', apply);
      opSelect.addEventListener('change', apply);

      dropdown.appendChild(opSelect);
      dropdown.appendChild(input);
    } else {
      // Default: text filter
      const input = document.createElement('input');
      input.type = 'text';
      input.placeholder = `Filter ${col.label || col.key}...`;
      input.value = currentFilter?.value ?? '';
      input.setAttribute('data-filter-input', '');
      input.addEventListener('input', () => {
        if (input.value) this.setFilter(col.key, input.value, 'contains');
        else this.setFilter(col.key, null);
      });
      dropdown.appendChild(input);
    }

    // Clear button
    if (currentFilter) {
      const clearBtn = document.createElement('button');
      clearBtn.textContent = 'Clear';
      clearBtn.setAttribute('data-filter-clear', '');
      clearBtn.addEventListener('click', () => {
        this.setFilter(col.key, null);
        this.#openFilter = null;
        this.#requestRender();
      });
      dropdown.appendChild(clearBtn);
    }

    // Auto-focus input
    if (this.#filterFocusRaf != null) cancelAnimationFrame(this.#filterFocusRaf);
    this.#filterFocusRaf = requestAnimationFrame(() => {
      this.#filterFocusRaf = null;
      if (!this.isConnected) return;
      const input = dropdown.querySelector('input');
      if (input) input.focus();
    });

    return dropdown;
  }

  #renderFilterChips() {
    let bar = this.querySelector(':scope > [data-filter-bar]');

    if (this.#filters.size === 0) {
      if (bar) bar.remove();
      return;
    }

    if (!bar) {
      bar = document.createElement('div');
      bar.setAttribute('data-filter-bar', '');
      bar.setAttribute('role', 'status');
      // Insert after header
      const header = this.querySelector(':scope > [data-header]');
      if (header) header.after(bar);
      else this.prepend(bar);
    }

    bar.innerHTML = '';
    for (const [key, { op, value }] of this.#filters) {
      const col = this.#columns.find(c => c.key === key);
      const label = col?.label || key;
      const chip = document.createElement('badge-ui');
      const displayVal = op === 'select' ? `${value.split(',').length} selected` : value;
      chip.setAttribute('text', `${label}: ${displayVal}`);
      chip.setAttribute('size', 'xs');
      chip.setAttribute('data-filter-chip', key);
      // Dismiss button
      const dismiss = document.createElement('button');
      dismiss.textContent = '×';
      dismiss.setAttribute('data-chip-dismiss', '');
      dismiss.addEventListener('click', (e) => {
        e.stopPropagation();
        this.setFilter(key, null);
      });
      chip.appendChild(dismiss);
      bar.appendChild(chip);
    }

    // Clear all button
    const clearAll = document.createElement('button');
    clearAll.textContent = 'Clear all';
    clearAll.setAttribute('data-filter-clear-all', '');
    clearAll.addEventListener('click', () => this.clearFilters());
    bar.appendChild(clearAll);
  }

  // ── Event Handling: Pointerdown (resize-drag start) ───────────────────────

  /** Resize-handle drag MUST start on pointerdown, not click — click fires
   *  only on full down→up gestures (no drag delta), so the document-level
   *  move/up listeners #startResize attaches arrive after the release.
   *  pointer events cover mouse + touch + pen in one handler. */
  #onPointerdown = (e) => {
    if (e.button !== undefined && e.button !== 0) return;  // primary button only
    const handle = e.target.closest('[data-resize-handle]');
    if (handle && this.contains(handle)) {
      e.preventDefault();
      this.#startResize(e, handle);
    }
  };

  // ── Event Handling: Click ──────────────────────────────────────────────────

  #onClick = (e) => {
    const target = e.target;

    // ── Filter button click ──
    const filterBtn = target.closest('[data-filter-btn]');
    if (filterBtn && this.contains(filterBtn)) {
      const key = filterBtn.dataset.filterKey;
      this.#openFilter = this.#openFilter === key ? null : key;
      this.#requestRender();
      return;
    }

    // ── Click outside filter dropdown closes it ──
    if (this.#openFilter && !target.closest('[data-filter-dropdown]') && !target.closest('[data-filter-btn]')) {
      this.#openFilter = null;
      this.#requestRender();
    }

    // ── Expand toggle click ──
    const expandToggle = target.closest('[data-expand-toggle]');
    if (expandToggle && this.expandable && this.contains(expandToggle)) {
      const row = expandToggle.closest('[role="row"]');
      if (row?.dataset?.index != null) {
        this.toggleExpand(Number(row.dataset.index));
      }
      return;
    }

    // ── Resize handle mousedown is handled separately ──
    const resizeHandle = target.closest('[data-resize-handle]');
    if (resizeHandle && this.contains(resizeHandle)) {
      this.#startResize(e, resizeHandle);
      return;
    }

    // ── Header sort click ──
    const headerCell = target.closest('[data-sort-key]');
    if (headerCell && this.sortable && this.contains(headerCell)) {
      const key = headerCell.dataset.sortKey;
      const existing = this.#sortState.findIndex(s => s.key === key);
      const col = this.#columns.find(c => c.key === key);
      const defaultDir = col?.sortDescFirst ? 'desc' : 'asc';
      const altDir = defaultDir === 'asc' ? 'desc' : 'asc';

      if (e.shiftKey) {
        // Multi-sort: add or toggle
        if (existing >= 0) {
          const cur = this.#sortState[existing];
          if (cur.dir === altDir) {
            // Remove from sort state (third click)
            this.#sortState.splice(existing, 1);
          } else {
            cur.dir = cur.dir === defaultDir ? altDir : defaultDir;
          }
        } else {
          this.#sortState.push({ key, dir: defaultDir });
        }
      } else {
        // Single sort: replace
        if (existing >= 0 && this.#sortState.length === 1) {
          const cur = this.#sortState[0];
          if (cur.dir === altDir) {
            this.#sortState = [];
          } else {
            cur.dir = cur.dir === defaultDir ? altDir : defaultDir;
          }
        } else {
          this.#sortState = [{ key, dir: defaultDir }];
        }
      }

      this.render();
      this.dispatchEvent(new CustomEvent('sort', {
        bubbles: true,
        detail: {
          key,
          dir: this.#sortState.find(s => s.key === key)?.dir || null,
          sortState: this.sortState,
        },
      }));
      return;
    }

    // ── Header checkbox (select all) ──
    const headerCheck = target.closest('[data-header] check-ui');
    if (headerCheck && this.selectable) {
      const checked = headerCheck.hasAttribute('checked');
      if (checked) {
        for (let i = 0; i < this.#data.length; i++) this.#selected.add(i);
      } else {
        this.#selected.clear();
      }
      this.render();
      this.dispatchEvent(new CustomEvent('select', {
        bubbles: true, detail: { selected: this.selected },
      }));
      return;
    }

    // ── Row checkbox ──
    const rowCheck = target.closest('[role="row"]:not([data-header]) check-ui');
    if (rowCheck && this.selectable) {
      const row = rowCheck.closest('[role="row"]');
      const idx = row ? +row.dataset.index : -1;
      if (idx >= 0) {
        const checked = rowCheck.hasAttribute('checked');

        if (e.shiftKey && this.#lastSelectedIndex >= 0) {
          // Range select
          const from = Math.min(this.#lastSelectedIndex, idx);
          const to = Math.max(this.#lastSelectedIndex, idx);
          for (let i = from; i <= to; i++) {
            if (checked) this.#selected.add(i);
            else this.#selected.delete(i);
          }
        } else {
          if (checked) this.#selected.add(idx);
          else this.#selected.delete(idx);
        }

        this.#lastSelectedIndex = idx;
        this.render();
        this.dispatchEvent(new CustomEvent('select', {
          bubbles: true, detail: { selected: this.selected },
        }));
      }
      return;
    }

    // ── Cell click ──
    const gridcell = target.closest('[role="gridcell"][data-key]');
    if (gridcell && this.contains(gridcell)) {
      const row = gridcell.closest('[role="row"]');
      const dataIndex = row ? +row.dataset.index : -1;
      if (dataIndex >= 0) {
        const key = gridcell.dataset.key;
        const rowData = this.#data[dataIndex];
        const col = this.#columns.find(c => c.key === key);
        const value = col ? getCellValue(rowData, col) : undefined;
        this.dispatchEvent(new CustomEvent('cell-click', {
          bubbles: true,
          detail: { key, row: rowData, value, dataIndex },
        }));
        // FEEDBACK-35: row-click convenience event — fires once per row,
        // regardless of which cell was hit. detail is a strict subset of
        // cell-click (no key/value) so it reads as row identity, not a cell.
        this.dispatchEvent(new CustomEvent('row-click', {
          bubbles: true,
          detail: { row: rowData, dataIndex },
        }));
      }
    }
  };

  // ── Column Resize ──────────────────────────────────────────────────────────

  #resizeState = null;

  #startResize(e, handle) {
    e.preventDefault();
    e.stopPropagation();

    const key = handle.dataset.resizeKey;
    const col = this.#columns.find(c => c.key === key);
    if (!col) return;

    // Get current column width
    const headerCell = handle.closest('[role="columnheader"]');
    const startWidth = headerCell ? headerCell.getBoundingClientRect().width : 100;
    const startX = e.clientX;

    this.setAttribute('data-resizing', '');

    this.#resizeState = { key, col, startX, startWidth };

    document.addEventListener('pointermove', this.#onResizeMove);
    document.addEventListener('pointerup', this.#onResizeEnd);
  }

  #onResizeMove = (e) => {
    if (!this.#resizeState) return;
    const { key, col, startX, startWidth } = this.#resizeState;
    const delta = e.clientX - startX;
    let newWidth = startWidth + delta;

    // Clamp to min/max
    const min = col.minWidth || 48;
    const max = col.maxWidth || Infinity;
    newWidth = Math.max(min, Math.min(max, newWidth));

    this.#columnWidths.set(key, Math.round(newWidth));
    this.style.gridTemplateColumns = this.#buildGridTemplate();
  };

  #onResizeEnd = (e) => {
    if (!this.#resizeState) return;
    const { key } = this.#resizeState;

    document.removeEventListener('pointermove', this.#onResizeMove);
    document.removeEventListener('pointerup', this.#onResizeEnd);

    this.removeAttribute('data-resizing');

    const width = this.#columnWidths.get(key);
    this.#resizeState = null;

    this.dispatchEvent(new CustomEvent('resize', {
      bubbles: true,
      detail: { key, width },
    }));

    this.render();
  };

  #cleanupResize() {
    document.removeEventListener('pointermove', this.#onResizeMove);
    document.removeEventListener('pointerup', this.#onResizeEnd);
    this.#resizeState = null;
  }

  // ── Keyboard Navigation ────────────────────────────────────────────────────

  #onKeydown = (e) => {
    const visCols = this.#visibleColumns;
    const totalCols = visCols.length + (this.selectable ? 1 : 0);
    const body = this.querySelector(':scope > [data-body]');
    const totalRows = body ? body.children.length : 0;

    // Include header as row -1 conceptually, body rows 0..n-1
    if (!this.#focusedCell) {
      this.#focusedCell = { row: 0, col: 0 };
    }

    let { row, col } = this.#focusedCell;
    let handled = true;

    switch (e.key) {
      case 'ArrowDown':
        row = Math.min(row + 1, totalRows - 1);
        break;
      case 'ArrowUp':
        row = Math.max(row - 1, -1); // -1 = header
        break;
      case 'ArrowRight':
        col = Math.min(col + 1, totalCols - 1);
        break;
      case 'ArrowLeft':
        col = Math.max(col - 1, 0);
        break;
      case 'Tab':
        if (e.shiftKey) {
          col--;
          if (col < 0) { col = totalCols - 1; row--; }
        } else {
          col++;
          if (col >= totalCols) { col = 0; row++; }
        }
        if (row < -1 || row >= totalRows) { handled = false; break; }
        break;
      case 'Enter':
        if (row === -1) {
          // Header row: trigger sort via the existing click path
          const header = this.querySelector(':scope > [data-header]');
          const cell = header?.children[col];
          const sortKey = cell?.dataset.sortKey;
          if (sortKey) cell.click();
        } else {
          // Body row: activate focused cell (WAI-ARIA Grid Pattern §6.2.2).
          // Delegates to the existing click → cell-click → row-click pipeline
          // so consumers get the same event regardless of mouse vs keyboard.
          const focused = this.querySelector('[data-focused]');
          if (focused) focused.click();
        }
        break;
      default:
        handled = false;
    }

    if (handled) {
      e.preventDefault();
      this.#focusedCell = { row, col };
      this.#updateFocus();
    }
  };

  #updateFocus() {
    // Remove old focus
    const oldFocused = this.querySelector('[data-focused]');
    if (oldFocused) oldFocused.removeAttribute('data-focused');

    if (!this.#focusedCell) return;
    const { row, col } = this.#focusedCell;

    let cell;
    if (row === -1) {
      // Header
      const header = this.querySelector(':scope > [data-header]');
      cell = header?.children[col];
    } else {
      // Body
      const body = this.querySelector(':scope > [data-body]');
      const rowEl = body?.children[row];
      cell = rowEl?.children[col];
    }

    if (cell) {
      cell.setAttribute('data-focused', '');
      cell.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
    }
  }

  // ── CSV Export ─────────────────────────────────────────────────────────────

  exportCSV(filename = 'export.csv') {
    const visCols = this.#visibleColumns;
    const processed = this.#getProcessedIndices();

    // Header row
    const headerRow = visCols.map(c => csvEscape(c.label || c.key));

    // Data rows
    const dataRows = processed.map(idx => {
      const row = this.#data[idx];
      return visCols.map(col => {
        const value = getCellValue(row, col);

        // Use column format, then cell type format, then String
        if (typeof col.format === 'function') {
          return csvEscape(col.format(value, row));
        }
        const typeDef = cellTypes[col.type || 'text'];
        if (typeDef?.format) {
          return csvEscape(typeDef.format(value, row, col.meta));
        }
        return csvEscape(value);
      });
    });

    const csv = [headerRow.join(','), ...dataRows.map(r => r.join(','))].join('\n');

    // Trigger download
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.style.display = 'none';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
  }
}
