// Copyright (c) 2026 Modelware. All rights reserved.

import { QueryMarkdownBlockRenderer, type TableBlockKind } from './renderer.js';
import type { MdBlockExecutionResult, MdTableCell } from './types.js';
import { shortLabelFromIri } from './wikilink-utils.js';

type TableEditorActionKind = 'add' | 'remove' | 'validate' | 'link' | 'undo' | 'redo' | 'ai' | 'properties';
type TableEditorActionContext = {
    action: TableEditorActionKind;
    root: HTMLElement;
    columns: string[];
    rows: Array<{ index: number; cells: string[] }>;
    row?: { index: number; cells: string[] };
    propertyDefinitions?: Array<{
        name: string;
        predicate: string;
        multiple: boolean;
        datatypeIri?: string;
        classIri?: string;
        nodeKind?: string;
        editor?: 'multiline';
    }>;
    blockSource?: string;
    typeOptions?: string[];
    instanceNamePrefix?: string;
    pointerX?: number;
    pointerY?: number;
};

type TableRootWithAiContext = HTMLDivElement & {
    __omlAiContext?: {
        columns: string[];
        rows: Array<{ iri: string; cells: string[] }>;
        blockSource?: string;
    };
};

type PersistedTableUiState = {
    search: string;
    pageSize: number;
    page: number;
    sortKey: string;
    sortDir: 'asc' | 'desc';
    hasUserSort: boolean;
};

type TableRendererWindow = Window & typeof globalThis & {
    __omlGetPersistedTableUiState?: (blockId: string) => Partial<PersistedTableUiState> | undefined;
    __omlSetPersistedTableUiState?: (blockId: string, state: PersistedTableUiState) => void;
};

export class TableMarkdownBlockRenderer extends QueryMarkdownBlockRenderer {
    protected readonly tableKinds: ReadonlyArray<TableBlockKind> = ['table'];

    canRender(result: MdBlockExecutionResult): boolean {
        return result.status === 'ok'
            && result.format === 'table'
            && !!result.payload
            && this.tableKinds.includes(result.kind as TableBlockKind);
    }

    render(result: MdBlockExecutionResult): HTMLElement {
        const container = this.createResultContainer(result.status);
        container.classList.add('oml-md-result-table');
        const stylesheet = compileTableStylesheet(result.options);
        const tableRoot = this.renderInteractiveTable(
            result.payload?.columns ?? [],
            result.payload?.rows ?? [],
            result.payload?.rowsTyped,
            stylesheet,
            result.options,
            result.blockSource,
            result.blockId
        );
        if (typeof result.blockId === 'string' && result.blockId.length > 0) {
            tableRoot.dataset.tableBlockId = result.blockId;
        }
        container.appendChild(tableRoot);
        return container;
    }

    protected appendCustomLeftControls(
        _leftControls: HTMLDivElement,
        _context: Omit<TableEditorActionContext, 'action'> & { options?: Record<string, unknown> }
    ): void {
        // Default tables do not add extra controls.
    }

    protected decorateCellValueElement(params: {
        row: { index: number; cells: string[] };
        columnIndex: number;
        columns: string[];
        value: string;
        valueElement: HTMLElement;
    }): HTMLElement {
        return params.valueElement;
    }

    protected shouldIgnoreRowDoubleClick(_event: MouseEvent): boolean {
        return false;
    }

    protected onRowDoubleClick(_context: Omit<TableEditorActionContext, 'action'>): void {
        // Default tables do not define row double-click behavior.
    }

    protected dispatchTableEditorAction(context: TableEditorActionContext): void {
        const event = new CustomEvent('oml-table-editor-action', { detail: context });
        window.dispatchEvent(event);
    }

    protected renderInteractiveTable(
        columns: string[],
        rows: string[][],
        rowsTyped: MdTableCell[][] | undefined,
        stylesheet: CompiledTableStyleRule[],
        options: Record<string, unknown> | undefined,
        blockSource?: string,
        blockId?: string
    ): HTMLElement {
        const root = document.createElement('div') as TableRootWithAiContext;
        root.className = 'table-root graph-root';
        const isTree = this.tableKinds.includes('tree');
        const allRowsWithIndex = rows.map((cells, index) => ({
            index,
            cells,
            typedCells: rowsTyped?.[index],
        }));
        const containmentColumns = isTree ? resolveContainmentColumns(columns, options) : [];
        const baseVisibleColumnIndexes = isTree
            ? columns.map((_, index) => index).filter((index) => !containmentColumns.includes(index))
            : columns.map((_, index) => index);
        const baseVisibleColumns = baseVisibleColumnIndexes.map((index) => columns[index]);
        const baseRowsWithIndex = allRowsWithIndex.map((row) => ({
            ...row,
            cells: baseVisibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
            typedCells: baseVisibleColumnIndexes.map((columnIndex) => row.typedCells?.[columnIndex]),
        }));
        const selectorRowsByIndex = new Map(baseRowsWithIndex.map((row) => [row.index, row] as const));
        const baseColumnContexts = createColumnContexts(baseVisibleColumns, baseRowsWithIndex);
        const hiddenColumns = resolveHiddenColumns(baseVisibleColumns, baseColumnContexts, stylesheet);
        const visibleColumnIndexes = baseVisibleColumnIndexes.filter((_, index) => !hiddenColumns.has(baseVisibleColumns[index]));
        const visibleColumns = visibleColumnIndexes.map((index) => columns[index]);
        const rowsWithIndex = allRowsWithIndex.map((row) => ({
            ...row,
            cells: visibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
            typedCells: visibleColumnIndexes.map((columnIndex) => row.typedCells?.[columnIndex]),
        }));
        const dedupeVisibleRows = hiddenColumns.size > 0 && !isTree;
        const visibleRows = dedupeVisibleRows ? dedupeRowsByDisplayedCells(rowsWithIndex) : rowsWithIndex;
        root.__omlAiContext = {
            columns: visibleColumns.slice(),
            rows: visibleRows
                .map((row) => ({
                    iri: (row.cells[0] ?? '').trim(),
                    cells: row.cells.slice(),
                }))
                .filter((row) => row.iri.length > 0),
            blockSource,
        };
        const columnContexts = createColumnContexts(visibleColumns, visibleRows);
        const treeModel = isTree
            ? this.createTreeModel(columns, allRowsWithIndex, visibleColumnIndexes, options)
            : undefined;
        const fullyExpandedTreeRows = isTree && treeModel
            ? this.flattenAllTreeRows(treeModel)
            : undefined;
        const persistedState = blockId
            ? (window as TableRendererWindow).__omlGetPersistedTableUiState?.(blockId)
            : undefined;

        const state = {
            search: persistedState?.search ?? '',
            pageSize: persistedState?.pageSize ?? 50,
            page: persistedState?.page ?? 0,
            sortKey: persistedState?.sortKey ?? '' as string,
            sortDir: persistedState?.sortDir ?? 'asc' as 'asc' | 'desc',
            hasUserSort: persistedState?.hasUserSort ?? false,
            columnWidths: new Array<number | undefined>(columns.length).fill(undefined),
            initialAutosizeApplied: false,
            expanded: new Set<string>(treeModel?.expandableNodes ?? []),
        };

        const persistUiState = () => {
            if (!blockId) {
                return;
            }
            (window as TableRendererWindow).__omlSetPersistedTableUiState?.(blockId, {
                search: state.search,
                pageSize: state.pageSize,
                page: state.page,
                sortKey: state.sortKey,
                sortDir: state.sortDir,
                hasUserSort: state.hasUserSort,
            });
        };

        const controls = document.createElement('div');
        controls.className = 'table-controls';

        const controlsLeft = document.createElement('div');
        controlsLeft.className = 'table-controls-left';
        const pageSize = document.createElement('select');
        pageSize.className = 'table-pagesize';
        for (const size of [10, 25, 50, 100]) {
            const option = document.createElement('option');
            option.value = String(size);
            option.textContent = `Show ${size}`;
            if (size === state.pageSize) {
                option.selected = true;
            }
            pageSize.appendChild(option);
        }
        pageSize.value = String(state.pageSize);
        pageSize.addEventListener('change', () => {
            state.pageSize = Number.parseInt(pageSize.value, 10);
            state.page = 0;
            persistUiState();
            renderPage();
        });
        if (!isTree) {
            controlsLeft.appendChild(pageSize);
        } else if (treeModel) {
            const expandAllButton = this.createTreeActionButton(
                'Expand all',
                'Expand all',
                'M7 7h10v2H7zM7 11h10v2H7zM7 15h10v2H7zM3 11h2V9h2V7H5V5H3v2H1v2h2z'
            );
            expandAllButton.addEventListener('click', () => {
                state.expanded = new Set<string>(treeModel.expandableNodes);
                renderPage();
            });
            controlsLeft.appendChild(expandAllButton);

            const collapseAllButton = this.createTreeActionButton(
                'Collapse all',
                'Collapse all',
                'M7 7h10v2H7zM7 11h10v2H7zM7 15h10v2H7zM1 7h6v2H1z'
            );
            collapseAllButton.addEventListener('click', () => {
                state.expanded = new Set<string>();
                renderPage();
            });
            controlsLeft.appendChild(collapseAllButton);
        }

        const controlsRight = document.createElement('div');
        controlsRight.className = 'table-controls-right';
        const searchInput = document.createElement('input');
        searchInput.type = 'search';
        searchInput.className = 'tree-filter';
        searchInput.placeholder = 'Filter...';
        searchInput.value = state.search;
        searchInput.addEventListener('input', () => {
            state.search = searchInput.value.trim().toLowerCase();
            state.page = 0;
            persistUiState();
            renderPage();
        });
        controlsRight.appendChild(searchInput);

        const downloadButton = document.createElement('button');
        downloadButton.className = 'tree-download-btn';
        downloadButton.title = 'Download CSV';
        downloadButton.setAttribute('aria-label', 'Download CSV');
        const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        iconSvg.setAttribute('viewBox', '0 0 24 24');
        iconSvg.setAttribute('width', '20');
        iconSvg.setAttribute('height', '20');
        iconSvg.setAttribute('aria-hidden', 'true');
        iconSvg.setAttribute('focusable', 'false');
        iconSvg.style.fill = 'currentColor';
        const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        iconPath.setAttribute('d', 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z');
        iconSvg.appendChild(iconPath);
        downloadButton.appendChild(iconSvg);
        downloadButton.addEventListener('click', () => {
            const sourceRows = isTree && treeModel
                ? this.resolveTreeSourceRows(treeModel, visibleColumns, state.expanded, state.search, blockSource, options)
                : visibleRows;
            if (isTree && treeModel) {
                this.requestTreeJsonDownload(visibleColumns, treeModel, sourceRows);
                return;
            }
            const filtered = this.applyFiltersAndSorting(visibleColumns, sourceRows, state, blockSource, options);
            const csvExport = this.transformCsvDownloadData(visibleColumns, filtered.map((entry) => entry.cells));
            this.requestCsvDownload(csvExport.columns, csvExport.rows);
        });
        controlsRight.appendChild(downloadButton);

        const uploadButton = this.createUploadCsvButton(visibleColumns, visibleRows, isTree, blockSource);
        if (uploadButton) {
            controlsRight.appendChild(uploadButton);
        }

        controls.appendChild(controlsLeft);
        controls.appendChild(controlsRight);
        root.appendChild(controls);

        const wrapper = document.createElement('div');
        wrapper.className = 'table-results-table-container hover-highlight';
        const table = document.createElement('div');
        table.className = 'table-results-table';
        const headerRow = document.createElement('div');
        headerRow.className = 'table-header-row';
        const body = document.createElement('div');
        body.className = 'table-body';
        wrapper.appendChild(table);
        root.appendChild(wrapper);

        const footer = document.createElement('div');
        footer.className = 'table-footer';
        const info = document.createElement('div');
        info.className = 'table-info-bar';
        const footerPagination = document.createElement('div');
        footerPagination.className = 'table-footer-pagination';
        const pager = document.createElement('div');
        pager.className = 'table-pagination';
        const prev = document.createElement('button');
        prev.className = 'table-page-btn';
        prev.textContent = '<';
        const next = document.createElement('button');
        next.className = 'table-page-btn';
        next.textContent = '>';
        const pageLabel = document.createElement('span');
        pageLabel.className = 'table-page-label';
        prev.addEventListener('click', () => {
            if (isTree) {
                return;
            }
            if (state.page > 0) {
                state.page -= 1;
                persistUiState();
                renderPage();
            }
        });
        next.addEventListener('click', () => {
            if (isTree) {
                return;
            }
            const filtered = this.applyFiltersAndSorting(visibleColumns, visibleRows, state, blockSource, options);
            const totalPages = Math.max(1, Math.ceil(filtered.length / state.pageSize));
            if (state.page < totalPages - 1) {
                state.page += 1;
                persistUiState();
                renderPage();
            }
        });
        pager.appendChild(prev);
        pager.appendChild(pageLabel);
        pager.appendChild(next);
        if (!isTree) {
            footerPagination.appendChild(pager);
        }
        footer.appendChild(info);
        if (!isTree) {
            footer.appendChild(footerPagination);
        }
        root.appendChild(footer);

        const renderPage = () => {
            const sourceRows = isTree && treeModel
                ? this.resolveTreeSourceRows(treeModel, visibleColumns, state.expanded, state.search, blockSource, options)
                : visibleRows;
            const filtered = isTree
                ? sourceRows.slice()
                : this.applyFiltersAndSorting(visibleColumns, sourceRows, state, blockSource, options);
            const total = filtered.length;
            const totalTreeRows = isTree
                ? (fullyExpandedTreeRows?.length ?? total)
                : total;
            const totalPages = isTree ? 1 : Math.max(1, Math.ceil(total / state.pageSize));
            if (!isTree && state.page >= totalPages) {
                state.page = totalPages - 1;
                persistUiState();
            }
            const gridTemplateColumns = this.buildGridTemplate(visibleColumns.length, state.columnWidths);

            headerRow.innerHTML = '';
            headerRow.style.gridTemplateColumns = gridTemplateColumns;
            for (let columnIndex = 0; columnIndex < visibleColumns.length; columnIndex += 1) {
                const column = visibleColumns[columnIndex];
                const th = document.createElement('div');
                th.className = 'table-header-cell';
                const sorted = !isTree && state.hasUserSort && state.sortKey === column;
                const indicator = sorted ? (state.sortDir === 'asc' ? ' ▲' : ' ▼') : '';
                const label = document.createElement('span');
                label.className = 'table-header-label';
                label.textContent = `${column}${indicator}`;
                th.appendChild(label);
                const headerContext = createHeaderContext(column);
                this.applyStylesheet(th, undefined, stylesheet, [{ kind: 'header', context: headerContext }]);
                if (!isTree) {
                    th.addEventListener('click', () => {
                        if (state.sortKey === column) {
                            state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
                        } else {
                            state.sortKey = column;
                            state.sortDir = 'asc';
                        }
                        state.hasUserSort = true;
                        state.page = 0;
                        persistUiState();
                        renderPage();
                    });
                }
                this.installColumnResizeHandle({
                    handleHost: th,
                    columnIndex,
                    state,
                    root,
                    columns: visibleColumns,
                    rows: fullyExpandedTreeRows ?? visibleRows,
                    referenceCell: label,
                    rerender: renderPage,
                });
                headerRow.appendChild(th);
            }

            body.innerHTML = '';
            const start = isTree ? 0 : state.page * state.pageSize;
            const pageRows = isTree ? filtered : filtered.slice(start, start + state.pageSize);
            for (const rowEntry of pageRows) {
                const tr = document.createElement('div');
                tr.className = 'table-row';
                tr.style.gridTemplateColumns = gridTemplateColumns;
                const selectorRow = selectorRowsByIndex.get(rowEntry.index) ?? rowEntry;
                const rowContext = createRowContext(baseVisibleColumns, selectorRow);
                for (let columnIndex = 0; columnIndex < visibleColumns.length; columnIndex += 1) {
                    const value = rowEntry.cells[columnIndex] ?? '';
                    const typedValue = rowEntry.typedCells?.[columnIndex];
                    const td = document.createElement('div');
                    td.className = 'table-cell';
                    const valueElement = this.renderValue(this.formatCellDisplayValue(value, {
                        columnIndex,
                        columns: visibleColumns,
                        blockSource,
                        options,
                    }), typedValue);
                    if (isTree && columnIndex === 0) {
                        const treeCell = this.renderTreeCell(valueElement, rowEntry, treeModel, state.expanded, () => renderPage());
                        td.appendChild(treeCell);
                    } else {
                        td.appendChild(this.decorateCellValueElement({
                            row: rowEntry,
                            columnIndex,
                            columns: visibleColumns,
                            value,
                            valueElement,
                        }));
                    }
                    const cellContext = createCellContext(rowContext, visibleColumns[columnIndex], value);
                    const columnContext = columnContexts[columnIndex];
                    this.applyStylesheet(td, valueElement, stylesheet, [
                        { kind: 'row', context: rowContext },
                        { kind: 'column', context: columnContext },
                        { kind: 'cell', context: cellContext },
                    ]);
                    tr.appendChild(td);
                }
                tr.addEventListener('dblclick', (event) => {
                    if (this.shouldIgnoreRowDoubleClick(event)) {
                        return;
                    }
                    this.onRowDoubleClick({
                        root,
                        columns: visibleColumns,
                        rows: visibleRows.map((row) => ({ index: row.index, cells: row.cells.slice() })),
                        row: { index: rowEntry.index, cells: rowEntry.cells.slice() },
                        blockSource,
                        typeOptions: this.extractTypeOptions(blockSource),
                    });
                });
                body.appendChild(tr);
            }
            table.replaceChildren(headerRow, body);

            info.textContent = `Showing ${pageRows.length} of ${isTree ? totalTreeRows : total} entries`;
            if (!isTree) {
                pageLabel.textContent = `${state.page + 1}/${totalPages}`;
                prev.disabled = state.page === 0;
                next.disabled = state.page >= totalPages - 1;
            }
        };

        this.appendCustomLeftControls(controlsLeft, {
            root,
            columns: visibleColumns,
            rows: visibleRows.map((row) => ({ index: row.index, cells: row.cells.slice() })),
            blockSource,
            options,
            typeOptions: this.extractTypeOptions(blockSource),
        });

        renderPage();
        this.scheduleInitialAutosize({
            root,
            columns: visibleColumns,
            rows: fullyExpandedTreeRows ?? visibleRows,
            state,
            rerender: renderPage,
        });
        return root;
    }

    protected extractTypeOptions(_blockSource: string | undefined): string[] {
        return [];
    }

    private resolveTreeSourceRows(
        model: TreeModel,
        columns: string[],
        expanded: ReadonlySet<string>,
        search: string,
        blockSource?: string,
        options?: Record<string, unknown>
    ): TableRowData[] {
        if (search) {
            return this.filterTreeRowsBySearch(model, columns, search, blockSource, options);
        }
        return this.flattenTreeRows(model, expanded);
    }

    private filterTreeRowsBySearch(
        model: TreeModel,
        columns: string[],
        search: string,
        blockSource?: string,
        options?: Record<string, unknown>
    ): TableRowData[] {
        const fullyExpanded = this.flattenTreeRows(model, new Set<string>(model.expandableNodes));
        const matchedIds = new Set<string>();
        for (const row of fullyExpanded) {
            const nodeId = row.treeNodeId ?? '';
            if (!nodeId) {
                continue;
            }
            if (matchesFilterQuery(columns, this.getDisplayFilterCells(row, columns, blockSource, options), search)) {
                matchedIds.add(nodeId);
            }
        }
        if (matchedIds.size === 0) {
            return [];
        }

        const includeIds = new Set<string>(matchedIds);
        const stack = Array.from(matchedIds);
        while (stack.length > 0) {
            const childId = stack.pop();
            if (!childId) {
                continue;
            }
            const parents = model.parentsByChild.get(childId) ?? [];
            for (const parentId of parents) {
                if (includeIds.has(parentId)) {
                    continue;
                }
                includeIds.add(parentId);
                stack.push(parentId);
            }
        }

        return fullyExpanded.filter((row) => {
            const nodeId = row.treeNodeId ?? '';
            return !!nodeId && includeIds.has(nodeId);
        });
    }

    private createTreeModel(
        allColumns: string[],
        allRows: TableRowData[],
        visibleColumnIndexes: number[],
        options: Record<string, unknown> | undefined
    ): TreeModel {
        const idColumnIndex = 0;
        const containmentColumns = resolveContainmentColumns(allColumns, options);
        const rowsById = new Map<string, TableRowData>();
        const order: string[] = [];
        const projectRow = (row: TableRowData): TableRowData => ({
            ...row,
            cells: visibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
            typedCells: visibleColumnIndexes.map((columnIndex) => row.typedCells?.[columnIndex]),
        });
        for (const row of allRows) {
            const id = row.cells[idColumnIndex] ?? '';
            if (!id) {
                continue;
            }
            if (!rowsById.has(id)) {
                rowsById.set(id, projectRow(row));
                order.push(id);
            }
        }

        const childrenByParent = new Map<string, string[]>();
        const parentsByChild = new Map<string, string[]>();
        const parentCountByChild = new Map<string, number>();
        const pushChild = (parent: string, child: string) => {
            if (!parent || !child) {
                return;
            }
            const existing = childrenByParent.get(parent) ?? [];
            if (!existing.includes(child)) {
                existing.push(child);
                childrenByParent.set(parent, existing);
                const parents = parentsByChild.get(child) ?? [];
                if (!parents.includes(parent)) {
                    parents.push(parent);
                    parentsByChild.set(child, parents);
                }
                parentCountByChild.set(child, (parentCountByChild.get(child) ?? 0) + 1);
            }
        };

        for (const row of allRows) {
            const parent = row.cells[idColumnIndex] ?? '';
            if (!parent) {
                continue;
            }
            for (const columnIndex of containmentColumns) {
                const raw = row.cells[columnIndex] ?? '';
                const children = raw.split(/[\s,]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
                for (const child of children) {
                    pushChild(parent, child);
                    if (!rowsById.has(child)) {
                        const syntheticAll = new Array<string>(allColumns.length).fill('');
                        syntheticAll[idColumnIndex] = child;
                        const synthetic = visibleColumnIndexes.map((columnIndex) => syntheticAll[columnIndex] ?? '');
                        const syntheticRow: TableRowData = { index: allRows.length + rowsById.size, cells: synthetic, typedCells: new Array(visibleColumnIndexes.length).fill(undefined) };
                        rowsById.set(child, syntheticRow);
                        order.push(child);
                    }
                }
            }
        }

        const roots = order.filter((id) => (parentCountByChild.get(id) ?? 0) === 0);
        return {
            rowsById,
            childrenByParent,
            parentsByChild,
            roots: roots.length > 0 ? roots : order.slice(),
            expandableNodes: Array.from(childrenByParent.keys()),
        };
    }

    private flattenTreeRows(model: TreeModel, expanded: ReadonlySet<string>): TableRowData[] {
        const flattened: TableRowData[] = [];
        const visit = (id: string, depth: number, path: Set<string>) => {
            const row = model.rowsById.get(id);
            if (!row) {
                return;
            }
            const children = model.childrenByParent.get(id) ?? [];
            flattened.push({
                ...row,
                treeNodeId: id,
                treeDepth: depth,
                treeHasChildren: children.length > 0,
            });
            if (!expanded.has(id)) {
                return;
            }
            const nextPath = new Set(path);
            nextPath.add(id);
            for (const child of children) {
                if (nextPath.has(child)) {
                    continue;
                }
                visit(child, depth + 1, nextPath);
            }
        };
        for (const rootId of model.roots) {
            visit(rootId, 0, new Set<string>());
        }
        return flattened;
    }

    private flattenAllTreeRows(model: TreeModel): TableRowData[] {
        const flattened: TableRowData[] = [];
        const visit = (id: string, depth: number, path: Set<string>) => {
            const row = model.rowsById.get(id);
            if (!row) {
                return;
            }
            const children = model.childrenByParent.get(id) ?? [];
            flattened.push({
                ...row,
                treeNodeId: id,
                treeDepth: depth,
                treeHasChildren: children.length > 0,
            });
            const nextPath = new Set(path);
            nextPath.add(id);
            for (const child of children) {
                if (nextPath.has(child)) {
                    continue;
                }
                visit(child, depth + 1, nextPath);
            }
        };
        for (const rootId of model.roots) {
            visit(rootId, 0, new Set<string>());
        }
        return flattened;
    }

    private renderTreeCell(
        valueElement: HTMLElement,
        row: TableRowData,
        model: TreeModel | undefined,
        expanded: Set<string>,
        rerender: () => void
    ): HTMLElement {
        const wrapper = document.createElement('div');
        wrapper.style.display = 'flex';
        wrapper.style.alignItems = 'center';
        wrapper.style.gap = '0.35rem';
        const depth = row.treeDepth ?? 0;
        wrapper.style.paddingLeft = `${Math.max(0, depth) * 16}px`;
        const nodeId = row.treeNodeId ?? '';
        const hasChildren = !!row.treeHasChildren && !!nodeId && !!model;
        if (hasChildren) {
            const toggle = document.createElement('button');
            toggle.type = 'button';
            toggle.className = 'tree-toggle-button';
            toggle.textContent = expanded.has(nodeId) ? '-' : '+';
            toggle.style.width = '1rem';
            toggle.style.height = '1rem';
            toggle.style.padding = '0';
            toggle.style.lineHeight = '1';
            toggle.style.fontSize = '0.85rem';
            toggle.style.border = '1px solid var(--vscode-editorWidget-border)';
            toggle.style.background = 'var(--vscode-editor-background)';
            toggle.style.color = 'var(--vscode-foreground)';
            toggle.style.cursor = 'pointer';
            toggle.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                if (expanded.has(nodeId)) {
                    expanded.delete(nodeId);
                } else {
                    expanded.add(nodeId);
                }
                rerender();
            });
            wrapper.appendChild(toggle);
        } else {
            const spacer = document.createElement('span');
            spacer.style.display = 'inline-block';
            spacer.style.width = '1rem';
            wrapper.appendChild(spacer);
        }
        wrapper.appendChild(valueElement);
        return wrapper;
    }

    private createTreeActionButton(title: string, ariaLabel: string, iconPath: string): HTMLButtonElement {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'tree-download-btn';
        button.title = title;
        button.setAttribute('aria-label', ariaLabel);
        const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        iconSvg.setAttribute('viewBox', '0 0 24 24');
        iconSvg.setAttribute('width', '20');
        iconSvg.setAttribute('height', '20');
        iconSvg.setAttribute('aria-hidden', 'true');
        iconSvg.setAttribute('focusable', 'false');
        iconSvg.style.fill = 'currentColor';
        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', iconPath);
        iconSvg.appendChild(path);
        button.appendChild(iconSvg);
        return button;
    }

    private applyFiltersAndSorting(
        columns: string[],
        rows: TableRowData[],
        state: { search: string; sortKey: string; sortDir: 'asc' | 'desc'; hasUserSort: boolean },
        blockSource?: string,
        options?: Record<string, unknown>
    ): TableRowData[] {
        const filtered = state.search
            ? rows.filter((row) => matchesFilterQuery(
                columns,
                this.getDisplayFilterCells(row, columns, blockSource, options),
                state.search
            ))
            : rows.slice();
        if (!state.hasUserSort) {
            return filtered;
        }
        const columnIndex = columns.indexOf(state.sortKey);
        if (columnIndex < 0) {
            return filtered;
        }
        const dir = state.sortDir === 'asc' ? 1 : -1;
        return filtered.sort((left, right) => {
            const a = left.cells[columnIndex] ?? '';
            const b = right.cells[columnIndex] ?? '';
            return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * dir;
        });
    }

    private getDisplayFilterCells(
        row: TableRowData,
        columns: string[],
        blockSource?: string,
        options?: Record<string, unknown>
    ): string[] {
        return columns.map((_, columnIndex) => {
            const raw = row.cells[columnIndex] ?? '';
            const typedValue = row.typedCells?.[columnIndex];
            if (typedValue?.values?.length) {
                return this.formatCellDisplayText(raw, typedValue);
            }
            return this.formatCellDisplayValue(raw, {
                columnIndex,
                columns,
                blockSource,
                options,
            });
        });
    }

    private applyStylesheet(
        cellElement: HTMLElement,
        valueElement: HTMLElement | undefined,
        rules: ReadonlyArray<CompiledTableStyleRule>,
        contexts: ReadonlyArray<SelectorContext>
    ): void {
        for (const rule of rules) {
            if (rule.target !== 'value') {
                if (!this.matchesRule(rule, contexts)) {
                    continue;
                }
                for (const [property, cssValue] of Object.entries(rule.style)) {
                    cellElement.style.setProperty(property, cssValue);
                }
                continue;
            }

            if (!valueElement) {
                continue;
            }
            const valueTargets = this.resolveValueTargets(valueElement);
            for (const target of valueTargets) {
                const valueOverride = target.dataset.value ?? target.textContent ?? '';
                if (!this.matchesRule(rule, contexts, valueOverride)) {
                    continue;
                }
                for (const [property, cssValue] of Object.entries(rule.style)) {
                    target.style.setProperty(property, cssValue);
                }
            }
        }
    }

    private matchesRule(
        rule: CompiledTableStyleRule,
        contexts: ReadonlyArray<SelectorContext>,
        cellValueOverride?: string
    ): boolean {
        return rule.selectors.some((selector) => {
            const contextEntry = contexts.find((entry) => entry.kind === selector.kind);
            if (!contextEntry) {
                return false;
            }
            if (selector.kind !== 'cell' || cellValueOverride === undefined) {
                return evaluateSelectorCondition(selector.condition, contextEntry.context);
            }
            return evaluateSelectorCondition(selector.condition, {
                ...contextEntry.context,
                value: cellValueOverride,
            });
        });
    }

    private resolveValueTargets(valueElement: HTMLElement): HTMLElement[] {
        const parts = Array.from(valueElement.querySelectorAll<HTMLElement>('.table-value-part'));
        if (parts.length > 0) {
            return parts;
        }
        return [valueElement];
    }

    private buildGridTemplate(columnCount: number, columnWidths: ReadonlyArray<number | undefined>): string {
        if (columnCount <= 0) {
            return 'minmax(0, 1fr)';
        }
        const lastColumnIndex = columnCount - 1;
        return columnWidths
            .slice(0, columnCount)
            .map((width, columnIndex) => {
                if (columnIndex === lastColumnIndex) {
                    if (width && width > 0) {
                        return `minmax(${Math.round(width)}px, 1fr)`;
                    }
                    return 'minmax(0, 1fr)';
                }
                return width && width > 0 ? `${Math.round(width)}px` : 'minmax(0, 1fr)';
            })
            .join(' ');
    }

    private createInitialColumnWidths(columns: string[], rows: TableRowData[], root: HTMLElement): Array<number | undefined> {
        if (columns.length === 0) {
            return [];
        }
        const minWidth = 72;
        const valueReference = root.querySelector<HTMLElement>('.table-cell') ?? root;
        const headerReference = root.querySelector<HTMLElement>('.table-header-label') ?? root;
        const valueFont = this.resolveFont(valueReference);
        const headerFont = this.resolveFont(headerReference);
        return columns.map((_, columnIndex) => Math.max(minWidth, this.autosizeColumnWidth(columnIndex, columns, rows, valueFont, headerFont)));
    }

    private scheduleInitialAutosize(params: {
        root: HTMLElement;
        columns: string[];
        rows: TableRowData[];
        state: { columnWidths: Array<number | undefined>; initialAutosizeApplied: boolean };
        rerender: () => void;
    }): void {
        const tryAutosize = () => {
            if (params.state.initialAutosizeApplied) {
                return;
            }
            if (!params.root.isConnected) {
                requestAnimationFrame(tryAutosize);
                return;
            }
            params.state.columnWidths = this.createInitialColumnWidths(params.columns, params.rows, params.root);
            params.state.initialAutosizeApplied = true;
            params.rerender();
        };
        requestAnimationFrame(tryAutosize);
    }

    private installColumnResizeHandle(params: {
        handleHost: HTMLElement;
        columnIndex: number;
        state: { columnWidths: Array<number | undefined> };
        root: HTMLElement;
        columns: string[];
        rows: TableRowData[];
        referenceCell: HTMLElement;
        rerender: () => void;
    }): void {
        const handle = document.createElement('span');
        handle.className = 'table-column-resize-handle';
        handle.title = 'Drag to resize. Double-click to autosize.';
        handle.setAttribute('aria-hidden', 'true');
        params.handleHost.appendChild(handle);
        handle.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
        });

        const minWidth = 72;
        handle.addEventListener('mousedown', (event) => {
            event.preventDefault();
            event.stopPropagation();
            const currentWidth = params.handleHost.getBoundingClientRect().width;
            const startWidth = params.state.columnWidths[params.columnIndex] ?? currentWidth;
            const startX = event.clientX;
            params.state.columnWidths[params.columnIndex] = Math.max(minWidth, Math.round(startWidth));
            params.root.classList.add('table-resizing');

            const onMouseMove = (moveEvent: MouseEvent) => {
                const delta = moveEvent.clientX - startX;
                params.state.columnWidths[params.columnIndex] = Math.max(minWidth, Math.round(startWidth + delta));
                params.rerender();
            };
            const onMouseUp = () => {
                params.root.classList.remove('table-resizing');
                window.removeEventListener('mousemove', onMouseMove);
                window.removeEventListener('mouseup', onMouseUp);
            };
            window.addEventListener('mousemove', onMouseMove);
            window.addEventListener('mouseup', onMouseUp);
        });

        handle.addEventListener('dblclick', (event) => {
            event.preventDefault();
            event.stopPropagation();
            const width = this.autosizeColumnWidth(params.columnIndex, params.columns, params.rows, params.referenceCell);
            params.state.columnWidths[params.columnIndex] = Math.max(minWidth, width);
            params.rerender();
        });
    }

    private autosizeColumnWidth(
        columnIndex: number,
        columns: string[],
        rows: TableRowData[],
        referenceCellOrFont: HTMLElement | string,
        headerFontOverride?: string
    ): number {
        const headerText = `${columns[columnIndex] ?? ''} ▲`;
        const valueFont = typeof referenceCellOrFont === 'string'
            ? referenceCellOrFont
            : this.resolveFont(referenceCellOrFont);
        const headerFont = headerFontOverride ?? valueFont;
        const widestValue = rows.reduce((max, row) => {
            const width = this.measureRenderedCellContentWidth(row, columnIndex, columns, valueFont);
            return Math.max(max, width);
        }, 0);
        const headerWidth = this.measureTextWidth(headerText, headerFont);
        const widestText = Math.max(headerWidth, widestValue);
        const horizontalPadding = 20;
        const handleSpace = 12;
        const treePrefixSpace = this.measureTreePrefixWidth(columnIndex, rows);
        return Math.ceil(widestText + horizontalPadding + handleSpace + treePrefixSpace);
    }

    private measureTreePrefixWidth(columnIndex: number, rows: TableRowData[]): number {
        if (columnIndex !== 0) {
            return 0;
        }
        const treeRows = rows.filter((row) => row.treeDepth !== undefined || row.treeNodeId !== undefined);
        if (treeRows.length === 0) {
            return 0;
        }
        const treeIndent = 16;
        const toggleWidth = 16;
        const toggleGap = 6;
        const toggleBorderAllowance = 2;
        return treeRows.reduce((max, row) => {
            const depth = Math.max(0, row.treeDepth ?? 0);
            const prefix = (depth * treeIndent) + toggleWidth + toggleGap + toggleBorderAllowance;
            return Math.max(max, prefix);
        }, 0);
    }

    private measureRenderedCellContentWidth(
        row: TableRowData,
        columnIndex: number,
        columns: string[],
        font: string
    ): number {
        const rawValue = row.cells[columnIndex] ?? '';
        const typedValue = row.typedCells?.[columnIndex];
        const formattedValue = this.formatCellDisplayValue(rawValue, {
            columnIndex,
            columns,
        });
        const valueElement = this.renderValue(formattedValue, typedValue);
        const decorated = this.decorateCellValueElement({
            row: { index: row.index, cells: row.cells },
            columnIndex,
            columns,
            value: rawValue,
            valueElement,
        });

        const probe = document.createElement('div');
        probe.style.position = 'fixed';
        probe.style.left = '-10000px';
        probe.style.top = '-10000px';
        probe.style.visibility = 'hidden';
        probe.style.whiteSpace = 'nowrap';
        probe.style.font = font;
        probe.appendChild(decorated);
        document.body.appendChild(probe);
        const width = probe.getBoundingClientRect().width;
        probe.remove();
        return width;
    }

    private resolveFont(referenceCell: HTMLElement): string {
        const computed = window.getComputedStyle(referenceCell);
        const lineHeight = computed.lineHeight && computed.lineHeight !== 'normal' ? computed.lineHeight : computed.fontSize;
        return `${computed.fontStyle} ${computed.fontVariant} ${computed.fontWeight} ${computed.fontSize} / ${lineHeight} ${computed.fontFamily}`;
    }

    private measureTextWidth(text: string, font: string): number {
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        if (!context) {
            return text.length * 8;
        }
        context.font = font;
        return context.measureText(text).width;
    }

    protected formatCellDisplayValue(
        raw: string,
        _context: {
            columnIndex: number;
            columns: string[];
            blockSource?: string;
            options?: Record<string, unknown>;
        }
    ): string {
        return raw;
    }

    private renderValue(raw: string, typedValue?: MdTableCell): HTMLElement {
        return this.renderCellValue(raw, typedValue);
    }

    protected createUploadCsvButton(
        _columns: string[],
        _rows: Array<{ index: number; cells: string[] }>,
        _isTree: boolean,
        _blockSource: string | undefined
    ): HTMLElement | undefined {
        return undefined;
    }

    protected transformCsvDownloadData(columns: string[], rows: string[][]): { columns: string[]; rows: string[][] } {
        return { columns, rows };
    }

    private requestCsvDownload(columns: string[], rows: string[][]): void {
        if (columns.length === 0) {
            return;
        }
        const escapeCell = (value: string) => `"${value.replace(/"/g, '""')}"`;
        const lines = [
            columns.map(escapeCell).join(','),
            ...rows.map((row) => row.map(escapeCell).join(',')),
        ];
        this.requestTextFileDownload(lines.join('\n'), 'table', 'csv');
    }

    private requestTreeJsonDownload(columns: string[], model: TreeModel, rows: TableRowData[]): void {
        if (columns.length === 0) {
            return;
        }
        const keyMap = this.createTreeJsonKeys(columns);
        const idColumn = columns[0] ?? '';
        const idKey = keyMap.get(idColumn) ?? idColumn;
        const includeIds = new Set(
            rows
                .map((row) => row.treeNodeId ?? '')
                .filter((id) => id.length > 0)
        );
        const serializeNode = (nodeId: string, path: Set<string>): Record<string, unknown> | undefined => {
            if (!includeIds.has(nodeId) || path.has(nodeId)) {
                return undefined;
            }
            const row = model.rowsById.get(nodeId);
            if (!row) {
                return undefined;
            }
            const node: Record<string, unknown> = {};
            columns.forEach((column, index) => {
                const key = keyMap.get(column) ?? column;
                const value = row.cells[index] ?? '';
                if (index === 0) {
                    node['@id'] = value;
                    return;
                }
                if (value.length > 0) {
                    node[key] = value;
                }
            });
            const nextPath = new Set(path);
            nextPath.add(nodeId);
            const children = (model.childrenByParent.get(nodeId) ?? [])
                .map((childId) => serializeNode(childId, nextPath))
                .filter((entry): entry is Record<string, unknown> => !!entry);
            if (children.length > 0) {
                node.children = children;
            }
            return node;
        };

        const graph = model.roots
            .map((rootId) => serializeNode(rootId, new Set<string>()))
            .filter((entry): entry is Record<string, unknown> => !!entry);
        const context: Record<string, string> = {};
        context[idKey] = idColumn;
        for (let columnIndex = 1; columnIndex < columns.length; columnIndex += 1) {
            const column = columns[columnIndex];
            const key = keyMap.get(column) ?? column;
            context[key] = column;
        }
        context.children = 'children';
        const payload = {
            '@context': context,
            '@graph': graph,
        };
        this.requestTextFileDownload(JSON.stringify(payload, null, 2), 'tree', 'json');
    }

    private createTreeJsonKeys(columns: string[]): Map<string, string> {
        const keys = new Map<string, string>();
        const counts = new Map<string, number>();
        for (const column of columns) {
            const base = shortLabelFromIri(column.trim()) || column;
            const currentCount = counts.get(base) ?? 0;
            counts.set(base, currentCount + 1);
            const key = currentCount === 0 ? base : `${base}_${currentCount + 1}`;
            keys.set(column, key);
        }
        return keys;
    }
}

type TableRowData = {
    index: number;
    cells: string[];
    typedCells?: Array<MdTableCell | undefined>;
    treeNodeId?: string;
    treeDepth?: number;
    treeHasChildren?: boolean;
};

function dedupeRowsByDisplayedCells(rows: ReadonlyArray<TableRowData>): TableRowData[] {
    const seen = new Set<string>();
    const deduped: TableRowData[] = [];
    for (const row of rows) {
        const key = row.cells.join('\u0000');
        if (seen.has(key)) {
            continue;
        }
        seen.add(key);
        deduped.push(row);
    }
    return deduped;
}

type TreeModel = {
    rowsById: Map<string, TableRowData>;
    childrenByParent: Map<string, string[]>;
    parentsByChild: Map<string, string[]>;
    roots: string[];
    expandableNodes: string[];
};

type TableRuleKind = 'row' | 'column' | 'cell' | 'header';
type TableRuleTarget = 'cell' | 'value';
type TableSelector = {
    kind: TableRuleKind;
    condition?: string;
};
type CompiledTableStyleRule = {
    selectors: TableSelector[];
    target: TableRuleTarget;
    style: Record<string, string>;
};

type SelectorContext =
    | { kind: 'row'; context: RowSelectorContext }
    | { kind: 'column'; context: ColumnSelectorContext }
    | { kind: 'cell'; context: CellSelectorContext }
    | { kind: 'header'; context: HeaderSelectorContext };

type RowSelectorContext = {
    index: number;
    get: (key?: unknown) => string | undefined;
    any: (expected?: unknown) => boolean;
    all: (expected?: unknown) => boolean;
    count: (expected?: unknown) => number;
};

type ColumnSelectorContext = {
    name: string;
    get: (index?: unknown) => string | undefined;
    any: (expected?: unknown) => boolean;
    all: (expected?: unknown) => boolean;
    count: (expected?: unknown) => number;
};

type CellSelectorContext = {
    row: CellRowSelectorContext;
    rowIndex: number;
    col: string;
    value: string;
    datatype?: string;
    lang?: string;
};

type CellRowSelectorContext = {
    index: number;
    get: (key?: unknown) => string | undefined;
    any: (expected?: unknown) => boolean;
    all: (expected?: unknown) => boolean;
    count: (expected?: unknown) => number;
    valueOf: () => number;
    toString: () => string;
    [Symbol.toPrimitive]: (hint: string) => number | string;
};

type HeaderSelectorContext = {
    name: string;
};

function compileTableStylesheet(options: Record<string, unknown> | undefined): CompiledTableStyleRule[] {
    const rawStylesheet = options?.stylesheet;
    if (!Array.isArray(rawStylesheet)) {
        return [];
    }
    const compiled: CompiledTableStyleRule[] = [];
    for (const rawRule of rawStylesheet) {
        if (!isRecord(rawRule)) {
            continue;
        }
        const selectors = parseSelectors(rawRule.selector);
        if (selectors.length === 0) {
            continue;
        }
        const style = normalizeStyle(rawRule.style);
        if (Object.keys(style).length === 0) {
            continue;
        }
        const target = rawRule.target === 'value' ? 'value' : 'cell';
        compiled.push({ selectors, target, style });
    }
    return compiled;
}

function parseSelectors(rawSelector: unknown): TableSelector[] {
    if (typeof rawSelector !== 'string') {
        return [];
    }
    const parts = splitSelectors(rawSelector);
    const selectors: TableSelector[] = [];
    for (const part of parts) {
        const parsed = parseSelector(part);
        if (parsed) {
            selectors.push(parsed);
        }
    }
    return selectors;
}

function splitSelectors(rawSelector: string): string[] {
    const parts: string[] = [];
    let current = '';
    let bracketDepth = 0;
    let quote: '"' | '\'' | undefined;
    for (let i = 0; i < rawSelector.length; i += 1) {
        const char = rawSelector[i];
        if (quote) {
            current += char;
            if (char === quote && rawSelector[i - 1] !== '\\') {
                quote = undefined;
            }
            continue;
        }
        if (char === '"' || char === '\'') {
            quote = char;
            current += char;
            continue;
        }
        if (char === '[') {
            bracketDepth += 1;
            current += char;
            continue;
        }
        if (char === ']' && bracketDepth > 0) {
            bracketDepth -= 1;
            current += char;
            continue;
        }
        if (char === ',' && bracketDepth === 0) {
            parts.push(current.trim());
            current = '';
            continue;
        }
        current += char;
    }
    if (current.trim()) {
        parts.push(current.trim());
    }
    return parts;
}

function parseSelector(selector: string): TableSelector | undefined {
    const trimmed = selector.trim();
    if (!trimmed) {
        return undefined;
    }
    const bracketStart = trimmed.indexOf('[');
    if (bracketStart === -1) {
        return toSelector(trimmed, undefined);
    }
    if (!trimmed.endsWith(']')) {
        return undefined;
    }
    const kind = trimmed.slice(0, bracketStart).trim();
    const condition = trimmed.slice(bracketStart + 1, -1).trim();
    return toSelector(kind, condition || undefined);
}

function toSelector(kind: string, condition: string | undefined): TableSelector | undefined {
    const normalized = kind.trim().toLowerCase();
    if (normalized !== 'row' && normalized !== 'column' && normalized !== 'cell' && normalized !== 'header') {
        return undefined;
    }
    return { kind: normalized, condition };
}

function normalizeStyle(rawStyle: unknown): Record<string, string> {
    if (!isRecord(rawStyle)) {
        return {};
    }
    const style: Record<string, string> = {};
    for (const [property, rawValue] of Object.entries(rawStyle)) {
        if (!property.trim()) {
            continue;
        }
        if (rawValue === undefined || rawValue === null) {
            continue;
        }
        style[property] = String(rawValue);
    }
    return style;
}

function createRowContext(columns: string[], row: TableRowData): RowSelectorContext {
    const values = row.cells;
    const getByKey = (key?: unknown): string | undefined => {
        if (typeof key === 'number' && Number.isInteger(key)) {
            return values[key];
        }
        if (typeof key === 'string') {
            const index = columns.indexOf(key);
            return index >= 0 ? values[index] : undefined;
        }
        return undefined;
    };
    const matches = (cellValue: string, expected?: unknown): boolean => {
        if (expected === undefined) {
            return cellValue.length > 0;
        }
        return cellValue === String(expected);
    };
    return {
        index: row.index,
        get: (key?: unknown) => getByKey(key),
        any: (expected?: unknown) => values.some((value) => matches(value, expected)),
        all: (expected?: unknown) => values.every((value) => matches(value, expected)),
        count: (expected?: unknown) => expected === undefined
            ? values.length
            : values.filter((value) => matches(value, expected)).length,
    };
}

function createColumnContexts(columns: string[], rows: TableRowData[]): ColumnSelectorContext[] {
    return columns.map((name, columnIndex) => {
        const values = rows.map((row) => row.cells[columnIndex] ?? '');
        const matches = (cellValue: string, expected?: unknown): boolean => {
            if (expected === undefined) {
                return cellValue.length > 0;
            }
            return cellValue === String(expected);
        };
        return {
            name,
            get: (index?: unknown) => {
                if (typeof index !== 'number' || !Number.isInteger(index)) {
                    return undefined;
                }
                return values[index];
            },
            any: (expected?: unknown) => values.some((value) => matches(value, expected)),
            all: (expected?: unknown) => values.every((value) => matches(value, expected)),
            count: (expected?: unknown) => expected === undefined
                ? values.length
                : values.filter((value) => matches(value, expected)).length,
        };
    });
}

function createCellContext(row: RowSelectorContext | number, columnName: string, value: string): CellSelectorContext {
    const rowContext = toCellRowSelectorContext(row);
    return {
        row: rowContext,
        rowIndex: rowContext.index,
        col: columnName,
        value,
        datatype: undefined,
        lang: undefined,
    };
}

function toCellRowSelectorContext(row: RowSelectorContext | number): CellRowSelectorContext {
    if (typeof row === 'number') {
        const index = row;
        return {
            index,
            get: () => undefined,
            any: () => false,
            all: () => false,
            count: () => 0,
            valueOf: () => index,
            toString: () => String(index),
            [Symbol.toPrimitive]: (hint: string) => hint === 'string' ? String(index) : index,
        };
    }

    const base = row;
    const index = base.index;
    return {
        index,
        get: (key?: unknown) => base.get(key),
        any: (expected?: unknown) => base.any(expected),
        all: (expected?: unknown) => base.all(expected),
        count: (expected?: unknown) => base.count(expected),
        valueOf: () => index,
        toString: () => String(index),
        [Symbol.toPrimitive]: (hint: string) => hint === 'string' ? String(index) : index,
    };
}

function createHeaderContext(name: string): HeaderSelectorContext {
    return { name };
}

function resolveHiddenColumns(
    columns: string[],
    columnContexts: ReadonlyArray<ColumnSelectorContext>,
    rules: ReadonlyArray<CompiledTableStyleRule>
): Set<string> {
    const hidden = new Set<string>();
    for (let index = 0; index < columns.length; index += 1) {
        const columnName = columns[index];
        const columnContext = columnContexts[index];
        const headerContext = createHeaderContext(columnName);
        const cellContext = createCellContext(0, columnName, '');
        for (const rule of rules) {
            if (rule.target === 'value') {
                continue;
            }
            const display = rule.style.display?.trim().toLowerCase();
            if (display !== 'none') {
                continue;
            }
            const matches = rule.selectors.some((selector) => {
                if (selector.kind === 'header') {
                    return evaluateSelectorCondition(selector.condition, headerContext);
                }
                if (selector.kind === 'column') {
                    return evaluateSelectorCondition(selector.condition, columnContext);
                }
                if (selector.kind === 'cell') {
                    return evaluateSelectorCondition(selector.condition, cellContext);
                }
                return false;
            });
            if (matches) {
                hidden.add(columnName);
                break;
            }
        }
    }
    return hidden;
}

type ParsedFilterTerm =
    | { kind: 'global'; value: string }
    | { kind: 'scoped'; columns: string[]; value: string };

function matchesFilterQuery(columns: string[], cells: string[], rawQuery: string): boolean {
    const terms = parseFilterTerms(rawQuery);
    if (terms.length === 0) {
        return true;
    }
    const lowerColumns = columns.map((column) => column.toLowerCase());
    const lowerCells = cells.map((cell) => cell.toLowerCase());
    return terms.every((term) => {
        if (term.kind === 'global') {
            return lowerCells.some((cell) => cell.includes(term.value));
        }
        const indexes = term.columns
            .map((columnName) => lowerColumns.indexOf(columnName))
            .filter((index) => index >= 0);
        if (indexes.length === 0) {
            return false;
        }
        return indexes.some((index) => (lowerCells[index] ?? '').includes(term.value));
    });
}

function parseFilterTerms(rawQuery: string): ParsedFilterTerm[] {
    const tokens = tokenizeFilterQuery(rawQuery);
    const terms: ParsedFilterTerm[] = [];
    for (const token of tokens) {
        const scoped = parseScopedFilterToken(token);
        if (scoped) {
            terms.push(scoped);
            continue;
        }
        const value = normalizeFilterValue(token);
        if (!value) {
            continue;
        }
        terms.push({ kind: 'global', value });
    }
    return terms;
}

function tokenizeFilterQuery(rawQuery: string): string[] {
    const tokens: string[] = [];
    let current = '';
    let quote: '"' | '\'' | undefined;
    for (let index = 0; index < rawQuery.length; index += 1) {
        const char = rawQuery[index];
        if (quote) {
            if (char === quote && rawQuery[index - 1] !== '\\') {
                quote = undefined;
            }
            current += char;
            continue;
        }
        if (char === '"' || char === '\'') {
            quote = char;
            current += char;
            continue;
        }
        if (/\s/.test(char)) {
            if (current.trim()) {
                tokens.push(current.trim());
            }
            current = '';
            continue;
        }
        current += char;
    }
    if (current.trim()) {
        tokens.push(current.trim());
    }
    return tokens;
}

function parseScopedFilterToken(token: string): ParsedFilterTerm | undefined {
    const separator = token.indexOf(':');
    if (separator <= 0) {
        return undefined;
    }
    const rawScope = token.slice(0, separator).trim();
    const rawValue = token.slice(separator + 1).trim();
    const value = normalizeFilterValue(rawValue);
    if (!value) {
        return undefined;
    }

    const multiScope = /^\((.+)\)$/.exec(rawScope);
    const scopeColumns = multiScope
        ? multiScope[1].split('|').map((entry) => normalizeFilterValue(entry)).filter((entry): entry is string => !!entry)
        : [normalizeFilterValue(rawScope)].filter((entry): entry is string => !!entry);
    if (scopeColumns.length === 0) {
        return undefined;
    }

    return {
        kind: 'scoped',
        columns: scopeColumns,
        value
    };
}

function normalizeFilterValue(raw: string): string | undefined {
    const trimmed = raw.trim();
    if (!trimmed) {
        return undefined;
    }
    const unquoted = unquoteFilterValue(trimmed);
    const normalized = unquoted.trim().toLowerCase();
    return normalized || undefined;
}

function unquoteFilterValue(value: string): string {
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
        return value.slice(1, -1);
    }
    return value;
}

function resolveContainmentColumns(columns: string[], options: Record<string, unknown> | undefined): number[] {
    const containment = options?.containment;
    if (!Array.isArray(containment) || containment.length === 0) {
        return [];
    }
    const names = new Set<string>();
    for (const entry of containment) {
        if (typeof entry !== 'string') {
            continue;
        }
        const normalized = normalizeContainmentName(entry);
        if (normalized) {
            names.add(normalized);
        }
    }
    if (names.size === 0) {
        return [];
    }
    const indexes: number[] = [];
    columns.forEach((column, index) => {
        if (names.has(normalizeContainmentName(column))) {
            indexes.push(index);
        }
    });
    return indexes;
}

function normalizeContainmentName(raw: string): string {
    const trimmed = raw.trim().toLowerCase();
    if (!trimmed) {
        return '';
    }
    const colon = trimmed.lastIndexOf(':');
    if (colon >= 0 && colon < trimmed.length - 1) {
        return trimmed.slice(colon + 1);
    }
    const hash = trimmed.lastIndexOf('#');
    if (hash >= 0 && hash < trimmed.length - 1) {
        return trimmed.slice(hash + 1);
    }
    const slash = trimmed.lastIndexOf('/');
    if (slash >= 0 && slash < trimmed.length - 1) {
        return trimmed.slice(slash + 1);
    }
    return trimmed;
}

function evaluateSelectorCondition(condition: string | undefined, context: object): boolean {
    if (!condition) {
        return true;
    }
    try {
        return Boolean(evaluateExpression(condition, context));
    } catch {
        return false;
    }
}

type TokenType = 'identifier' | 'number' | 'string' | 'symbol' | 'eof';
type Token = {
    type: TokenType;
    value: string;
};

class ExpressionParser {
    private index = 0;

    constructor(
        private readonly tokens: ReadonlyArray<Token>,
        private readonly scope: Record<string, unknown>
    ) {}

    parse(): unknown {
        const value = this.parseOr();
        this.expectType('eof');
        return value;
    }

    private parseOr(): unknown {
        let left = this.parseAnd();
        while (this.matchSymbol('||')) {
            const right = this.parseAnd();
            left = Boolean(left) || Boolean(right);
        }
        return left;
    }

    private parseAnd(): unknown {
        let left = this.parseEquality();
        while (this.matchSymbol('&&')) {
            const right = this.parseEquality();
            left = Boolean(left) && Boolean(right);
        }
        return left;
    }

    private parseEquality(): unknown {
        let left = this.parseComparison();
        while (true) {
            if (this.matchSymbol('===')) {
                left = left === this.parseComparison();
                continue;
            }
            if (this.matchSymbol('!==')) {
                left = left !== this.parseComparison();
                continue;
            }
            if (this.matchSymbol('==')) {
                left = left == this.parseComparison();
                continue;
            }
            if (this.matchSymbol('!=')) {
                left = left != this.parseComparison();
                continue;
            }
            break;
        }
        return left;
    }

    private parseComparison(): unknown {
        let left = this.parseAdditive();
        while (true) {
            if (this.matchSymbol('>=')) {
                left = Number(left) >= Number(this.parseAdditive());
                continue;
            }
            if (this.matchSymbol('<=')) {
                left = Number(left) <= Number(this.parseAdditive());
                continue;
            }
            if (this.matchSymbol('>')) {
                left = Number(left) > Number(this.parseAdditive());
                continue;
            }
            if (this.matchSymbol('<')) {
                left = Number(left) < Number(this.parseAdditive());
                continue;
            }
            break;
        }
        return left;
    }

    private parseAdditive(): unknown {
        let left = this.parseMultiplicative();
        while (true) {
            if (this.matchSymbol('+')) {
                const right = this.parseMultiplicative();
                left = typeof left === 'string' || typeof right === 'string'
                    ? `${left ?? ''}${right ?? ''}`
                    : Number(left) + Number(right);
                continue;
            }
            if (this.matchSymbol('-')) {
                left = Number(left) - Number(this.parseMultiplicative());
                continue;
            }
            break;
        }
        return left;
    }

    private parseMultiplicative(): unknown {
        let left = this.parseUnary();
        while (true) {
            if (this.matchSymbol('*')) {
                left = Number(left) * Number(this.parseUnary());
                continue;
            }
            if (this.matchSymbol('/')) {
                left = Number(left) / Number(this.parseUnary());
                continue;
            }
            if (this.matchSymbol('%')) {
                left = Number(left) % Number(this.parseUnary());
                continue;
            }
            break;
        }
        return left;
    }

    private parseUnary(): unknown {
        if (this.matchSymbol('!')) {
            return !Boolean(this.parseUnary());
        }
        if (this.matchSymbol('-')) {
            return -Number(this.parseUnary());
        }
        return this.parseCallChain();
    }

    private parseCallChain(): unknown {
        let current = this.parsePrimary();
        let receiver: unknown = undefined;
        while (true) {
            if (this.matchSymbol('.')) {
                const propertyToken = this.expectType('identifier');
                if (current === null || current === undefined) {
                    current = undefined;
                    receiver = undefined;
                } else {
                    const obj = Object(current) as Record<string, unknown>;
                    receiver = current;
                    current = obj[propertyToken.value];
                }
                continue;
            }
            if (this.matchSymbol('(')) {
                const args: unknown[] = [];
                if (!this.matchSymbol(')')) {
                    do {
                        args.push(this.parseOr());
                    } while (this.matchSymbol(','));
                    this.expectSymbol(')');
                }
                if (typeof current !== 'function') {
                    current = undefined;
                } else {
                    const fn = current as (...params: unknown[]) => unknown;
                    current = receiver !== undefined ? fn.apply(receiver, args) : fn(...args);
                }
                receiver = undefined;
                continue;
            }
            break;
        }
        return current;
    }

    private parsePrimary(): unknown {
        const token = this.current();
        if (token.type === 'number') {
            this.index += 1;
            return Number(token.value);
        }
        if (token.type === 'string') {
            this.index += 1;
            return token.value;
        }
        if (token.type === 'identifier') {
            this.index += 1;
            if (token.value === 'true') return true;
            if (token.value === 'false') return false;
            if (token.value === 'null') return null;
            if (token.value === 'undefined') return undefined;
            return this.scope[token.value];
        }
        if (this.matchSymbol('(')) {
            const value = this.parseOr();
            this.expectSymbol(')');
            return value;
        }
        throw new Error('Expected expression');
    }

    private current(): Token {
        return this.tokens[this.index];
    }

    private matchSymbol(symbol: string): boolean {
        const token = this.current();
        if (token.type === 'symbol' && token.value === symbol) {
            this.index += 1;
            return true;
        }
        return false;
    }

    private expectType(type: TokenType): Token {
        const token = this.current();
        if (token.type !== type) {
            throw new Error(`Expected ${type}`);
        }
        this.index += 1;
        return token;
    }

    private expectSymbol(symbol: string): void {
        if (!this.matchSymbol(symbol)) {
            throw new Error(`Expected ${symbol}`);
        }
    }
}

function evaluateExpression(expression: string, scope: object): unknown {
    const parser = new ExpressionParser(tokenizeExpression(expression), scope as Record<string, unknown>);
    return parser.parse();
}

function tokenizeExpression(expression: string): Token[] {
    const tokens: Token[] = [];
    let index = 0;
    const operators = ['===', '!==', '>=', '<=', '&&', '||', '==', '!=', '(', ')', ',', '.', '+', '-', '*', '/', '%', '!', '>', '<'];

    while (index < expression.length) {
        const char = expression[index];
        if (/\s/.test(char)) {
            index += 1;
            continue;
        }

        const operator = operators.find((op) => expression.startsWith(op, index));
        if (operator) {
            tokens.push({ type: 'symbol', value: operator });
            index += operator.length;
            continue;
        }

        if (char === '"' || char === '\'') {
            const quote = char;
            let value = '';
            index += 1;
            while (index < expression.length) {
                const current = expression[index];
                if (current === quote) {
                    index += 1;
                    break;
                }
                if (current === '\\' && index + 1 < expression.length) {
                    value += expression[index + 1];
                    index += 2;
                    continue;
                }
                value += current;
                index += 1;
            }
            tokens.push({ type: 'string', value });
            continue;
        }

        const numberMatch = /^[0-9]+(?:\.[0-9]+)?/.exec(expression.slice(index));
        if (numberMatch) {
            tokens.push({ type: 'number', value: numberMatch[0] });
            index += numberMatch[0].length;
            continue;
        }

        const identifierMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(expression.slice(index));
        if (identifierMatch) {
            tokens.push({ type: 'identifier', value: identifierMatch[0] });
            index += identifierMatch[0].length;
            continue;
        }

        throw new Error(`Unexpected token '${char}'`);
    }

    tokens.push({ type: 'eof', value: '' });
    return tokens;
}

function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
