import { createSignal, createEffect, createMemo, onCleanup, untrack, on, For, Show, JSXElement, JSX } from "solid-js";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
// The worker reads import.meta.url, so it must load from a real URL (not a
// Blob). The bundler's asset-url plugin emits it from frontend/vendor into
// wwwroot/vendor and resolves this import to its served URL.
import { FormInput, FormSelect, FormMultiSelect, FormMultiSelectTrigger, FormCombobox, FormLabel, type FormSelectOption } from "./Forms.tsx";
import { DatePicker } from "./DatePicker.tsx";
import {Icon} from "./Icons.tsx";
import { Menu, MenuContent, MenuDivider, MenuItem, MenuSection, MenuTrigger } from "./Menu.tsx";
import { Popover, PopoverContent, PopoverTrigger, HoverPopover, HoverPopoverTrigger, HoverPopoverContent } from "./Popovers.tsx";
import { useFloatingContext } from "./Floating.tsx";
import { BUTTON_COLOR_LIGHT_NEUTRAL, BUTTON_COLOR_PRIMARY, ButtonUI, ButtonLinkRed, SegmentedButtons } from "./Buttons.tsx";
import { authFetch } from "../auth/useAuthFetch.js";
import { compareRowsGeneric } from "./CellGrid.tsx";
import { accessor, readAccessor, type MaybeAccessor } from "../utils/accessors.ts";
import { formatNumber, formatDecimal } from "./Formatters.ts";

const pdfWorkerUrl = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();

if (!GlobalWorkerOptions.workerSrc) {
    GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
}

export type ColumnPosition = 0 | 1 | 2;
export type AutoTableHeaderColor = 0 | 1 | 2 | 3 | 4;
export type AutoTableSize = 0 | 1 | 2;
export type PDFOrientation = 0 | 1;

// Predefined spreadsheet-style functions for calculated columns. A fixed
// enumeration whose behavior is obvious and reviewable: "sum/average/min/max/
// count" ignore blank/non-numeric operands (like Excel's aggregate functions);
// "subtract/multiply/divide" require every operand to resolve to a number,
// otherwise the cell shows `emptyValue`. The "custom" function instead evaluates
// an Excel-style `formula` expression (see compileFormula).
export type CalculatedFunction =
    | "sum"
    | "subtract"
    | "multiply"
    | "divide"
    | "average"
    | "median"
    | "mode"
    | "min"
    | "max"
    | "count";

// How a calculated result is formatted for display/CSV/PDF.
export type CalculatedDataType = "plain" | "number" | "integer" | "decimal" | "money" | "percent";

// A user-created calculated column, configured at runtime through the UI and
// (optionally) persisted to localStorage. For predefined functions, `operands`
// reference other columns by key — a data column's sortIdentifier, or
// "_calc_<id>" to reference another calculated column. For fn "custom",
// `formula` holds an Excel-style expression that references columns as
// [Display Name] (see compileFormula).
export interface UserCalculatedColumn {
    id: string;
    displayName: string;
    fn: CalculatedFunction | "custom";
    operands: string[];
    formula?: string;
    dataType?: CalculatedDataType;
    precision?: number;
    displayPosition?: ColumnPosition;  // header + cell alignment (default right)
}

// A user-created footer summary line (Total, Subtotal, …). Unlike a calculated
// column, the formula is evaluated ONCE (no current row), so [cell] refs are NaN
// and only whole-column aggregates like SUM({Revenue}) are meaningful. The single
// result is shown right-aligned in the footer (rightmost column), with `label` to
// its left.
export interface UserSummaryRow {
    id: string;
    label: string;
    // Basic mode: a predefined fn aggregating `operands[0]` down the rows.
    // Advanced mode: fn "custom" with an Excel-style `formula`.
    fn?: CalculatedFunction | "custom";
    operands?: string[];
    formula?: string;
    dataType?: CalculatedDataType;
    precision?: number;
}

const CALC_DATATYPE_OPTIONS: { value: CalculatedDataType; label: string }[] = [
    { value: "number", label: "Number" },
    { value: "integer", label: "Integer" },
    { value: "decimal", label: "Decimal" },
    { value: "money", label: "Money" },
    { value: "percent", label: "Percent" },
    { value: "plain", label: "Plain" },
];

// Basic-mode functions. For a calculated COLUMN these combine the chosen columns
// per row (column-based); binary ones (subtract/divide) take two ordered columns,
// the rest take a multi-select. For a SUMMARY they aggregate one column down the
// rows (row-based), so only the aggregates apply.
const CALC_FUNCTION_OPTIONS: { value: string; label: string }[] = [
    { value: "sum", label: "Sum" },
    { value: "subtract", label: "Subtract" },
    { value: "multiply", label: "Multiply" },
    { value: "divide", label: "Divide" },
    { value: "average", label: "Average (mean)" },
    { value: "median", label: "Median" },
    { value: "mode", label: "Mode" },
    { value: "min", label: "Minimum" },
    { value: "max", label: "Maximum" },
    { value: "count", label: "Count" },
];
const SUMMARY_FUNCTION_OPTIONS: { value: string; label: string }[] = [
    { value: "sum", label: "Sum" },
    { value: "average", label: "Average (mean)" },
    { value: "median", label: "Median" },
    { value: "mode", label: "Mode" },
    { value: "min", label: "Minimum" },
    { value: "max", label: "Maximum" },
    { value: "count", label: "Count" },
];
const isBinaryCalcFn = (fn: string): boolean => fn === "subtract" || fn === "divide";

// Functions offered by the editor's "Function" menu. Names must match the
// formula engine (see makeFormulaFunction); each is inserted as NAME() with the
// caret placed between the parentheses.
const FORMULA_FUNCTION_GROUPS: { label: string; fns: { name: string; sig: string; desc: string }[] }[] = [
    { label: "Aggregate", fns: [
        { name: "SUM",     sig: "SUM(range)",     desc: "Total of the values" },
        { name: "AVERAGE", sig: "AVERAGE(range)", desc: "Mean of the values" },
        { name: "MEDIAN",  sig: "MEDIAN(range)",  desc: "Middle value" },
        { name: "MODE",    sig: "MODE(range)",    desc: "Most frequent value" },
        { name: "MIN",     sig: "MIN(range)",     desc: "Smallest value" },
        { name: "MAX",     sig: "MAX(range)",     desc: "Largest value" },
        { name: "COUNT",   sig: "COUNT(range)",   desc: "How many numbers" },
    ] },
    { label: "Math", fns: [
        { name: "ABS",     sig: "ABS(n)",          desc: "Absolute value" },
        { name: "ROUND",   sig: "ROUND(n, digits)", desc: "Round to digits" },
        { name: "FLOOR",   sig: "FLOOR(n)",        desc: "Round down" },
        { name: "CEILING", sig: "CEILING(n)",      desc: "Round up" },
        { name: "SQRT",    sig: "SQRT(n)",         desc: "Square root" },
        { name: "POWER",   sig: "POWER(n, p)",     desc: "n to the power p" },
        { name: "MOD",     sig: "MOD(n, d)",       desc: "Remainder of n ÷ d" },
        { name: "EXP",     sig: "EXP(n)",          desc: "e to the power n" },
        { name: "LN",      sig: "LN(n)",           desc: "Natural log (base e)" },
        { name: "LOG",     sig: "LOG(n, base)",    desc: "Log, base 10 by default" },
    ] },
    { label: "Trigonometry", fns: [
        { name: "SIN",     sig: "SIN(angle)",   desc: "Sine (radians)" },
        { name: "COS",     sig: "COS(angle)",   desc: "Cosine (radians)" },
        { name: "TAN",     sig: "TAN(angle)",   desc: "Tangent (radians)" },
        { name: "ASIN",    sig: "ASIN(n)",      desc: "Inverse sine" },
        { name: "ACOS",    sig: "ACOS(n)",      desc: "Inverse cosine" },
        { name: "ATAN",    sig: "ATAN(n)",      desc: "Inverse tangent" },
        { name: "ATAN2",   sig: "ATAN2(x, y)",  desc: "Angle of point (x, y)" },
        { name: "SINH",    sig: "SINH(n)",      desc: "Hyperbolic sine" },
        { name: "COSH",    sig: "COSH(n)",      desc: "Hyperbolic cosine" },
        { name: "TANH",    sig: "TANH(n)",      desc: "Hyperbolic tangent" },
        { name: "PI",      sig: "PI()",         desc: "π constant" },
        { name: "RADIANS", sig: "RADIANS(deg)", desc: "Degrees → radians" },
        { name: "DEGREES", sig: "DEGREES(rad)", desc: "Radians → degrees" },
    ] },
    { label: "Logic", fns: [
        { name: "IF",  sig: "IF(test, then, else)", desc: "Choose by condition" },
        { name: "AND", sig: "AND(a, b, …)",         desc: "True if all are true" },
        { name: "OR",  sig: "OR(a, b, …)",          desc: "True if any are true" },
        { name: "NOT", sig: "NOT(a)",               desc: "Negate" },
    ] },
    { label: "Row", fns: [
        { name: "ROW", sig: "ROW()", desc: "Current row number" },
    ] },
];

// Named mathematical constants — written bare in a formula (e.g. 2 * PI * [r]).
const FORMULA_CONSTANTS: Record<string, number> = {
    PI: Math.PI,
    E: Math.E,
    TAU: Math.PI * 2,
    PHI: (1 + Math.sqrt(5)) / 2,
    SQRT2: Math.SQRT2,
};
const FORMULA_CONSTANT_OPTIONS: { name: string; desc: string }[] = [
    { name: "PI",    desc: "π ≈ 3.14159" },
    { name: "E",     desc: "Euler's number ≈ 2.71828" },
    { name: "TAU",   desc: "2π ≈ 6.28319" },
    { name: "PHI",   desc: "Golden ratio ≈ 1.61803" },
    { name: "SQRT2", desc: "√2 ≈ 1.41421" },
];


function makeCalcColumnId(): string {
    return "c" + Date.now().toString(36) + Math.floor(Math.random() * 0x1000000).toString(36);
}

export interface CalculatedColumnSpec {
    fn: CalculatedFunction | "custom";
    // Operands are either a row field key (its raw value is parsed as a number,
    // tolerating "$", ",", "%" and whitespace) or a numeric literal constant.
    operands: (string | number)[];
    dataType?: CalculatedDataType;  // drives default formatting
    // Default display formatting (ignored when `format` is supplied):
    precision?: number;   // fixed number of decimal places
    prefix?: string;      // e.g. "$"
    suffix?: string;      // e.g. "%"
    emptyValue?: string;  // shown when the result isn't a finite number (default "—")
    // Full override of display/CSV/PDF text. Receives the computed number (which
    // may be NaN/Infinity) and the row.
    format?: (result: number, item: any) => any;
    // Overrides operand/fn computation. User columns set this to resolve
    // calc-in-calc references and custom formulas; see computeCalculatedValue.
    compute?: (item: any) => number;
}

export interface AutoTableColumn {
    displayName: string;
    displayPosition?: ColumnPosition;
    sortable?: boolean;
    sortIdentifier?: string;
    // sortType controls the local-sort comparator (see compareRowsGeneric):
    // "numeric" sorts by leading digits then suffix (so "4" < "33", "4NY" < "4X"),
    // "money" by parsed float, otherwise locale string compare. Only applies to
    // client-side (local) sorting; server-side sorting is driven by the backend.
    sortType?: string;
    // sortValue extracts the value to compare for this column when sorting
    // locally (e.g. a computed column). Defaults to row[sortIdentifier].
    sortValue?: (item: any) => unknown;
    headerClasses?: string;
    csv?: boolean;
    csvValue?: (item: any) => any;
    toggleable?: boolean;
    hiddenByDefault?: boolean;
    // Marks this as a calculated column: AutoTable renders, exports, and sorts
    // its value itself from `calculated`, so your cellRenderer does NOT handle
    // it. Calculated columns require `cellRenderer` (not `rowRenderer`) because
    // the table needs per-cell control to inject the computed value, and are
    // evaluated client-side only. See computeCalculatedValue.
    calculated?: CalculatedColumnSpec;
}

export interface PDFBelowTableContext {
    page: any;
    y: number;
    pageWidth: number;
    pageHeight: number;
    margin: number;
    contentWidth: number;
    font: any;
    fontBold: any;
    colors: { text: any; muted: any; border: any };
    response: any;
    pdf: any;
    addPage: () => { page: any; y: number };
}

export interface AutoTablePDFHeader {
    title?: string;
    subtitle?: string;
    showDate?: boolean;
    logoUrl?: string;
    showLogo?: boolean;
    orientation?: PDFOrientation;
    belowTable?: (ctx: PDFBelowTableContext) => void | Promise<void>;
}

export interface AutoTableOptions {
    size?: AutoTableSize;
    shadow?: boolean;
    hover?: boolean;
    alternate?: boolean;
    headerBorderY?: boolean;
    surroundingBorder?: boolean;
    borderX?: boolean;
    borderY?: boolean;
    color?: AutoTableHeaderColor;
    tableLayoutAuto?: boolean;
    hidePagination?: boolean;
    paginationShowAll?: boolean;
    draggableColumns?: boolean;
    columnOrderStorageKey?: string;
    toggleColumns?: boolean;
    columnVisibilityStorageKey?: string;
    // Let users drag the right edge of a column header to resize it. Works best
    // with the default fixed table layout (not tableLayoutAuto).
    resizableColumns?: boolean;
    // Persist user-set column widths (px, keyed by column) to localStorage.
    columnWidthStorageKey?: string;
    // Show an in-table Reset menu to restore column order/widths/calculated
    // columns (only the resets for enabled features are listed).
    resetButton?: boolean;
    exportCSV?: boolean;
    exportFilename?: string;
    accordion?: boolean;
    accordionSingle?: boolean;
    pdfHeader?: AutoTablePDFHeader;
    // Render `searchFields` as a card to the left of the table instead of inline
    // above it. The toolbar (export, column toggles, custom actions) stays on top.
    searchAside?: boolean;
    /** Place export/column toggles at the end of the inline filter row (desktop). */
    inlineToolbar?: boolean;
    // Let end users add/edit/remove their own calculated columns at runtime via
    // a toolbar button. Calculated columns are appended after the data columns,
    // computed client-side from the table's numeric columns.
    calculatedColumns?: boolean;
    // Persist user-created calculated columns to localStorage under this key.
    calculatedColumnsStorageKey?: string;
    // Let end users add/edit/remove footer summary lines (Total, Subtotal, …) at
    // runtime via a toolbar button. Each is a formula evaluated once over the
    // filtered rows, shown in a <tfoot> aligned under the chosen column.
    summaryRows?: boolean;
    // Persist user-created summary rows to localStorage under this key.
    summaryRowsStorageKey?: string;
    // When true, the Export button opens a popover (instead of the menu) that
    // lets the user edit otherwise developer-fixed export settings — file name,
    // PDF title/subtitle, orientation, show date/logo — before downloading.
    userCustomizeExport?: boolean;
}

export interface AutoTableSearchEntry {
    Identifier: string;
    Values: string[];
    Exact?: boolean;
}

export interface AutoTablePagination {
    Disabled?: boolean;
    CurrentPage: number;
    NextPage?: number;
    PreviousPage?: number;
    TotalPages: number;
    TotalItems: number;
    MaxItemsPerPage: number;
    ItemsThisPage?: number;
    ViewRangeLower: number;
    ViewRangeUpper: number;
}

export interface AutoTableOrderBy {
    Identifier: string;
    Descending: boolean;
}

export interface AutoTableFilter {
    Pagination: AutoTablePagination;
    OrderBy: AutoTableOrderBy;
    Search: AutoTableSearchEntry[];
}

export interface SearchFieldsContext {
    getSearchValue: (identifier: string) => string;
    setSearchValue: (identifier: string, value: string) => void;
    setSearchValueExact: (identifier: string, value: string) => void;
    getSearchValues: (identifier: string) => string[];
    setSearchValues: (identifier: string, values: string[]) => void;
    getMultiSearchValue: (fields: string[]) => string;
    setMultiSearchValue: (fields: string[], value: string) => void;
    resetColumnOrder: () => void;
}

export interface ToolbarActionsContext {
    allFilteredData: () => any[];
}

export interface AutoTableProps {
    url?: string;
    data?: any[];
    remoteFiltering?: boolean;
    requireAuth?: boolean;
    // Reading this prop inside the initial remote-fetch effect lets a parent force
    // a reload (e.g. after creating a row) by changing the tracked value. Passed as
    // a signal accessor, wrapProps unwraps it to its current value here — so we read
    // the property reactively rather than calling it.
    refreshSignal?: unknown;
    columns: AutoTableColumn[];
    options?: AutoTableOptions;
    initialFilter?: Partial<AutoTableFilter>;
    accordionKey?: string;
    searchFields?: (ctx: SearchFieldsContext) => any;
    aboveTable?: any;
    belowTable?: any;
    emptyMessage?: string | (() => string);
    rowRenderer?: (item: any, position: number, rowIdx: number) => any;
    cellRenderer?: (item: any, col: AutoTableColumn, colIdx: number, position: number, rowIdx: number) => any;
    accordionRenderer?: (item: any, accordionData: any) => any;
    toolbarActions?: (ctx: ToolbarActionsContext) => any;
    /** Extra items rendered inside the Export dropdown (e.g. QuickBooks CSV). */
    exportMenuItems?: (ctx: ToolbarActionsContext) => any;
    // Predicate that flags one or more rows for highlighting. When the
    // returned predicate matches an item that lives on a different page than
    // the one currently displayed, the table jumps to that page so the row is
    // visible. Re-evaluated whenever the predicate function reference changes.
    highlightMatch?: (item: any) => boolean;
    // Called whenever the effective filter (search/sort/pagination) changes, so
    // a parent can build features off the current query without re-deriving it
    // (e.g. fetching the full result set to seed an "open as queue" action).
    onFilterChange?: (filter: AutoTableFilter) => void;
}

export interface AutoTableSearchProps {
    label: string;
    placeholder?: string;
    value: string;
    onchange: (value: string) => void;
    class?: string;
}

export interface AutoTableMultiSearchProps {
    label?: string;
    placeholder?: string;
    value: string;
    onchange: (value: string) => void;
    class?: string;
}

const MULTI_SEARCH_PREFIX = "_multi_";

export function AutoTableSearch(props: AutoTableSearchProps) {
    const fieldCls = () => props.class || AUTOTABLE_SEARCH_FIELD;
    return <div class={fieldCls()}>
        <label>{props.label}</label>
        <FormInput
            small={true}
            placeholder={props.placeholder}
            value={props.value}
            oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
        />
    </div>;
}

export interface AutoTableDateSearchProps {
    label: string;
    identifier?: string;
    getSearchValue?: (identifier: string) => string;
    value?: MaybeAccessor<string>;
    onchange: (value: string) => void;
    placeholder?: string;
    class?: string;
}

export function AutoTableDateSearch(props: AutoTableDateSearchProps) {
    const liveValue = () => {
        if (props.identifier != null && props.getSearchValue) {
            return props.getSearchValue(props.identifier);
        }
        return readAccessor(props.value, "");
    };

    const fieldCls = () => props.class || AUTOTABLE_DATE_SEARCH_FIELD;
    return (
        <div class={fieldCls()}>
            <label>{props.label}</label>
            <DatePicker
                small={true}
                clearable={true}
                value={liveValue}
                onchange={props.onchange}
                placeholder={props.placeholder}
            />
        </div>
    );
}

export function AutoTableMultiSearch(props: AutoTableMultiSearchProps) {
    return <div class={AUTOTABLE_SEARCH_FIELD + " " + (props.class || "")}>
        <Show when={props.label}>
            <label>{props.label}</label>
        </Show>
        <FormInput
            small={true}
            placeholder={props.placeholder}
            value={props.value}
            oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
        />
    </div>;
}

function createMultiSearchIdentifier(fields: string[]): string {
    return MULTI_SEARCH_PREFIX + fields.join(",");
}

function isMultiSearchIdentifier(identifier: string): boolean {
    return identifier.startsWith(MULTI_SEARCH_PREFIX);
}

function parseMultiSearchFields(identifier: string): string[] {
    if (!isMultiSearchIdentifier(identifier)) return [];
    return identifier.slice(MULTI_SEARCH_PREFIX.length).split(",");
}

export const COL_POS_LEFT: ColumnPosition = 0;
export const COL_POS_RIGHT: ColumnPosition = 1;
export const COL_POS_CENTER: ColumnPosition = 2;

// Alignment picker options for the calculated-column editor (value is the
// stringified ColumnPosition; declared after the COL_POS_* consts).
const CALC_POSITION_OPTIONS: { value: string; label: string }[] = [
    { value: String(COL_POS_LEFT), label: "Left" },
    { value: String(COL_POS_CENTER), label: "Center" },
    { value: String(COL_POS_RIGHT), label: "Right" },
];

export const AUTOTABLE_HEADER_COLOR_DEFAULT = 0;
export const AUTOTABLE_HEADER_COLOR_BLUE = 1;
export const AUTOTABLE_HEADER_COLOR_GREEN = 2;
export const AUTOTABLE_HEADER_COLOR_GRAY = 3;
export const AUTOTABLE_HEADER_COLOR_DARK_BLUE = 4;

export const AUTOTABLE_SIZE_DEFAULT: AutoTableSize = 0;
export const AUTOTABLE_SIZE_COMPACT: AutoTableSize = 1;
export const AUTOTABLE_SIZE_SUPERCOMPACT: AutoTableSize = 2;

export const PDF_ORIENTATION_LANDSCAPE: PDFOrientation = 0;
export const PDF_ORIENTATION_PORTRAIT: PDFOrientation = 1;

// -- Tailwind class maps ---------------------------------------------
// Background + text colors per header color.
export const HEADER_COLOR_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "bg-neutral-50",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "bg-sky-700 text-white",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "bg-green-700 text-white",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "bg-neutral-600 text-white",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "bg-sky-900 text-white",
};

// Sortable hover override per color (applied when `sortable`).
const HEADER_SORT_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "hover:bg-neutral-300",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "hover:bg-sky-500",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "hover:bg-green-800",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "hover:bg-neutral-500",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-800",
};

// Header text weight/case styling per color.
export const HEADER_TEXT_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "font-bold uppercase tracking-wider",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "font-semibold",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "font-semibold",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "font-semibold",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "font-semibold",
};

// Drag-handle color (muted variant of header color).
const HEADER_DRAG_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "text-neutral-500",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "text-white/70",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "text-white/70",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "text-white/70",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white/70",
};

// Sort icon color (same as header text color).
const HEADER_SORT_ICON_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "text-black",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "text-white",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "text-white",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "text-white",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white",
};

// Th cell padding per table size.
export const HEADER_PADDING_CLS: Record<AutoTableSize, string> = {
    [AUTOTABLE_SIZE_DEFAULT]:      "p-4 text-sm",
    [AUTOTABLE_SIZE_COMPACT]:      "py-1.5 px-2 text-sm",
    [AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-2 text-xs",
};

// Body cell padding per table size — applied via [&_td]: on the tbody.
export const BODY_PADDING_CLS: Record<AutoTableSize, string> = {
    [AUTOTABLE_SIZE_DEFAULT]:      "text-sm [&_td]:p-4",
    [AUTOTABLE_SIZE_COMPACT]:      "text-sm [&_td]:py-1 [&_td]:px-2",
    [AUTOTABLE_SIZE_SUPERCOMPACT]: "text-xs [&_td]:py-0.5 [&_td]:px-2",
};

// Pagination padding per table size.
const PAGINATION_PADDING_CLS: Record<AutoTableSize, string> = {
    [AUTOTABLE_SIZE_DEFAULT]:      "py-3 px-4",
    [AUTOTABLE_SIZE_COMPACT]:      "py-1 px-4",
    [AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-4",
};

// Hover background per color (applied to body rows when `hover` is on).
const ROW_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
    [AUTOTABLE_HEADER_COLOR_DEFAULT]:   "hover:bg-neutral-200",
    [AUTOTABLE_HEADER_COLOR_BLUE]:      "hover:bg-sky-100",
    [AUTOTABLE_HEADER_COLOR_GREEN]:     "hover:bg-green-100",
    [AUTOTABLE_HEADER_COLOR_GRAY]:      "hover:bg-neutral-200",
    [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-100",
};

// Alignment per ColumnPosition.
export const POS_CLS: Record<ColumnPosition, string> = {
    [COL_POS_LEFT]:   "text-left",
    [COL_POS_RIGHT]:  "text-right",
    [COL_POS_CENTER]: "text-center",
};

// Header inner flex direction per position.
export const HEADER_INNER_POS: Record<ColumnPosition, string> = {
    [COL_POS_LEFT]:   "",
    [COL_POS_RIGHT]:  "flex-row-reverse",
    [COL_POS_CENTER]: "justify-center",
};

// -- Class string constants for parts that don't vary by config --
export const TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden";
export const TBL_WRAPPER = "overflow-x-auto w-full";
export const TBL_BASE = "min-w-full";

export const HEADER_CONTENT = "transition-transform duration-150 ease-in-out";
export const HEADER_INNER_BASE = "flex justify-between gap-2 items-center";
const DRAG_HANDLE = "opacity-40 shrink-0";
const SORT_ICON_WRAP = "leading-none shrink-0 opacity-50";

const ACCORDION_TOGGLE_TH = "w-10 text-center text-neutral-400";
const ACCORDION_TOGGLE_TD = "w-10 text-center text-neutral-400";
const ACCORDION_ICON = "inline-block transition-transform duration-200";

const SKELETON = "h-4 bg-neutral-200 rounded-default animate-pulse";
const ERROR_CELL = "text-center text-red-600";
const EMPTY_CELL = "text-center text-neutral-500";

const PAGINATION_BASE = "flex justify-between items-center border-t border-neutral-300";
const PAGINATION_INFO = "hidden sm:flex items-center text-sm text-neutral-500";
const PAGINATION_CONTROLS = "flex items-center";
const PAGINATION_LABEL = "hidden sm:block text-sm text-neutral-500 mr-2";
const PAGINATION_PAGE = "text-sm text-neutral-500 px-3";
const PAGINATION_BTN = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent";

const TOOLBAR = "min-w-0 max-w-full mb-1.5 overflow-visible";
const TOOLBAR_MOBILE_BAR = "flex flex-col items-stretch gap-2 mb-2 min-w-0 max-w-full lg:hidden overflow-visible";
const TOOLBAR_DESKTOP_ROW = "flex flex-col gap-3 min-w-0 max-w-full lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 overflow-visible";
const TOOLBAR_ACTIONS = "flex flex-wrap items-center gap-2 min-w-0 max-w-full shrink-0 lg:flex-nowrap lg:ml-auto lg:flex-[0_0_auto] lg:justify-end";
const TOOLBAR_ACTIONS_INLINE = "flex flex-wrap items-center gap-2 min-w-0 shrink-0 lg:flex-nowrap lg:flex-[0_0_auto] lg:justify-end";

const PANEL_ACTIONS_BASE = "w-full min-w-0 max-lg:flex max-lg:flex-wrap max-lg:items-center max-lg:justify-end max-lg:gap-2 max-lg:pt-2.5 max-lg:mt-0.5 max-lg:border-t max-lg:border-neutral-200";
const PANEL_ACTIONS_INLINE = PANEL_ACTIONS_BASE + " lg:flex lg:ml-auto lg:shrink-0 lg:flex-[0_0_auto] lg:self-end lg:w-auto lg:border-t-0 lg:pt-0 lg:mt-0";
const PANEL_ACTIONS_DEFAULT = PANEL_ACTIONS_BASE + " lg:contents";
// Mirrors a small light-neutral ButtonUI for use as a bare PopoverTrigger
// <button> (a PopoverTrigger can't wrap a ButtonUI without nesting buttons).
const CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3";
// Drag affordance on the right edge of a resizable column header.
const RESIZE_HANDLE_CLS = "absolute top-0 right-0 h-full w-[6px] cursor-col-resize select-none z-10 hover:bg-neutral-400/40 active:bg-neutral-500/50";
// Shared metrics so the formula textarea and its highlight overlay align exactly.
// Sized to match a small form input (h-[30px], p-1, text-sm). Kept to a single
// horizontally-scrolling line (whitespace-pre + wrap="off") so it's never taller
// than any other input; the overlay scroll is kept in sync (see syncScroll).
const FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre";
const FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800";
const FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden";
const FORMULA_PLACEHOLDER = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`;


// Mobile: filters collapse behind a toggle; lg+ always shows the filter row inline.
const FILTERS_TOGGLE = "lg:hidden inline-flex w-full items-center justify-between gap-2 py-1 px-3 text-sm font-normal text-neutral-700 bg-neutral-50 border border-neutral-300 rounded-default shadow-xs cursor-pointer hover:bg-neutral-100";
const FILTERS_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold text-white bg-sky-700 rounded-full";
const FILTERS_BODY_BASE = "grow flex flex-col gap-3 min-w-0 max-w-full items-stretch overflow-visible lg:items-end max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0 max-lg:[&_.search-fields]:w-full max-lg:[&_.search-fields]:flex-col [&>.quick-date-tags]:w-full";
const FILTERS_BODY_INLINE = FILTERS_BODY_BASE
    + " lg:flex lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5"
    + " lg:[&>.quick-date-tags]:w-full lg:[&>.quick-date-tags]:basis-full"
    + " lg:[&>.sales-report-toolbar__presets]:w-full lg:[&>.sales-report-toolbar__presets]:basis-full"
    + " lg:[&>.search-fields]:flex-1 lg:[&>.search-fields]:min-w-0 lg:[&>.sales-report-toolbar__filters]:flex-1 lg:[&>.sales-report-toolbar__filters]:min-w-0";
const FILTERS_BODY_DESKTOP_FLEX = "lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 lg:[&>*:not(.quick-date-tags)]:w-auto lg:[&_.search-fields]:flex-1 lg:[&_.search-fields]:min-w-0 lg:[&_.search-fields]:w-auto";
const FILTERS_BODY_ASIDE = "flex flex-col gap-3 max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0";

/** Default AutoTable filter row — stacked on mobile, wrapping row on desktop. */
export const SEARCH_FIELDS = "search-fields flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3 lg:gap-y-2.5 lg:w-auto lg:min-w-0 [&>*]:min-w-0 [&>*]:w-full lg:[&>*]:w-auto lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_9rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:min-w-[8rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[14rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none [&_.search-fields-spacer]:hidden [&_.search-fields-spacer]:basis-full lg:[&_.search-fields-spacer]:block lg:[&_.search-fields-spacer]:flex-1 lg:[&_.search-fields-spacer]:min-w-4 lg:[&_.search-fields-spacer]:basis-auto lg:[&_.search-fields-spacer]:max-w-none lg:[&_.search-fields-spacer]:min-w-[1rem]";

/** Pushes toolbar actions to the end when embedded inside a filter row. */
export const SEARCH_FIELDS_SPACER = "search-fields-spacer hidden lg:block flex-1 min-w-4 basis-full lg:basis-auto lg:min-w-[1rem]";

/** Combobox / custom filter control wrapper — pair with FormCombobox fieldWidth="grow". */
export const AUTOTABLE_FILTER_FIELD = "flex flex-col gap-1 min-w-0 w-full overflow-visible [&>label]:text-xs [&>label]:font-medium [&>label]:text-neutral-700 [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";

/** Sales report toolbar wrappers (presets row 1, filters + export row 2). */
export const SALES_REPORT_TOOLBAR = "sales-report-toolbar flex flex-col gap-2.5 w-full min-w-0 lg:contents";
export const SALES_REPORT_PRESETS = "sales-report-toolbar__presets min-w-0 w-full [&_.quick-date-tags]:gap-1.5";
export const SALES_REPORT_FILTERS = "search-fields sales-report-toolbar__filters flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5 lg:w-auto lg:min-w-0 min-w-0 pt-0.5 border-t border-neutral-200 [&>*]:min-w-0 [&>*]:max-lg:w-full lg:[&>*]:w-auto lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_8rem] lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[12rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";

export const AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 w-full [&>label]:text-xs [&>label]:font-medium [&>label]:text-neutral-700 [&_.relative]:w-full [&_.relative]:min-w-0";
export const AUTOTABLE_SEARCH_FIELD_WIDE = AUTOTABLE_SEARCH_FIELD + " lg:flex-[2_1_12rem] lg:max-w-[20rem]";
export const AUTOTABLE_SEARCH_FIELD_NARROW = AUTOTABLE_SEARCH_FIELD + " lg:flex-[0_1_auto] lg:max-w-[11rem] lg:min-w-[7rem]";
export const AUTOTABLE_DATE_SEARCH_FIELD = AUTOTABLE_SEARCH_FIELD + " lg:flex-[1_1_10rem] lg:max-w-none";

export const SALES_REPORT_FILTER_FIELD = AUTOTABLE_FILTER_FIELD + " sales-report-filter-field lg:flex-[1_1_8rem] lg:max-w-[12rem]";
export const SALES_REPORT_FILTER_FIELD_WIDE = SALES_REPORT_FILTER_FIELD + " sales-report-filter-field--wide lg:flex-[2_1_10rem] lg:max-w-[16rem]";
export const SALES_REPORT_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-date lg:flex-[0_1_auto] lg:w-[9rem] lg:min-w-[8.5rem] lg:max-w-[9.5rem]";
export const SALES_REPORT_FILTER_SEARCH_WIDE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-search--wide lg:flex-[2_1_10rem] lg:max-w-[12rem] lg:min-w-[8rem]";
export const SALES_REPORT_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-ref lg:flex-[0_1_auto] lg:w-[4.5rem] lg:min-w-[4rem] lg:max-w-[5rem]";

/** Sales list (/app/manager/sales) — compact date + car # filters. */
export const SALES_LIST_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-date lg:flex-[0_1_auto] lg:w-[8.25rem] lg:min-w-[8rem] lg:max-w-[8.75rem]";
export const SALES_LIST_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-ref lg:flex-[0_1_auto] lg:w-[3.75rem] lg:min-w-[3.5rem] lg:max-w-[4.25rem]";

// Side filter panel (opts.searchAside): a bordered card to the left of the table.
const SEARCH_CARD = "w-64 shrink-0 bg-white border border-neutral-300 rounded-default shadow-sm p-4 max-lg:w-full";

/** Groups AutoTable filter controls — stacked on mobile, horizontal on desktop. */
export function AutoTableFilterFields(props: { children?: JSXElement; class?: string }) {
    const cls = () => props.class || SEARCH_FIELDS;
    return <div class={cls()}>{props.children}</div>;
}

const PAGE_NUM_KEY = "page_num";
const ORDER_BY_KEY = "order_by";
const ITEMS_PER_PAGE_KEY = "items_per_page";
const SEARCH_KEY_PREFIX = "search_";

export function buildQueryString(filter: AutoTableFilter): string {
    const params = new URLSearchParams();
    params.set(PAGE_NUM_KEY, filter.Pagination.CurrentPage.toString());
    params.set(ITEMS_PER_PAGE_KEY, filter.Pagination.MaxItemsPerPage.toString());
    if (filter.OrderBy.Identifier) {
        params.set(ORDER_BY_KEY, filter.OrderBy.Identifier);
        params.set("order_desc", filter.OrderBy.Descending.toString());
    }
    for (const search of filter.Search) {
        for (const value of search.Values) {
            if (value) {
                params.append(SEARCH_KEY_PREFIX + search.Identifier, value);
            }
        }
    }
    return params.toString();
}

// Parse a row value into a number, tolerating formatted strings like "$1,234.56"
// or "12%". Returns NaN when there's nothing numeric to read.
function toCalcNumber(value: any): number {
    if (typeof value === "number") return value;
    if (value === null || value === undefined) return NaN;
    const cleaned = String(value).replace(/[$,%\s]/g, "");
    if (cleaned === "") return NaN;
    return parseFloat(cleaned);
}

// Apply a predefined function to a list of operand numbers. Aggregates
// (sum/average/median/mode/min/max/count) ignore NaN operands; arithmetic
// (subtract/multiply/divide) yields NaN if any operand is NaN.
function applyCalcFunction(fn: CalculatedFunction, operands: number[]): number {
    if (fn === "count") return operands.filter(n => !Number.isNaN(n)).length;
    if (operands.length === 0) return NaN;
    switch (fn) {
        case "sum":
        case "average":
        case "min":
        case "max": {
            const nums = operands.filter(n => !Number.isNaN(n));
            if (nums.length === 0) return NaN;
            if (fn === "sum") return nums.reduce((a, b) => a + b, 0);
            if (fn === "average") return nums.reduce((a, b) => a + b, 0) / nums.length;
            if (fn === "min") return Math.min(...nums);
            return Math.max(...nums);
        }
        case "median": {
            const nums = operands.filter(n => !Number.isNaN(n)).sort((a, b) => a - b);
            if (nums.length === 0) return NaN;
            const mid = Math.floor(nums.length / 2);
            return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
        }
        case "mode": {
            const nums = operands.filter(n => !Number.isNaN(n));
            if (nums.length === 0) return NaN;
            const counts = new Map<number, number>();
            let best = NaN, bestCount = 0;
            for (const n of nums) {
                const c = (counts.get(n) ?? 0) + 1;
                counts.set(n, c);
                if (c > bestCount) { bestCount = c; best = n; }
            }
            return bestCount > 1 ? best : NaN;  // no repeated value -> no mode
        }
        case "subtract":
            if (operands.some(n => Number.isNaN(n))) return NaN;
            return operands.reduce((a, b) => a - b);
        case "multiply":
            if (operands.some(n => Number.isNaN(n))) return NaN;
            return operands.reduce((a, b) => a * b, 1);
        case "divide":
            if (operands.some(n => Number.isNaN(n))) return NaN;
            return operands.reduce((a, b) => b === 0 ? NaN : a / b);
        default:
            return NaN;
    }
}

// -- Excel-style formula support (the "Custom" calculated-column function) ----
// A small, dependency-free expression evaluator. Supports + - * / ^, comparisons
// (= <> < > <= >=), modulo (%), unary minus, parentheses, numeric literals,
// references, and a fixed set of functions (SUM, AVERAGE/AVG, MIN, MAX, COUNT, IF,
// ABS, ROUND, FLOOR, CEILING/CEIL, SQRT, POWER, MOD, AND, OR, NOT, ROW).
//
// Reference kinds let formulas span both columns and rows:
//   [Name]     -> the current row's value for that column (a scalar)
//   {Name}     -> that column's values across all (filtered) rows (an array)
//   {Name:n}   -> the nth (1-based) row of that column (a scalar)
//   {Name:a:b} -> rows a..b inclusive (an array; Excel-style ":" range)
// e.g. [Revenue] / SUM({Revenue}) * 100      (% of column total)
//      [Score] - AVERAGE({Score})            (deviation from the column mean)
//      SUM({Revenue:1:ROW()})                (running total to the current row)
//      AVERAGE({Sales:ROW()-2:ROW()})        (trailing 3-row moving average)
// Indices/bounds can be any expression; ROW() is the current row's number, so
// {Revenue:ROW()-1} is the previous row's revenue and {Revenue:1} the first.
// Aggregate functions (SUM/AVERAGE/MIN/MAX/COUNT) flatten array arguments;
// arithmetic and comparison operators are scalar-only (an array operand resolves
// to NaN). Comparisons yield 1/0; IF/AND/OR/NOT treat any nonzero, non-NaN value
// as true. compileFormula parses once and throws on a malformed formula. A
// compiled node is evaluated against a context: cell(name) gives the current-row
// scalar, column(name) gives the column across rows, row() the current row #. -mta

type FormulaValue = number | number[];
interface FormulaContext {
    cell: (name: string) => number;
    column: (name: string) => number[];
    row: () => number;
}
type FormulaNode = (ctx: FormulaContext) => FormulaValue;
interface FormulaToken { t: string; v: string; }

const toScalar = (v: FormulaValue): number => Array.isArray(v) ? NaN : v;
const flattenValues = (vals: FormulaValue[]): number[] => {
    const out: number[] = [];
    for (const v of vals) {
        if (Array.isArray(v)) { for (const n of v) out.push(n); }
        else out.push(v);
    }
    return out;
};
// Index of the first ":" at brace/bracket/paren depth 0, or -1. Lets a column
// ref's name and its row index/range be split without tripping on a ":" inside a
// nested {ref} or function args.
const topLevelColon = (s: string): number => {
    let depth = 0;
    for (let k = 0; k < s.length; k++) {
        const c = s[k];
        if (c === "{" || c === "[" || c === "(") depth++;
        else if (c === "}" || c === "]" || c === ")") depth--;
        else if (c === ":" && depth === 0) return k;
    }
    return -1;
};

function tokenizeFormula(src: string): FormulaToken[] {
    const tokens: FormulaToken[] = [];
    const isDigit = (c: string) => c >= "0" && c <= "9";
    const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
    let i = 0;
    while (i < src.length) {
        const c = src[i];
        if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
        if (c === "[") {
            const end = src.indexOf("]", i + 1);
            if (end < 0) throw new Error("Unclosed '[' reference");
            tokens.push({ t: "ref", v: src.slice(i + 1, end).trim() });
            i = end + 1;
            continue;
        }
        if (c === "{") {
            // Balanced-brace scan so a nested {Col:...} inside the index is kept
            // intact (e.g. {A:{B:1}}).
            let depth = 1, j = i + 1;
            while (j < src.length && depth > 0) {
                if (src[j] === "{") depth++;
                else if (src[j] === "}") { depth--; if (depth === 0) break; }
                j++;
            }
            if (depth !== 0) throw new Error("Unclosed '{' column reference");
            tokens.push({ t: "colref", v: src.slice(i + 1, j).trim() });
            i = j + 1;
            continue;
        }
        if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
            let j = i + 1;
            while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
            tokens.push({ t: "num", v: src.slice(i, j) });
            i = j;
            continue;
        }
        if (isAlpha(c)) {
            let j = i + 1;
            while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
            tokens.push({ t: "id", v: src.slice(i, j) });
            i = j;
            continue;
        }
        const two = src.slice(i, i + 2);
        if (two === "<=" || two === ">=" || two === "<>") { tokens.push({ t: "op", v: two }); i += 2; continue; }
        if ("+-*/^%=<>".includes(c)) { tokens.push({ t: "op", v: c }); i++; continue; }
        if ("(),".includes(c)) { tokens.push({ t: "punc", v: c }); i++; continue; }
        throw new Error("Unexpected character '" + c + "'");
    }
    return tokens;
}

function makeFormulaFunction(name: string, args: FormulaNode[]): FormulaNode {
    // Aggregate args flatten arrays (so {Col} spans rows); scalar args coerce.
    const aggArgs = (ctx: FormulaContext) => flattenValues(args.map(a => a(ctx)));
    const scalarArgs = (ctx: FormulaContext) => args.map(a => toScalar(a(ctx)));
    const arg = (idx: number, ctx: FormulaContext) => args[idx] ? toScalar(args[idx](ctx)) : NaN;
    const truthy = (n: number) => !Number.isNaN(n) && n !== 0;
    switch (name) {
        case "SUM": return (ctx) => applyCalcFunction("sum", aggArgs(ctx));
        case "AVERAGE": case "AVG": return (ctx) => applyCalcFunction("average", aggArgs(ctx));
        case "MEDIAN": return (ctx) => applyCalcFunction("median", aggArgs(ctx));
        case "MODE": return (ctx) => applyCalcFunction("mode", aggArgs(ctx));
        case "MIN": return (ctx) => applyCalcFunction("min", aggArgs(ctx));
        case "MAX": return (ctx) => applyCalcFunction("max", aggArgs(ctx));
        case "COUNT": return (ctx) => applyCalcFunction("count", aggArgs(ctx));
        case "ABS": return (ctx) => Math.abs(arg(0, ctx));
        case "ROUND": return (ctx) => { const f = Math.pow(10, args[1] ? arg(1, ctx) : 0); return Math.round(arg(0, ctx) * f) / f; };
        case "FLOOR": return (ctx) => Math.floor(arg(0, ctx));
        case "CEILING": case "CEIL": return (ctx) => Math.ceil(arg(0, ctx));
        case "SQRT": return (ctx) => Math.sqrt(arg(0, ctx));
        case "POWER": return (ctx) => Math.pow(arg(0, ctx), arg(1, ctx));
        case "MOD": return (ctx) => { const b = arg(1, ctx); return b === 0 ? NaN : arg(0, ctx) % b; };
        case "EXP": return (ctx) => Math.exp(arg(0, ctx));
        case "LN": return (ctx) => Math.log(arg(0, ctx));
        // LOG(n, [base]) — base defaults to 10, matching Excel.
        case "LOG": return (ctx) => Math.log(arg(0, ctx)) / Math.log(args[1] ? arg(1, ctx) : 10);
        // Trigonometry (angles in radians, like Excel; use RADIANS()/DEGREES() to convert).
        case "SIN": return (ctx) => Math.sin(arg(0, ctx));
        case "COS": return (ctx) => Math.cos(arg(0, ctx));
        case "TAN": return (ctx) => Math.tan(arg(0, ctx));
        case "ASIN": return (ctx) => Math.asin(arg(0, ctx));
        case "ACOS": return (ctx) => Math.acos(arg(0, ctx));
        case "ATAN": return (ctx) => Math.atan(arg(0, ctx));
        // ATAN2(x, y) — Excel order (angle of the point (x, y)).
        case "ATAN2": return (ctx) => Math.atan2(arg(1, ctx), arg(0, ctx));
        case "SINH": return (ctx) => Math.sinh(arg(0, ctx));
        case "COSH": return (ctx) => Math.cosh(arg(0, ctx));
        case "TANH": return (ctx) => Math.tanh(arg(0, ctx));
        case "PI": return () => Math.PI;
        case "RADIANS": return (ctx) => arg(0, ctx) * Math.PI / 180;
        case "DEGREES": return (ctx) => arg(0, ctx) * 180 / Math.PI;
        case "IF": return (ctx) => truthy(arg(0, ctx)) ? arg(1, ctx) : (args[2] ? arg(2, ctx) : 0);
        case "AND": return (ctx) => scalarArgs(ctx).every(truthy) ? 1 : 0;
        case "OR": return (ctx) => scalarArgs(ctx).some(truthy) ? 1 : 0;
        case "NOT": return (ctx) => truthy(arg(0, ctx)) ? 0 : 1;
        // ROW(): the current row's 1-based position in the filtered set.
        case "ROW": return (ctx) => ctx.row();
        default: throw new Error("Unknown function '" + name + "'");
    }
}

export function compileFormula(src: string): FormulaNode {
    const tokens = tokenizeFormula(src);
    if (tokens.length === 0) throw new Error("Empty formula");
    let pos = 0;
    const peek = () => tokens[pos];
    const next = () => tokens[pos++];
    const expect = (v: string) => { const t = next(); if (!t || t.v !== v) throw new Error("Expected '" + v + "'"); };
    const isOp = (...ops: string[]) => peek() && peek().t === "op" && ops.includes(peek().v);

    const parseComparison = (): FormulaNode => {
        let left = parseAddSub();
        while (isOp("=", "<>", "<", ">", "<=", ">=")) {
            const op = next().v, l = left, r = parseAddSub();
            left = (ctx) => {
                const a = toScalar(l(ctx)), b = toScalar(r(ctx));
                if (Number.isNaN(a) || Number.isNaN(b)) return 0;
                switch (op) {
                    case "=": return a === b ? 1 : 0;
                    case "<>": return a !== b ? 1 : 0;
                    case "<": return a < b ? 1 : 0;
                    case ">": return a > b ? 1 : 0;
                    case "<=": return a <= b ? 1 : 0;
                    default: return a >= b ? 1 : 0;
                }
            };
        }
        return left;
    };
    const parseAddSub = (): FormulaNode => {
        let left = parseMulDiv();
        while (isOp("+", "-")) {
            const op = next().v, l = left, r = parseMulDiv();
            left = (ctx) => op === "+" ? toScalar(l(ctx)) + toScalar(r(ctx)) : toScalar(l(ctx)) - toScalar(r(ctx));
        }
        return left;
    };
    const parseMulDiv = (): FormulaNode => {
        let left = parsePow();
        while (isOp("*", "/", "%")) {
            const op = next().v, l = left, r = parsePow();
            left = (ctx) => {
                const a = toScalar(l(ctx)), b = toScalar(r(ctx));
                if (op === "*") return a * b;
                return b === 0 ? NaN : (op === "/" ? a / b : a % b);  // "%" = remainder
            };
        }
        return left;
    };
    const parsePow = (): FormulaNode => {
        const left = parseUnary();
        if (isOp("^")) { next(); const r = parsePow(); return (ctx) => Math.pow(toScalar(left(ctx)), toScalar(r(ctx))); }
        return left;
    };
    const parseUnary = (): FormulaNode => {
        if (isOp("-")) { next(); const o = parseUnary(); return (ctx) => -toScalar(o(ctx)); }
        if (isOp("+")) { next(); return parseUnary(); }
        return parsePrimary();
    };
    const parsePrimary = (): FormulaNode => {
        const t = peek();
        if (!t) throw new Error("Unexpected end of formula");
        if (t.t === "num") { next(); const v = parseFloat(t.v); return () => v; }
        if (t.t === "ref") { next(); const name = t.v; return (ctx) => ctx.cell(name); }
        if (t.t === "colref") {
            next();
            const raw = t.v;
            // {Name} -> whole column (array); {Name:n} -> the nth (1-based) row
            // (scalar); {Name:a:b} -> rows a..b inclusive (array, Excel range).
            const ci = topLevelColon(raw);
            if (ci < 0) { const name = raw.trim(); return (ctx) => ctx.column(name); }
            const name = raw.slice(0, ci).trim();
            const spec = raw.slice(ci + 1).trim();
            const ri = topLevelColon(spec);
            if (ri < 0) {
                const idxNode = compileFormula(spec);
                return (ctx) => {
                    const arr = ctx.column(name);
                    const n = toScalar(idxNode(ctx));
                    if (Number.isNaN(n)) return NaN;
                    const idx = Math.trunc(n) - 1;
                    return idx >= 0 && idx < arr.length ? arr[idx] : NaN;
                };
            }
            const startNode = compileFormula(spec.slice(0, ri).trim());
            const endNode = compileFormula(spec.slice(ri + 1).trim());
            return (ctx) => {
                const arr = ctx.column(name);
                let s = toScalar(startNode(ctx)), e = toScalar(endNode(ctx));
                if (Number.isNaN(s) || Number.isNaN(e)) return [];
                s = Math.trunc(s); e = Math.trunc(e);
                if (s > e) { const tmp = s; s = e; e = tmp; }
                const out: number[] = [];
                for (let k = s; k <= e; k++) { const i = k - 1; if (i >= 0 && i < arr.length) out.push(arr[i]); }
                return out;
            };
        }
        if (t.t === "punc" && t.v === "(") { next(); const e = parseComparison(); expect(")"); return e; }
        if (t.t === "id") {
            next();
            // A bare identifier (no following "(") is a named constant (PI, E, …).
            if (!(peek() && peek().t === "punc" && peek().v === "(")) {
                const cv = FORMULA_CONSTANTS[t.v.toUpperCase()];
                if (cv === undefined) throw new Error("Unknown name '" + t.v + "'");
                return () => cv;
            }
            expect("(");
            const args: FormulaNode[] = [];
            if (!(peek() && peek().t === "punc" && peek().v === ")")) {
                args.push(parseComparison());
                while (peek() && peek().t === "punc" && peek().v === ",") { next(); args.push(parseComparison()); }
            }
            expect(")");
            return makeFormulaFunction(t.v.toUpperCase(), args);
        }
        throw new Error("Unexpected token '" + t.v + "'");
    };

    const root = parseComparison();
    if (pos < tokens.length) throw new Error("Unexpected token '" + tokens[pos].v + "'");
    return root;
}

const escapeHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

// Lexical syntax highlighter for the formula editor overlay. Returns HTML with
// colored spans for cell refs [..], column refs {..}, function names, numbers,
// and operators. Purely visual — mirrors the tokenizer's character rules.
function highlightFormula(src: string): string {
    const isDigit = (c: string) => c >= "0" && c <= "9";
    const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
    let out = "";
    let i = 0;
    while (i < src.length) {
        const c = src[i];
        if (c === "[") {
            const end = src.indexOf("]", i + 1);
            const j = end < 0 ? src.length : end + 1;
            out += `<span class="text-sky-600">${escapeHtml(src.slice(i, j))}</span>`;
            i = j; continue;
        }
        if (c === "{") {
            let depth = 1, j = i + 1;
            while (j < src.length && depth > 0) { if (src[j] === "{") depth++; else if (src[j] === "}") depth--; j++; }
            out += `<span class="text-violet-600">${escapeHtml(src.slice(i, j))}</span>`;
            i = j; continue;
        }
        if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
            let j = i + 1;
            while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
            out += `<span class="text-amber-600">${escapeHtml(src.slice(i, j))}</span>`;
            i = j; continue;
        }
        if (isAlpha(c)) {
            let j = i + 1;
            while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
            const word = src.slice(i, j);
            let k = j; while (k < src.length && src[k] === " ") k++;
            out += src[k] === "("
                ? `<span class="text-emerald-700 font-semibold">${escapeHtml(word)}</span>`
                : (FORMULA_CONSTANTS[word.toUpperCase()] !== undefined ? `<span class="text-amber-600">${escapeHtml(word)}</span>` : escapeHtml(word));
            i = j; continue;
        }
        if ("+-*/^%=<>(),:".includes(c)) { out += `<span class="text-neutral-400">${escapeHtml(c)}</span>`; i++; continue; }
        out += escapeHtml(c); i++;
    }
    return out;
}

// Format a numeric result per its data type. Falls back to plain string when no
// data type is set. prefix/suffix wrap the formatted text.
function formatCalcResult(result: number, dataType: CalculatedDataType | undefined, precision: number | undefined, prefix: string | undefined, suffix: string | undefined, emptyValue: string | undefined): string {
    if (!isFinite(result)) return emptyValue ?? "—";
    let money = "";
    let percent = "";
    let text: string;
    switch (dataType) {
        case "money":   money = "$"; text = formatDecimal(result, precision ?? 2); break;
        case "decimal": text = formatDecimal(result, precision ?? 2); break;
        case "integer": text = formatNumber(Math.round(result)); break;
        case "percent": percent = "%"; text = result.toFixed(precision ?? 2); break;
        case "number":  text = typeof precision === "number" ? formatDecimal(result, precision) : formatNumber(result); break;
        case "plain":
        default:        text = typeof precision === "number" ? result.toFixed(precision) : String(result);
    }
    // Never render a negative zero: once rounded to the display precision, a tiny
    // negative like SIN(2*PI) ≈ -2.4e-16 would otherwise show as "-0"/"-0.00".
    if (text.charAt(0) === "-" && parseFloat(text) === 0) text = text.slice(1);
    return (prefix ?? "") + money + text + percent + (suffix ?? "");
}

// Evaluate a calculated column for a single row. A spec with a `compute` closure
// (user columns) defers to it; otherwise operands are read from row fields and
// combined by the predefined function. Returns NaN when not computable.
export function computeCalculatedValue(item: any, spec: CalculatedColumnSpec): number {
    if (spec.compute) return spec.compute(item);
    if (spec.fn === "custom") return NaN; // custom requires a `compute` closure
    const operands = spec.operands.map(op => typeof op === "number" ? op : toCalcNumber(item?.[op]));
    return applyCalcFunction(spec.fn, operands);
}

// Compute and format a calculated column's value for display/CSV/PDF.
export function formatCalculatedValue(item: any, spec: CalculatedColumnSpec): any {
    const result = computeCalculatedValue(item, spec);
    if (spec.format) return spec.format(result, item);
    return formatCalcResult(result, spec.dataType, spec.precision, spec.prefix, spec.suffix, spec.emptyValue);
}

interface ProcessedData {
    data: any[];
    allFilteredData: any[];
    pagination: AutoTablePagination;
}

// Apply the search/filter entries to raw rows (no sort/pagination). Shared by
// processDataLocally and the cross-row aggregate source so both see the same set.
function applySearchFilters(rawData: any[], searchEntries: AutoTableSearchEntry[]): any[] {
    let processed = [...rawData];

    for (const search of searchEntries) {
        if (search.Values && search.Values.length > 0 && search.Values[0]) {
            if (search.Values.length > 1) {
                const matchSet = new Set(search.Values.map(v => String(v).toLowerCase()));
                processed = processed.filter(item => {
                    const fieldValue = item[search.Identifier];
                    if (fieldValue === null || fieldValue === undefined) return false;
                    return matchSet.has(String(fieldValue).toLowerCase());
                });
            } else {
                const searchValue = search.Values[0].toLowerCase();
                const match = search.Exact
                    ? (val: any) => String(val).toLowerCase() === searchValue
                    : (val: any) => String(val).toLowerCase().includes(searchValue);

                if (isMultiSearchIdentifier(search.Identifier)) {
                    const fields = parseMultiSearchFields(search.Identifier);
                    processed = processed.filter(item => {
                        return fields.some(field => {
                            const fieldValue = item[field];
                            if (fieldValue === null || fieldValue === undefined) return false;
                            return match(fieldValue);
                        });
                    });
                } else {
                    processed = processed.filter(item => {
                        const fieldValue = item[search.Identifier];
                        if (fieldValue === null || fieldValue === undefined) return false;
                        return match(fieldValue);
                    });
                }
            }
        }
    }

    return processed;
}

function processDataLocally(rawData: any[], filter: AutoTableFilter, columns: AutoTableColumn[]): ProcessedData {
    let processed = applySearchFilters(rawData, filter.Search);

    const totalItems = processed.length;

    if (filter.OrderBy.Identifier) {
        let sortKey: string | null = null;
        let column: AutoTableColumn | undefined;
        const colIndexMatch = filter.OrderBy.Identifier.match(/^_col_(\d+)$/);
        if (colIndexMatch) {
            const colIndex = parseInt(colIndexMatch[1], 10);
            column = columns[colIndex];
            if (column?.sortIdentifier) {
                sortKey = column.sortIdentifier;
            } else if (!column?.calculated && processed.length > 0) {
                // Calculated columns derive their sort value from the spec below,
                // not from a row field, so skip the field-key derivation for them.
                const keys = Object.keys(processed[0]);
                if (colIndex < keys.length) {
                    sortKey = keys[colIndex];
                }
            }
        } else {
            sortKey = filter.OrderBy.Identifier;
            column = columns.find((c) => c.sortIdentifier === sortKey);
        }

        if (sortKey || column?.calculated) {
            const finalSortKey = sortKey;
            // Calculated columns sort by their computed number (NaN sorts last
            // via the null handling below) unless an explicit sortValue is given.
            const calcSpec = column?.calculated;
            const sortValue = column?.sortValue
                ?? (calcSpec ? (r: any) => { const n = computeCalculatedValue(r, calcSpec); return Number.isNaN(n) ? null : n; } : undefined);
            // Extract the comparison value into a stable key so the column's
            // sortType (e.g. "numeric") and any custom sortValue
            // drive the order via the shared comparator. Falling back to the raw
            // field keeps the prior type-aware behavior for untyped columns.
            const getVal = typeof sortValue === "function" ? sortValue : (r: any) => r[finalSortKey];
            const sortType = column?.sortType;
            processed.sort((a, b) => {
                const av = getVal(a);
                const bv = getVal(b);
                // Empty/null always sort last, regardless of direction (matches
                // the prior behavior and the CellGrid comparator).
                if (av === null || av === undefined) return 1;
                if (bv === null || bv === undefined) return -1;
                let comparison: number;
                if (!sortType && typeof av === "number" && typeof bv === "number") {
                    comparison = av - bv;
                } else if (!sortType && av instanceof Date && bv instanceof Date) {
                    comparison = av.getTime() - bv.getTime();
                } else {
                    comparison = compareRowsGeneric({ v: av }, { v: bv }, "v", sortType);
                }
                return filter.OrderBy.Descending ? -comparison : comparison;
            });
        }
    }

    const maxItems = filter.Pagination.MaxItemsPerPage;
    const totalPages = maxItems === -1 ? 1 : Math.max(1, Math.ceil(totalItems / maxItems));
    const currentPage = Math.min(filter.Pagination.CurrentPage, totalPages);

    let paginatedData: any[];
    let viewRangeLower: number, viewRangeUpper: number;
    if (maxItems === -1) {
        paginatedData = processed;
        viewRangeLower = totalItems > 0 ? 1 : 0;
        viewRangeUpper = totalItems;
    } else {
        const startIdx = (currentPage - 1) * maxItems;
        const endIdx = startIdx + maxItems;
        paginatedData = processed.slice(startIdx, endIdx);
        viewRangeLower = totalItems > 0 ? startIdx + 1 : 0;
        viewRangeUpper = Math.min(endIdx, totalItems);
    }

    return {
        data: paginatedData,
        allFilteredData: processed,
        pagination: {
            CurrentPage: currentPage,
            TotalPages: totalPages,
            TotalItems: totalItems,
            MaxItemsPerPage: maxItems,
            ViewRangeLower: viewRangeLower,
            ViewRangeUpper: viewRangeUpper,
        },
    };
}

function downloadCSV(data: any[], columns: AutoTableColumn[], filename: string): void {
    if (data.length === 0) return;

    const csvColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
    if (csvColumns.length === 0) return;

    const escapeCSV = (value: any): string => {
        if (value === null || value === undefined) return "";
        const str = String(value);
        if (str.includes(",") || str.includes('"') || str.includes("\n")) {
            return '"' + str.replace(/"/g, '""') + '"';
        }
        return str;
    };

    const cellValue = (col: AutoTableColumn, item: any): any =>
        col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);

    const headers = csvColumns.map(col => escapeCSV(col.displayName)).join(",");
    const rows = data.map(item => {
        return csvColumns.map(col => escapeCSV(cellValue(col, item))).join(",");
    });

    const csvContent = [headers, ...rows].join("\n");

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

async function buildTablePDF(data: any[], columns: AutoTableColumn[], pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []) {
    const pdfColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
    if (data.length === 0 || pdfColumns.length === 0) return null;

    const pdfCellValue = (col: AutoTableColumn, item: any): any =>
        col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);

    const header = {
        title: "",
        subtitle: "",
        showDate: true,
        logoUrl: "/images/logo_black.png",
        showLogo: true,
        orientation: PDF_ORIENTATION_LANDSCAPE,
        ...pdfHeader,
    };

    const pdf = await PDFDocument.create();
    const font = await pdf.embedFont(StandardFonts.Helvetica);
    const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
    const fontHeading = fontBold;

    const PAGE_MARGIN = 40;
    const ROW_HEIGHT = 18;
    const COL_HEADER_HEIGHT = 22;
    const FONT_SIZE = 8;
    const COL_HEADER_FONT_SIZE = 9;
    const isPortrait = header.orientation === PDF_ORIENTATION_PORTRAIT;
    const PAGE_WIDTH = isPortrait ? 612 : 792;
    const PAGE_HEIGHT = isPortrait ? 792 : 612;
    const CONTENT_WIDTH = PAGE_WIDTH - PAGE_MARGIN * 2;
    const HEADER_BG = rgb(1, 1, 1);
    const HEADER_TEXT_COLOR = rgb(0.1, 0.1, 0.1);
    const ROW_ALT_BG = rgb(0.95, 0.95, 0.95);
    const BORDER_COLOR = rgb(0.8, 0.8, 0.8);
    const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
    const MUTED_COLOR = rgb(0.5, 0.5, 0.5);

    // Embed logo
    let logoImage: any = null;
    if (header.showLogo && header.logoUrl) {
        try {
            const logoBytes = await fetch(header.logoUrl).then((r) => r.arrayBuffer());
            logoImage = await pdf.embedPng(new Uint8Array(logoBytes));
        } catch {
            // Logo not available
        }
    }

    const colWidths = pdfColumns.map(col => {
        const headerLen = col.displayName.length;
        let maxDataLen = headerLen;
        for (let i = 0; i < Math.min(data.length, 50); i++) {
            const val = pdfCellValue(col, data[i]);
            const len = val === null || val === undefined ? 0 : String(val).length;
            if (len > maxDataLen) maxDataLen = len;
        }
        return Math.max(maxDataLen, 4);
    });
    const totalWeight = colWidths.reduce((sum, w) => sum + w, 0);
    const colActualWidths = colWidths.map(w => (w / totalWeight) * CONTENT_WIDTH);

    const truncateText = (text: any, maxWidth: number, f: any, size: number): string => {
        if (!text) return "";
        let str = String(text);
        let w = f.widthOfTextAtSize(str, size);
        if (w <= maxWidth) return str;
        while (str.length > 0 && w > maxWidth) {
            str = str.slice(0, -1);
            w = f.widthOfTextAtSize(str + "…", size);
        }
        return str + "…";
    };

    let page: any = null;
    let y = 0;
    let isFirstPage = true;

    const drawPageHeader = () => {
        const hasHeader = header.title || header.subtitle || header.showDate;
        if (!isFirstPage || !hasHeader) return;

        const TITLE_SIZE = 12;
        const SUBTITLE_SIZE = 9;
        const DATE_SIZE = 9;

        // Right-aligned date (the logo is drawn in the page footer, not here).
        if (header.showDate) {
            const now = new Date();
            const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
            const dateWidth = font.widthOfTextAtSize(dateStr, DATE_SIZE);
            page.drawText(dateStr, {
                x: PAGE_MARGIN + CONTENT_WIDTH - dateWidth,
                y: y - DATE_SIZE - 2,
                size: DATE_SIZE, font, color: MUTED_COLOR,
            });
        }

        let rightBlockHeight = header.showDate ? DATE_SIZE + 2 : 0;

        // Title on the left
        if (header.title) {
            page.drawText(header.title, {
                x: PAGE_MARGIN,
                y: y - TITLE_SIZE,
                size: TITLE_SIZE, font: fontHeading, color: TEXT_COLOR,
            });
        }

        let blockHeight = Math.max(rightBlockHeight, header.title ? TITLE_SIZE + 6 : 0);

        let leftBlockHeight = header.title ? TITLE_SIZE + 6 : 0;
        if (header.subtitle) {
            page.drawText(header.subtitle, {
                x: PAGE_MARGIN,
                y: y - leftBlockHeight - SUBTITLE_SIZE - 4,
                size: SUBTITLE_SIZE, font, color: MUTED_COLOR,
            });
            leftBlockHeight += SUBTITLE_SIZE + 4;
            blockHeight = Math.max(blockHeight, leftBlockHeight);
        }

        if (blockHeight > 0) {
            y -= blockHeight + 16;
        }

        // Divider line
        page.drawLine({
            start: { x: PAGE_MARGIN, y },
            end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
            thickness: 0.75, color: BORDER_COLOR,
        });
        y -= 12;
    };

    const drawColumnHeaders = () => {
        let x = PAGE_MARGIN;
        for (let i = 0; i < pdfColumns.length; i++) {
            const text = pdfColumns[i].displayName;
            const truncated = truncateText(text, colActualWidths[i] - 6, fontBold, COL_HEADER_FONT_SIZE);
            page.drawText(truncated, {
                x: x + 4, y: y - COL_HEADER_HEIGHT + 7,
                size: COL_HEADER_FONT_SIZE, font: fontBold, color: HEADER_TEXT_COLOR,
            });
            x += colActualWidths[i];
        }
        y -= COL_HEADER_HEIGHT;
        page.drawLine({
            start: { x: PAGE_MARGIN, y },
            end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
            thickness: 1, color: rgb(0.15, 0.15, 0.15),
        });
    };

    const addPage = () => {
        page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
        y = PAGE_HEIGHT - PAGE_MARGIN;
        drawPageHeader();
        drawColumnHeaders();
        isFirstPage = false;
    };

    addPage();

    for (let rowIdx = 0; rowIdx < data.length; rowIdx++) {
        if (y - ROW_HEIGHT < PAGE_MARGIN) {
            addPage();
        }

        if (rowIdx % 2 === 1) {
            page.drawRectangle({
                x: PAGE_MARGIN, y: y - ROW_HEIGHT,
                width: CONTENT_WIDTH, height: ROW_HEIGHT,
                color: ROW_ALT_BG,
            });
        }

        page.drawLine({
            start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
            end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
            thickness: 0.5, color: BORDER_COLOR,
        });

        let x = PAGE_MARGIN;
        for (let i = 0; i < pdfColumns.length; i++) {
            const raw = pdfCellValue(pdfColumns[i], data[rowIdx]);
            const text = raw === null || raw === undefined ? "" : String(raw);
            const truncated = truncateText(text, colActualWidths[i] - 6, font, FONT_SIZE);
            page.drawText(truncated, {
                x: x + 4, y: y - ROW_HEIGHT + 6,
                size: FONT_SIZE, font, color: TEXT_COLOR,
            });
            x += colActualWidths[i];
        }
        y -= ROW_HEIGHT;
    }

    // Footer summary rows (Total, Subtotal, …): a subtle shaded band under a
    // divider, emphasized label and value right-aligned in the last column —
    // mirroring the on-screen <tfoot>.
    if (summaries.length > 0) {
        // Left edge x of each column.
        const colX: number[] = [];
        { let xx = PAGE_MARGIN; for (let i = 0; i < pdfColumns.length; i++) { colX[i] = xx; xx += colActualWidths[i]; } }
        // Draw text with its right edge at `rightX`; returns the text width.
        const drawRightEdge = (text: string, rightX: number, fnt: any): number => {
            const w = fnt.widthOfTextAtSize(text, FONT_SIZE);
            page.drawText(text, { x: rightX - w, y: y - ROW_HEIGHT + 6, size: FONT_SIZE, font: fnt, color: TEXT_COLOR });
            return w;
        };
        const cellRight = (colIdx: number) => colX[colIdx] + colActualWidths[colIdx] - 4;
        page.drawLine({
            start: { x: PAGE_MARGIN, y },
            end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
            thickness: 1, color: rgb(0.55, 0.55, 0.55),
        });
        for (const s of summaries) {
            if (y - ROW_HEIGHT < PAGE_MARGIN) addPage();
            page.drawRectangle({
                x: PAGE_MARGIN, y: y - ROW_HEIGHT,
                width: CONTENT_WIDTH, height: ROW_HEIGHT,
                color: rgb(0.96, 0.96, 0.96),
            });
            page.drawLine({
                start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
                end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
                thickness: 0.5, color: BORDER_COLOR,
            });
            // Always right-aligned in the last column; only the label (title) is bold.
            const valueColIdx = pdfColumns.length - 1;
            if (valueColIdx > 0) {
                drawRightEdge(s.value, cellRight(valueColIdx), font);          // value in the last column
                drawRightEdge(s.label, cellRight(valueColIdx - 1), fontBold);  // label in the cell to the left
            } else {
                // Single-column table: share the one cell (value at the right, label just left of it).
                const right = cellRight(0);
                const vw = drawRightEdge(s.value, right, font);
                drawRightEdge(s.label, right - vw - 8, fontBold);
            }
            y -= ROW_HEIGHT;
        }
    }

    // Draw belowTable content if provided
    if (header.belowTable) {
        await header.belowTable({
            page,
            y,
            pageWidth: PAGE_WIDTH,
            pageHeight: PAGE_HEIGHT,
            margin: PAGE_MARGIN,
            contentWidth: CONTENT_WIDTH,
            font,
            fontBold,
            colors: { text: TEXT_COLOR, muted: MUTED_COLOR, border: BORDER_COLOR },
            response,
            pdf,
            addPage: () => {
                page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
                y = PAGE_HEIGHT - PAGE_MARGIN;
                return { page, y };
            },
        });
    }

    // Logo footer dimensions (drawn bottom-left of every page).
    const FOOTER_LOGO_MAX_HEIGHT = 18;
    const FOOTER_LOGO_MAX_WIDTH = 70;
    let footerLogoWidth = 0;
    let footerLogoHeight = 0;
    if (logoImage) {
        const aspect = logoImage.width / logoImage.height;
        footerLogoHeight = Math.min(FOOTER_LOGO_MAX_HEIGHT, logoImage.height);
        footerLogoWidth = footerLogoHeight * aspect;
        if (footerLogoWidth > FOOTER_LOGO_MAX_WIDTH) {
            footerLogoWidth = FOOTER_LOGO_MAX_WIDTH;
            footerLogoHeight = footerLogoWidth / aspect;
        }
    }

    const pages = pdf.getPages();
    for (let i = 0; i < pages.length; i++) {
        // Page number bottom-left, logo bottom-right.
        const footerText = "Page " + (i + 1) + " of " + pages.length;
        pages[i].drawText(footerText, {
            x: PAGE_MARGIN,
            y: PAGE_MARGIN - 20,
            size: 8, font, color: MUTED_COLOR,
        });
        if (logoImage) {
            pages[i].drawImage(logoImage, {
                x: PAGE_WIDTH - PAGE_MARGIN - footerLogoWidth,
                y: PAGE_MARGIN - 18 - (footerLogoHeight - 8) / 2,
                width: footerLogoWidth,
                height: footerLogoHeight,
            });
        }
    }

    return await pdf.save();
}

async function downloadPDF(data: any[], columns: AutoTableColumn[], filename: string, pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []): Promise<void> {
    const pdfBytes = await buildTablePDF(data, columns, pdfHeader, response, summaries);
    if (!pdfBytes) return;

    const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = filename + ".pdf";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
}

interface FormulaSpec { fn: string; operands: string[]; formula: string; }
interface FormulaFieldApi { setSpec: (spec: FormulaSpec) => void; }
interface FormulaFieldProps {
    // "column": Basic functions combine the chosen columns per row (column-based).
    // "summary": Basic functions aggregate one column down the rows (row-based).
    mode?: "column" | "summary";
    onchange: (spec: FormulaSpec) => void;          // fired on every edit
    operandOptions: () => FormSelectOption[];        // column choices + caret-name → column mapping
    onHighlight?: (keys: string[] | null) => void;   // highlight the affected column(s)
    apiRef?: (api: FormulaFieldApi) => void;         // parent uses api.setSpec to seed/reset
}

// The calculation editor, shared by the calculated-column and summary-row forms.
// Basic mode: a predefined function + column select(s). Advanced mode ("custom"):
// an Excel-style formula in a single-line textarea (UNCONTROLLED, overlay repainted
// IMPERATIVELY so typing never loses focus) with a "Function" menu and insert chips.
// The current state is reported to the parent as a {fn, operands, formula} spec; the
// parent seeds it through api.setSpec when its popover opens.
function FormulaField(props: FormulaFieldProps) {
    const ctx = useFloatingContext();
    const [funcMenuOpen, setFuncMenuOpen] = createSignal(false);
    const [funcSearch, setFuncSearch] = createSignal("");
    let funcSearchInput: HTMLInputElement | undefined;
    const [colMenuOpen, setColMenuOpen] = createSignal(false);
    const [colSearch, setColSearch] = createSignal("");
    let colSearchInput: HTMLInputElement | undefined;
    const [constMenuOpen, setConstMenuOpen] = createSignal(false);
    const [constSearch, setConstSearch] = createSignal("");
    let constSearchInput: HTMLInputElement | undefined;
    const [fn, setFn] = createSignal("sum");              // "custom" === advanced
    const [formula, setFormula] = createSignal("");
    const [multiOperands, setMultiOperands] = createSignal([] as string[]);
    const [operandA, setOperandA] = createSignal("");
    const [operandB, setOperandB] = createSignal("");
    const [singleOperand, setSingleOperand] = createSignal("");
    let ta: HTMLTextAreaElement | undefined;
    let overlay: HTMLDivElement | undefined;

    const mode = () => (typeof props.mode === "function" ? (props.mode as () => string)() : props.mode) || "column";
    const operandOptions = () =>
        typeof props.operandOptions === "function"
            ? (props.operandOptions as () => FormSelectOption[])()
            : (props.operandOptions as unknown as FormSelectOption[]) || [];
    const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);
    const isAdvanced = () => fn() === "custom";
    const isBinary = () => isBinaryCalcFn(fn());
    const functionOptions = () => mode() === "summary" ? SUMMARY_FUNCTION_OPTIONS : CALC_FUNCTION_OPTIONS;
    const currentOperands = (): string[] => {
        if (isAdvanced()) return [];
        if (mode() === "summary") return singleOperand() ? [singleOperand()] : [];
        if (isBinary()) return [operandA(), operandB()].filter(Boolean);
        return multiOperands();
    };
    const emit = () => props.onchange({ fn: fn(), operands: currentOperands(), formula: isAdvanced() ? formula() : "" });

    const renderOverlay = () => { if (overlay && ta) overlay.innerHTML = ta.value.trim() ? highlightFormula(ta.value) : FORMULA_PLACEHOLDER; };
    const syncScroll = () => { if (overlay && ta) { overlay.scrollLeft = ta.scrollLeft; overlay.scrollTop = ta.scrollTop; } };

    // Highlight the table column whose [Col]/{Col...} reference the caret sits in.
    const refNameAtCaret = (text: string, pos: number): string | null => {
        let i = 0;
        while (i < text.length) {
            if (text[i] === "[") {
                const end = text.indexOf("]", i + 1);
                const close = end < 0 ? text.length : end;
                if (pos >= i && pos <= close) return text.slice(i + 1, close).trim();
                i = close + 1; continue;
            }
            if (text[i] === "{") {
                let depth = 1, j = i + 1;
                while (j < text.length && depth > 0) { if (text[j] === "{") depth++; else if (text[j] === "}") depth--; j++; }
                const close = depth === 0 ? j - 1 : text.length;
                if (pos >= i && pos <= close) return text.slice(i + 1, close).split(":")[0].trim();
                i = close + 1; continue;
            }
            i++;
        }
        return null;
    };
    const updateCaretHighlight = () => {
        if (!ta) { highlight(null); return; }
        const name = refNameAtCaret(ta.value, ta.selectionStart ?? 0);
        const lc = (name ?? "").toLowerCase();
        const match = name ? operandOptions().find(o => o.label.toLowerCase() === lc) : null;
        highlight(match ? [match.value] : null);
    };
    const onFormulaInput = (v: string) => { setFormula(v); emit(); renderOverlay(); syncScroll(); updateCaretHighlight(); };
    // Insert text at the caret (replacing any selection), then restore focus.
    const insertAt = (build: (cur: string, start: number, end: number) => { next: string; caret: number }) => {
        if (!ta) return;
        const cur = ta.value;
        const start = ta.selectionStart ?? cur.length;
        const end = ta.selectionEnd ?? cur.length;
        const { next, caret } = build(cur, start, end);
        ta.value = next; setFormula(next); emit(); renderOverlay();
        requestAnimationFrame(() => { if (ta) { ta.focus(); ta.setSelectionRange(caret, caret); syncScroll(); updateCaretHighlight(); } });
    };
    const insertRef = (text: string) => insertAt((cur, start, end) => ({ next: cur.slice(0, start) + text + cur.slice(end), caret: start + text.length }));
    // NAME(...) — any current selection becomes the first argument, else caret lands inside the parens.
    const insertFunction = (name: string) => insertAt((cur, start, end) => {
        const selected = cur.slice(start, end);
        const insert = name + "(" + selected + ")";
        return { next: cur.slice(0, start) + insert + cur.slice(end), caret: selected ? start + insert.length : start + name.length + 1 };
    });

    const setSpec = (s: FormulaSpec) => {
        setFn(s.fn || "sum");
        const ops = s.operands ?? [];
        setSingleOperand(ops[0] ?? "");
        setOperandA(ops[0] ?? "");
        setOperandB(ops[1] ?? "");
        setMultiOperands(ops);
        setFormula(s.formula ?? "");
        if (ta) { ta.value = s.formula ?? ""; renderOverlay(); }
        emit();
    };
    if (props.apiRef) props.apiRef({ setSpec });

    const setMode = (advanced: boolean) => { setFn(advanced ? "custom" : functionOptions()[0].value); highlight(null); emit(); };
    const chooseFn = (v: string) => { setFn(v); emit(); };

    // Function menu, filtered by the search box (matches name, signature or description).
    const filteredFunctionGroups = () => {
        const q = funcSearch().trim().toLowerCase();
        if (!q) return FORMULA_FUNCTION_GROUPS;
        return FORMULA_FUNCTION_GROUPS
            .map(g => ({ label: g.label, fns: g.fns.filter(f => (f.name + " " + f.sig + " " + f.desc).toLowerCase().includes(q)) }))
            .filter(g => g.fns.length > 0);
    };
    // Reset the search when the menu closes; focus it when it opens.
    createEffect(() => {
        if (funcMenuOpen()) requestAnimationFrame(() => funcSearchInput?.focus());
        else setFuncSearch("");
    });

    // Column insert menu, filtered by its search box (matches the column name).
    const filteredColumns = () => {
        const q = colSearch().trim().toLowerCase();
        const all = operandOptions;
        return q ? all().filter(o => o.label.toLowerCase().includes(q)) : all();
    };
    createEffect(() => {
        if (colMenuOpen()) requestAnimationFrame(() => colSearchInput?.focus());
        else setColSearch("");
    });

    // Constant menu, filtered by its search box (matches name or description).
    const filteredConstants = () => {
        const q = constSearch().trim().toLowerCase();
        return q ? FORMULA_CONSTANT_OPTIONS.filter(c => (c.name + " " + c.desc).toLowerCase().includes(q)) : FORMULA_CONSTANT_OPTIONS;
    };
    createEffect(() => {
        if (constMenuOpen()) requestAnimationFrame(() => constSearchInput?.focus());
        else setConstSearch("");
    });

    // Clear the table highlight and close the insert menus when the popover closes.
    createEffect(() => { if (!ctx.isOpen()) { highlight(null); setFuncMenuOpen(false); setColMenuOpen(false); setConstMenuOpen(false); } });

    const basicEditor = () => <div class="flex flex-col gap-2"
        onMouseEnter={() => highlight(currentOperands())} onMouseLeave={() => highlight(null)}>
        <FormSelect small={true} class="w-full" value={fn()}
            onchange={(e: Event & { currentTarget: HTMLSelectElement }) => chooseFn(e.currentTarget.value)}>
            <For each={functionOptions()}>
                {(f: { value: string; label: string }) => <option value={f.value}>{f.label}</option>}
            </For>
        </FormSelect>
        <Show when={mode() === "summary"}>
            <FormCombobox small={true} class="w-full" searchable={true}
                options={operandOptions()} value={singleOperand()}
                onchange={(v: string) => { setSingleOperand(v); emit(); }} placeholder="Column…" />
        </Show>
        <Show when={mode() !== "summary" && isBinary()}>
            <div class="grid grid-cols-2 gap-2">
                <FormCombobox small={true} class="w-full" searchable={true}
                    options={operandOptions()} value={operandA()}
                    onchange={(v: string) => { setOperandA(v); emit(); }} placeholder={fn() === "divide" ? "Numerator…" : "Value…"} />
                <FormCombobox small={true} class="w-full" searchable={true}
                    options={operandOptions()} value={operandB()}
                    onchange={(v: string) => { setOperandB(v); emit(); }} placeholder={fn() === "divide" ? "Denominator…" : "Minus…"} />
            </div>
        </Show>
        <Show when={mode() !== "summary" && !isBinary()}>
            <FormMultiSelect small={true} class="w-full" searchable={true}
                options={operandOptions()} value={multiOperands()}
                onchange={(v: string[]) => { setMultiOperands(v); emit(); }} placeholder="Select columns…" />
        </Show>
    </div>;

    // -- Insert menus (Advanced mode), shown in a row above the formula input ----
    const MENU_TRIGGER = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded-default border border-neutral-300 bg-neutral-50 hover:bg-neutral-100 text-neutral-700 cursor-pointer leading-none";
    const MENU_SEARCH = "w-full text-xs p-1 border border-neutral-300 rounded-default focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";

    const columnMenu = () => <Show when={operandOptions.length}>
        <Popover placement="bottom-start" standalone={true} open={colMenuOpen()} onOpenChange={setColMenuOpen}>
            <PopoverTrigger class={MENU_TRIGGER}>Column <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
            <PopoverContent class="w-64 max-h-72 overflow-y-auto">
                <div class="sticky top-0 z-10 bg-white border-b border-neutral-200 p-1.5">
                    <input ref={(el: HTMLInputElement) => { colSearchInput = el; }} type="text" spellcheck="false"
                        placeholder="Search columns…" value={colSearch()}
                        oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setColSearch(e.currentTarget.value)}
                        class={MENU_SEARCH} />
                </div>
                <div class="p-1.5">
                    <For each={filteredColumns()}>
                        {(o: FormSelectOption) => <div
                            class="flex items-center rounded-default hover:bg-neutral-100"
                            onMouseEnter={() => highlight([o.value])} onMouseLeave={() => highlight(null)}>
                            <button type="button"
                                class="flex-1 min-w-0 text-left truncate px-2 py-1 font-mono text-xs text-sky-600 bg-transparent border-0 cursor-pointer"
                                title={"Insert [" + o.label + "] — this row's cell"}
                                onclick={(_e: MouseEvent) => { insertRef("[" + o.label + "]"); setColMenuOpen(false); }}>[{o.label}]</button>
                            <button type="button"
                                class="px-2 py-1 text-xs font-mono text-violet-600 hover:text-violet-800 bg-transparent border-0 cursor-pointer"
                                title={"Insert {" + o.label + "} — whole column"}
                                onclick={(_e: MouseEvent) => { insertRef("{" + o.label + "}"); setColMenuOpen(false); }}>{"{ }"}</button>
                        </div>}
                    </For>
                    <Show when={filteredColumns().length === 0}>
                        <div class="px-2 py-3 text-xs text-neutral-500 text-center">No columns match.</div>
                    </Show>
                </div>
            </PopoverContent>
        </Popover>
    </Show>;

    const functionMenu = () => <Popover placement="bottom-start" standalone={true} open={funcMenuOpen()} onOpenChange={setFuncMenuOpen}>
        <PopoverTrigger class={MENU_TRIGGER}>Function <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
        <PopoverContent class="w-64 max-h-72 overflow-y-auto">
            <div class="sticky top-0 z-10 bg-white border-b border-neutral-200 p-1.5">
                <input ref={(el: HTMLInputElement) => { funcSearchInput = el; }} type="text" spellcheck="false"
                    placeholder="Search functions…" value={funcSearch()}
                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setFuncSearch(e.currentTarget.value)}
                    class={MENU_SEARCH} />
            </div>
            <div class="p-1.5">
                <For each={filteredFunctionGroups()}>
                    {(g: { label: string; fns: { name: string; sig: string; desc: string }[] }) => <div>
                        <div class="px-2 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wide text-neutral-400">{g.label}</div>
                        <For each={g.fns}>
                            {(f: { name: string; sig: string; desc: string }) => <button type="button"
                                class="w-full text-left px-2 py-1 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex flex-col gap-0.5"
                                onclick={(_e: MouseEvent) => { insertFunction(f.name); setFuncMenuOpen(false); }}>
                                <span class="font-mono text-xs text-neutral-800" innerHTML={highlightFormula(f.sig)}></span>
                                <span class="text-[11px] text-neutral-500">{f.desc}</span>
                            </button>}
                        </For>
                    </div>}
                </For>
                <Show when={filteredFunctionGroups().length === 0}>
                    <div class="px-2 py-3 text-xs text-neutral-500 text-center">No functions match.</div>
                </Show>
            </div>
        </PopoverContent>
    </Popover>;

    const constantMenu = () => <Popover placement="bottom-start" standalone={true} open={constMenuOpen()} onOpenChange={setConstMenuOpen}>
        <PopoverTrigger class={MENU_TRIGGER}>Constant <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
        <PopoverContent class="w-64 max-h-72 overflow-y-auto">
            <div class="sticky top-0 z-10 bg-white border-b border-neutral-200 p-1.5">
                <input ref={(el: HTMLInputElement) => { constSearchInput = el; }} type="text" spellcheck="false"
                    placeholder="Search constants…" value={constSearch()}
                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setConstSearch(e.currentTarget.value)}
                    class={MENU_SEARCH} />
            </div>
            <div class="p-1.5">
                <For each={filteredConstants()}>
                    {(c: { name: string; desc: string }) => <button type="button"
                        class="w-full text-left px-2 py-1 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-center justify-between gap-2"
                        onclick={(_e: MouseEvent) => { insertRef(c.name); setConstMenuOpen(false); }}>
                        <span class="font-mono text-xs text-amber-600">{c.name}</span>
                        <span class="text-[11px] text-neutral-500">{c.desc}</span>
                    </button>}
                </For>
                <Show when={filteredConstants().length === 0}>
                    <div class="px-2 py-3 text-xs text-neutral-500 text-center">No constants match.</div>
                </Show>
            </div>
        </PopoverContent>
    </Popover>;

    const advancedEditor = () => <div>
        <div class="relative">
            <div ref={(el: HTMLDivElement) => { overlay = el; }} class={FORMULA_OVERLAY_CLS} aria-hidden="true"></div>
            <textarea ref={(el: HTMLTextAreaElement) => { ta = el; el.rows = 1; el.value = untrack(formula); renderOverlay(); }}
                class={FORMULA_TEXTAREA_CLS} spellcheck="false" rows="1" wrap="off"
                oninput={(e: InputEvent & { currentTarget: HTMLTextAreaElement }) => onFormulaInput(e.currentTarget.value)}
                onKeyUp={updateCaretHighlight}
                onclick={updateCaretHighlight}
                onBlur={() => highlight(null)}
                onScroll={syncScroll}></textarea>
        </div>
        <div class="flex flex-wrap items-center gap-1.5 mt-2">
            {columnMenu()}
            {functionMenu()}
            {constantMenu()}
        </div>
    </div>;

    return <div>
        <div class="flex items-center gap-1.5 mb-2">
            <span class="text-neutral-700 text-sm font-medium leading-none">Formula</span>
            <Show when={isAdvanced()}>
                <HoverPopover placement="top" standalone={true}>
                    <HoverPopoverTrigger class="inline-flex items-center text-neutral-400 hover:text-neutral-600 cursor-help bg-transparent border-0 p-0 leading-none">
                        <Icon icon="circle-info" size={14} />
                    </HoverPopoverTrigger>
                    <HoverPopoverContent class="p-3 w-80 text-xs text-neutral-600 leading-relaxed flex flex-col gap-2">
                        <div>
                            <div class="font-medium text-neutral-700 mb-1">References</div>
                            <div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">[Col]</code><span>this row's cell</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">{"{Col}"}</code><span>the whole column</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">{"{Col:n}"}</code><span>value in row n</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">{"{Col:a:b}"}</code><span>rows a through b</span>
                            </div>
                        </div>
                        <div class="mt-2">
                            <div class="font-medium text-neutral-700 mb-1">Operators</div>
                            <div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">{"+ - * /"}</code><span>add, subtract, multiply, divide</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">^</code><span>power (a to the b)</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">%</code><span>remainder (modulo)</span>
                                <code class="bg-neutral-100 px-1 rounded justify-self-start">{"= <> < > <= >="}</code><span>compare (yields 1 or 0)</span>
                            </div>
                        </div>
                    </HoverPopoverContent>
                </HoverPopover>
            </Show>
            <SegmentedButtons class="ml-auto shrink-0" small={true}
                options={[{ value: "basic", label: "Basic" }, { value: "advanced", label: "Advanced" }]}
                value={isAdvanced() ? "advanced" : "basic"}
                onchange={(v: string) => setMode(v === "advanced")} />
        </div>
        <Show when={isAdvanced()} fallback={basicEditor()}>
            {advancedEditor()}
        </Show>
    </div>;
}

interface CalculatedColumnFormProps {
    column: () => UserCalculatedColumn | null;   // null = add mode
    operandOptions: () => FormSelectOption[];
    onSave: (col: UserCalculatedColumn) => void;
    onRemove?: (id: string) => void;
    // Highlight columns in the table while editing (operand keys, or null/[]).
    onHighlight?: (keys: string[] | null) => void;
}

// Editor for a calculated column, rendered inside a Popover. Reads the floating
// context to prefill when the popover opens and to close itself on save/cancel.
// A column is defined by an Excel-style formula (see compileFormula); the insert
// chips reference other columns as [Col]/{Col}, including other calculated
// columns (but never this one). Saved columns always have fn "custom".
function CalculatedColumnForm(props: CalculatedColumnFormProps) {
    const ctx = useFloatingContext();
    const [name, setName] = createSignal("");
    const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec);
    const [dataType, setDataType] = createSignal("number" as CalculatedDataType);
    const [decimals, setDecimals] = createSignal("");
    const [position, setPosition] = createSignal(String(COL_POS_RIGHT));
    const [errorMsg, setErrorMsg] = createSignal("");
    let formulaApi: FormulaFieldApi | undefined;

    // wrapProps may deliver these as the unwrapped value or as a function.
    const column = () => typeof props.column === "function" ? (props.column as () => UserCalculatedColumn | null)() : (props.column as unknown as UserCalculatedColumn | null);
    const allOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]);
    // Exclude this column from its own reference choices.
    const operandOptions = () => {
        const ed = column();
        const selfRef = ed ? "_calc_" + ed.id : null;
        return allOptions().filter(o => o.value !== selfRef);
    };
    const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);

    // Prefill the form each time the popover opens (FormulaField is seeded via its api).
    createEffect(() => {
        if (!ctx.isOpen()) return;
        untrack(() => {
            const ed = column();
            setName(ed?.displayName ?? "");
            setDataType(ed?.dataType ?? "number");
            setDecimals(ed?.precision != null ? String(ed.precision) : "");
            setPosition(String(ed?.displayPosition ?? COL_POS_RIGHT));
            setErrorMsg("");
            formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" });
        });
    });

    // Live formula validation feedback (Advanced/custom only).
    const formulaError = createMemo(() => {
        const s = spec();
        if (s.fn !== "custom" || !s.formula.trim()) return "";
        try { compileFormula(s.formula); return ""; }
        catch (e) { return e instanceof Error ? e.message : "Invalid formula"; }
    });

    const close = () => ctx.setIsOpen(false);

    const handleSave = () => {
        const trimmedName = name().trim();
        if (!trimmedName) { setErrorMsg("Enter a column name."); return; }
        const s = spec();
        let operands: string[] = [];
        let formulaText: string | undefined;
        if (s.fn === "custom") {
            formulaText = s.formula.trim();
            if (!formulaText) { setErrorMsg("Enter a formula."); return; }
            try { compileFormula(formulaText); }
            catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; }
        } else {
            operands = s.operands.filter(Boolean);
            if (isBinaryCalcFn(s.fn)) { if (operands.length < 2) { setErrorMsg("Pick both columns."); return; } }
            else if (operands.length < 1) { setErrorMsg("Pick at least one column."); return; }
        }
        const ed = column();
        const decTrimmed = decimals().trim();
        const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10);
        const col: UserCalculatedColumn = {
            id: ed?.id ?? makeCalcColumnId(),
            displayName: trimmedName,
            fn: s.fn as CalculatedFunction | "custom",
            operands,
            formula: formulaText,
            dataType: dataType(),
            precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision,
            displayPosition: parseInt(position(), 10) as ColumnPosition,
        };
        // Close before onSave: editing replaces this column's object, which
        // disposes this popover's row, so closing afterward would target a
        // disposed floating context.
        close();
        props.onSave(col);
    };

    const handleRemove = () => {
        const ed = column();
        close();
        if (ed && props.onRemove) props.onRemove(ed.id);
    };

    return <div class="flex flex-col gap-3 p-4 w-96">
        <div class="text-sm font-semibold text-neutral-700">{column() ? "Edit calculation" : "New calculation"}</div>
        <div>
            <FormLabel>Column Name</FormLabel>
            <FormInput small={true} class="w-full" placeholder="e.g. Total"
                value={name()}
                oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setName(e.currentTarget.value)} />
        </div>
        <div>
            <FormulaField
                mode="column"
                onchange={setSpec}
                operandOptions={operandOptions}
                onHighlight={highlight}
                apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
            <Show when={formulaError()}>
                <p class="text-xs text-red-600 mt-1">{formulaError()}</p>
            </Show>
        </div>
        <div class="grid grid-cols-2 gap-2">
            <div>
                <FormLabel>Data type</FormLabel>
                <FormSelect small={true} class="w-full"
                    value={dataType()}
                    onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
                    <For each={CALC_DATATYPE_OPTIONS}>
                        {(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
                    </For>
                </FormSelect>
            </div>
            <div>
                <FormLabel>Decimals</FormLabel>
                <FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
                    value={decimals()}
                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
            </div>
            <div class="col-span-2">
                <FormLabel>Alignment</FormLabel>
                <FormSelect small={true} class="w-full"
                    value={position()}
                    onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPosition(e.currentTarget.value)}>
                    <For each={CALC_POSITION_OPTIONS}>
                        {(p: { value: string; label: string }) => <option value={p.value}>{p.label}</option>}
                    </For>
                </FormSelect>
            </div>
        </div>
        <Show when={errorMsg()}>
            <p class="text-xs text-red-600">{errorMsg()}</p>
        </Show>
        <div class="flex gap-2 items-center pt-1">
            <Show when={!!column() && !!props.onRemove}>
                <ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
            </Show>
            <div class="flex gap-2 items-center ml-auto">
                <ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
                <ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
            </div>
        </div>
    </div>;
}

interface SummaryRowFormProps {
    row: () => UserSummaryRow | null;            // null = add mode
    operandOptions: () => FormSelectOption[];     // display-under columns + formula refs
    onSave: (row: UserSummaryRow) => void;
    onRemove?: (id: string) => void;
    onHighlight?: (keys: string[] | null) => void;
}

// Editor for a footer summary line (Total, Subtotal, …): a label and a single-value
// calculation (FormulaField). The value is always shown right-aligned in the footer.
// Mirrors CalculatedColumnForm's lifecycle (prefill on open, close before onSave).
function SummaryRowForm(props: SummaryRowFormProps) {
    const ctx = useFloatingContext();
    const [label, setLabel] = createSignal("");
    const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec);
    const [dataType, setDataType] = createSignal("number" as CalculatedDataType);
    const [decimals, setDecimals] = createSignal("");
    const [errorMsg, setErrorMsg] = createSignal("");
    let formulaApi: FormulaFieldApi | undefined;

    const row = () => typeof props.row === "function" ? (props.row as () => UserSummaryRow | null)() : (props.row as unknown as UserSummaryRow | null);
    const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]) || [];
    const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);

    createEffect(() => {
        if (!ctx.isOpen()) return;
        untrack(() => {
            const ed = row();
            setLabel(ed?.label ?? "");
            setDataType(ed?.dataType ?? "number");
            setDecimals(ed?.precision != null ? String(ed.precision) : "");
            setErrorMsg("");
            formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" });
        });
    });

    const formulaError = createMemo(() => {
        const s = spec();
        if (s.fn !== "custom" || !s.formula.trim()) return "";
        try { compileFormula(s.formula); return ""; }
        catch (e) { return e instanceof Error ? e.message : "Invalid formula"; }
    });

    const close = () => ctx.setIsOpen(false);
    const handleSave = () => {
        const trimmedLabel = label().trim();
        if (!trimmedLabel) { setErrorMsg("Enter a label."); return; }
        const s = spec();
        let operands: string[] = [];
        let formulaText: string | undefined;
        if (s.fn === "custom") {
            formulaText = s.formula.trim();
            if (!formulaText) { setErrorMsg("Enter a formula."); return; }
            try { compileFormula(formulaText); } catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; }
        } else {
            operands = s.operands.filter(Boolean);
            if (operands.length < 1) { setErrorMsg("Pick a column."); return; }
        }
        const ed = row();
        const decTrimmed = decimals().trim();
        const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10);
        const out: UserSummaryRow = {
            id: ed?.id ?? makeCalcColumnId(),
            label: trimmedLabel,
            fn: s.fn as CalculatedFunction | "custom",
            operands,
            formula: formulaText,
            dataType: dataType(),
            precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision,
        };
        close();
        props.onSave(out);
    };
    const handleRemove = () => { const ed = row(); close(); if (ed && props.onRemove) props.onRemove(ed.id); };

    return <div class="flex flex-col gap-3 p-4 w-96">
        <div class="text-sm font-semibold text-neutral-700">{row() ? "Edit summary" : "New summary"}</div>
        <div>
            <FormLabel>Label</FormLabel>
            <FormInput small={true} class="w-full" placeholder="e.g. Total"
                value={label()}
                oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setLabel(e.currentTarget.value)} />
        </div>
        <div>
            <FormulaField
                mode="summary"
                onchange={setSpec}
                operandOptions={operandOptions}
                onHighlight={highlight}
                apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
            <Show when={formulaError()}>
                <p class="text-xs text-red-600 mt-1">{formulaError()}</p>
            </Show>
        </div>
        <div class="grid grid-cols-2 gap-2">
            <div>
                <FormLabel>Data type</FormLabel>
                <FormSelect small={true} class="w-full"
                    value={dataType()}
                    onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
                    <For each={CALC_DATATYPE_OPTIONS}>
                        {(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
                    </For>
                </FormSelect>
            </div>
            <div>
                <FormLabel>Decimals</FormLabel>
                <FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
                    value={decimals()}
                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
            </div>
        </div>
        <Show when={errorMsg()}>
            <p class="text-xs text-red-600">{errorMsg()}</p>
        </Show>
        <div class="flex gap-2 items-center pt-1">
            <Show when={!!row() && !!props.onRemove}>
                <ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
            </Show>
            <div class="flex gap-2 items-center ml-auto">
                <ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
                <ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
            </div>
        </div>
    </div>;
}

interface AddCalcMenuProps {
    allowColumn: boolean;
    allowSummary: boolean;
    operandOptions: () => FormSelectOption[];
    onSaveColumn: (col: UserCalculatedColumn) => void;
    onSaveSummary: (row: UserSummaryRow) => void;
    onHighlight?: (keys: string[] | null) => void;
}

// Content of the single toolbar "Add Calculation" popover: a chooser listing the
// enabled options (calculated column and/or summary row), which then swaps to the
// chosen editor in the same popover (so both editors share this floating context).
// The chooser is always shown and resets when the popover closes.
function AddCalcMenu(props: AddCalcMenuProps) {
    const ctx = useFloatingContext();
    const allowColumn = () => typeof props.allowColumn === "function" ? (props.allowColumn as () => boolean)() : props.allowColumn;
    const allowSummary = () => typeof props.allowSummary === "function" ? (props.allowSummary as () => boolean)() : props.allowSummary;
    // Forward as an accessor (a function) so the binding stays reactive — passing
    // props.operandOptions directly would snapshot the array (losing new columns).
    const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]);
    const [view, setView] = createSignal("menu");
    createEffect(() => { if (!ctx.isOpen()) setView("menu"); });

    const ITEM = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5";
    return <>
        <Show when={view() === "menu"}>
            <div class="flex flex-col gap-0.5 p-2 w-96">
                <Show when={allowColumn()}>
                    <button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("column")}>
                        <Icon icon="table-columns" size={16} class="mt-0.5 text-neutral-500" />
                        <span class="flex flex-col">
                            <span class="text-sm font-medium text-neutral-800">Calculated column</span>
                            <span class="text-xs text-neutral-500">A new column computed for each row</span>
                        </span>
                    </button>
                </Show>
                <Show when={allowSummary()}>
                    <button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("summary")}>
                        <Icon icon="list-ol" size={16} class="mt-0.5 text-neutral-500" />
                        <span class="flex flex-col">
                            <span class="text-sm font-medium text-neutral-800">Summary row</span>
                            <span class="text-xs text-neutral-500">A total or aggregate shown in the footer</span>
                        </span>
                    </button>
                </Show>
            </div>
        </Show>
        <Show when={view() === "column"}>
            <CalculatedColumnForm
                column={() => null}
                operandOptions={operandOptions}
                onSave={props.onSaveColumn}
                onHighlight={props.onHighlight} />
        </Show>
        <Show when={view() === "summary"}>
            <SummaryRowForm
                row={() => null}
                operandOptions={operandOptions}
                onSave={props.onSaveSummary}
                onHighlight={props.onHighlight} />
        </Show>
    </>;
}

/**
 * Feature-rich data table with sorting, pagination, search, drag-to-reorder columns,
 * column visibility toggle, CSV export, accordion rows, and user-defined
 * calculated columns.
 * Supports both local data and remote data fetching with optional server-side filtering.
 */
export function AutoTable(props: AutoTableProps) {
    const isRemote = () => !!props.url;
    const isLocalFiltering = () => !isRemote() || !props.remoteFiltering;

    const opts = createMemo(() => {
        const merged = {
            size: AUTOTABLE_SIZE_DEFAULT,
            shadow: false,
            hover: false,
            alternate: false,
            headerBorderY: false,
            surroundingBorder: false,
            borderX: false,
            borderY: false,
            color: AUTOTABLE_HEADER_COLOR_DEFAULT,
            tableLayoutAuto: false,
            hidePagination: false,
            paginationShowAll: false,
            draggableColumns: false,
            columnOrderStorageKey: undefined,
            toggleColumns: false,
            columnVisibilityStorageKey: undefined,
            resizableColumns: false,
            columnWidthStorageKey: undefined,
            resetButton: false,
            exportCSV: false,
            exportFilename: "export",
            pdfHeader: undefined,
            accordion: false,
            accordionSingle: false,
            searchAside: false,
            calculatedColumns: false,
            calculatedColumnsStorageKey: undefined,
            summaryRows: false,
            summaryRowsStorageKey: undefined,
            userCustomizeExport: false,
            ...props.options,
        };
        if (merged.inlineToolbar === undefined && props.searchFields && !merged.searchAside) {
            merged.inlineToolbar = !!(
                merged.exportCSV
                || merged.toggleColumns
                || props.exportMenuItems
                || props.toolbarActions
            );
        }
        return merged;
    });

    const accordionKey = () => props.accordionKey ?? "accordion";

    const [expandedRows, setExpandedRows] = createSignal(new Set() as Set<any>);

    const toggleAccordion = (rowKey: any) => {
        setExpandedRows(prev => {
            const next = new Set(prev);
            if (next.has(rowKey)) {
                next.delete(rowKey);
            } else {
                if (opts().accordionSingle) {
                    next.clear();
                }
                next.add(rowKey);
            }
            return next;
        });
    };

    const [remoteData, setRemoteData] = createSignal([] as any[]);
    const [initialLoading, setInitialLoading] = createSignal(isRemote());
    const [showLoading, setShowLoading] = createSignal(false);
    const [error, setError] = createSignal(null as string | null);
    const [isExporting, setIsExporting] = createSignal(false);

    // User-editable export settings (used when opts().userCustomizeExport). Seeded
    // from the developer-provided defaults; persist for the table's lifetime.
    const [exportFilenameInput, setExportFilenameInput] = createSignal(opts().exportFilename);
    const [pdfTitle, setPdfTitle] = createSignal(opts().pdfHeader?.title ?? "");
    const [pdfSubtitle, setPdfSubtitle] = createSignal(opts().pdfHeader?.subtitle ?? "");
    const [pdfOrientation, setPdfOrientation] = createSignal(String(opts().pdfHeader?.orientation ?? PDF_ORIENTATION_LANDSCAPE));
    const [pdfShowDate, setPdfShowDate] = createSignal(opts().pdfHeader?.showDate ?? true);

    const effectiveExportFilename = () => opts().userCustomizeExport ? (exportFilenameInput().trim() || "export") : opts().exportFilename;
    const effectivePdfHeader = (): AutoTablePDFHeader | undefined => {
        if (!opts().userCustomizeExport) return opts().pdfHeader;
        // showLogo and logoUrl stay developer-controlled (not user-editable).
        return {
            ...opts().pdfHeader,
            title: pdfTitle(),
            subtitle: pdfSubtitle(),
            orientation: parseInt(pdfOrientation(), 10) as PDFOrientation,
            showDate: pdfShowDate(),
        };
    };

    const [draggedColumn, setDraggedColumn] = createSignal(null as number | null);
    const [dragOverColumn, setDragOverColumn] = createSignal(null as number | null);
    const [draggedSummary, setDraggedSummary] = createSignal(null as number | null);
    const [dragOverSummary, setDragOverSummary] = createSignal(null as number | null);

    // -- Resizable columns ----------------------------------------------------
    // Widths (px) keyed by the same stable keys as the order system
    // ("d<index>" for data columns, "_calc_<id>" for calc columns).
    const loadColumnWidths = (): Record<string, number> => {
        const key = opts().columnWidthStorageKey;
        if (key) {
            try {
                const stored = localStorage.getItem(key);
                if (stored) {
                    const parsed = JSON.parse(stored);
                    if (parsed && typeof parsed === "object") return parsed;
                }
            } catch {
                // Ignore localStorage errors
            }
        }
        return {};
    };
    const [columnWidths, setColumnWidths] = createSignal(loadColumnWidths());
    const [resizingColumn, setResizingColumn] = createSignal(null as string | null);

    createEffect(() => {
        const key = opts().columnWidthStorageKey;
        if (key && opts().resizableColumns) {
            try {
                localStorage.setItem(key, JSON.stringify(columnWidths()));
            } catch {
                // Ignore localStorage errors
            }
        }
    });

    const MIN_COLUMN_WIDTH = 56;
    // Dragging a column boundary trades width between that column and its right
    // neighbor, keeping the total constant. This keeps the table within its
    // container (no runaway horizontal overflow) while still letting the user
    // set the proportions — the intuitive spreadsheet "move the boundary" feel.
    const startColumnResize = (e: MouseEvent, key: string) => {
        e.preventDefault();
        e.stopPropagation();
        const th = (e.currentTarget as HTMLElement).closest("th") as HTMLElement | null;
        const row = th?.parentElement;
        const startX = e.clientX;

        // Pin EVERY column to its current rendered width so the layout is fully
        // determined; without this the unsized columns reflow when one changes.
        const snapshot: Record<string, number> = { ...columnWidths() };
        const ths: HTMLElement[] = row
            ? Array.from(row.querySelectorAll("th[data-colkey]")) as HTMLElement[]
            : [];
        ths.forEach((el) => {
            const k = el.getAttribute("data-colkey");
            if (k) snapshot[k] = el.getBoundingClientRect().width;
        });
        setColumnWidths(snapshot);
        setResizingColumn(key);

        const startWidth = snapshot[key] ?? (th ? th.getBoundingClientRect().width : 120);
        // The right neighbor absorbs the change (the last column has no handle, so
        // a neighbor always exists). Falls back to a plain grow if none is found.
        const idx = ths.findIndex((el) => el.getAttribute("data-colkey") === key);
        const nextKey = idx >= 0 && idx + 1 < ths.length ? ths[idx + 1].getAttribute("data-colkey") : null;
        const startNextWidth = nextKey ? (snapshot[nextKey] ?? 0) : 0;

        const onMove = (ev: MouseEvent) => {
            const rawDelta = ev.clientX - startX;
            if (nextKey) {
                // Clamp so neither the dragged column nor its neighbor drops below
                // the minimum; the pair's combined width never changes.
                const delta = Math.max(
                    MIN_COLUMN_WIDTH - startWidth,
                    Math.min(startNextWidth - MIN_COLUMN_WIDTH, rawDelta),
                );
                const newWidth = Math.round(startWidth + delta);
                const newNext = startWidth + startNextWidth - newWidth;
                setColumnWidths(prev => ({ ...prev, [key]: newWidth, [nextKey]: newNext }));
            } else {
                const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(startWidth + rawDelta));
                setColumnWidths(prev => ({ ...prev, [key]: newWidth }));
            }
        };
        const onUp = () => {
            document.removeEventListener("mousemove", onMove);
            document.removeEventListener("mouseup", onUp);
            setResizingColumn(null);
        };
        document.addEventListener("mousemove", onMove);
        document.addEventListener("mouseup", onUp);
    };

    // Inline width style for a header cell, or undefined when unset.
    const columnWidthStyle = (key: string) => {
        const w = columnWidths()[key];
        return w ? { width: w + "px" } : undefined;
    };


    const getInitialVisibleColumns = () => {
        if (opts().columnVisibilityStorageKey) {
            try {
                const stored = localStorage.getItem(opts().columnVisibilityStorageKey);
                if (stored) {
                    const parsed = JSON.parse(stored);
                    if (Array.isArray(parsed)) {
                        return parsed;
                    }
                }
            } catch {
                // Ignore localStorage errors
            }
        }
        return props.columns
            .map((col, i) => ({ col, i }))
            .filter(({ col }) => !col.hiddenByDefault)
            .map(({ i }) => i.toString());
    };
    const [visibleColumns, setVisibleColumns] = createSignal(getInitialVisibleColumns());

    createEffect(on(
        () => props.columns.length,
        () => {
            const allIndices = props.columns.map((_, i) => i.toString());
            const validVisible = visibleColumns().filter(v => allIndices.includes(v));
            const newColumns = allIndices.filter(i => !visibleColumns().includes(i) && !props.columns[parseInt(i)]?.hiddenByDefault);
            if (newColumns.length > 0 || validVisible.length !== visibleColumns().length) {
                setVisibleColumns([...validVisible, ...newColumns]);
            }
        }
    ));

    createEffect(() => {
        if (opts().columnVisibilityStorageKey && opts().toggleColumns) {
            try {
                localStorage.setItem(opts().columnVisibilityStorageKey, JSON.stringify(visibleColumns()));
            } catch {
                // Ignore localStorage errors
            }
        }
    });

    // -- User-created calculated columns -------------------------------------
    const loadUserCalcColumns = (): UserCalculatedColumn[] => {
        const key = opts().calculatedColumnsStorageKey;
        if (key) {
            try {
                const stored = localStorage.getItem(key);
                if (stored) {
                    const parsed = JSON.parse(stored);
                    if (Array.isArray(parsed)) return parsed;
                }
            } catch {
                // Ignore localStorage errors
            }
        }
        return [];
    };
    const [userCalcColumns, setUserCalcColumns] = createSignal(loadUserCalcColumns());

    createEffect(() => {
        const key = opts().calculatedColumnsStorageKey;
        if (key && opts().calculatedColumns) {
            try {
                localStorage.setItem(key, JSON.stringify(userCalcColumns()));
            } catch {
                // Ignore localStorage errors
            }
        }
    });

    const CALC_REF_PREFIX = "_calc_";

    // Lookups rebuilt whenever the set of calc columns changes.
    const calcById = createMemo(() => {
        const m = new Map<string, UserCalculatedColumn>();
        for (const c of userCalcColumns()) m.set(c.id, c);
        return m;
    });
    // Maps a referenceable display name (lowercased) to an operand key, so custom
    // formulas can use [Display Name]. Data columns resolve to their
    // sortIdentifier; calc columns to "_calc_<id>".
    const calcNameToRef = createMemo(() => {
        const m = new Map<string, string>();
        for (const col of props.columns) if (col.sortIdentifier) m.set(col.displayName.trim().toLowerCase(), col.sortIdentifier);
        for (const c of userCalcColumns()) m.set(c.displayName.trim().toLowerCase(), CALC_REF_PREFIX + c.id);
        return m;
    });
    // Compile custom formulas once per change (not per row); invalid -> null.
    const compiledFormulas = createMemo(() => {
        const m = new Map<string, FormulaNode | null>();
        for (const c of userCalcColumns()) {
            if (c.fn === "custom") {
                try { m.set(c.id, compileFormula(c.formula ?? "")); }
                catch { m.set(c.id, null); }
            }
        }
        return m;
    });

    // Build the per-row resolvers a formula needs. `visiting` holds the chain of
    // calc column ids being evaluated so cycles resolve to NaN instead of
    // recursing forever. [refs] read the current row, {refs} the whole filtered
    // column (both may point at other calc columns).
    const makeCalcResolvers = (item: any, visiting: Set<string>) => {
        const resolveRef = (ref: string): number => {
            if (ref.startsWith(CALC_REF_PREFIX)) {
                const id = ref.slice(CALC_REF_PREFIX.length);
                if (visiting.has(id)) return NaN;
                const target = calcById().get(id);
                if (!target) return NaN;
                const next = new Set(visiting); next.add(id);
                return computeUserColumn(target, item, next);
            }
            return toCalcNumber(item?.[ref]);
        };
        const resolveColumn = (ref: string): number[] => {
            const rows = aggregateRows();
            if (ref.startsWith(CALC_REF_PREFIX)) {
                const id = ref.slice(CALC_REF_PREFIX.length);
                if (visiting.has(id)) return [];
                const target = calcById().get(id);
                if (!target) return [];
                const next = new Set(visiting); next.add(id);
                return rows.map(r => computeUserColumn(target, r, next));
            }
            return rows.map(r => toCalcNumber(r?.[ref]));
        };
        const rowIndex = () => { const i = aggregateRows().indexOf(item); return i < 0 ? NaN : i + 1; };
        return { resolveRef, resolveColumn, rowIndex };
    };

    // Evaluate a calc spec (a saved column or an unsaved draft) for one row.
    const evalCalcSpec = (spec: { fn: UserCalculatedColumn["fn"]; operands: string[]; formula?: string }, compiled: FormulaNode | null, item: any, visiting: Set<string>): number => {
        const { resolveRef, resolveColumn, rowIndex } = makeCalcResolvers(item, visiting);
        if (spec.fn === "custom") {
            if (!compiled) return NaN;
            const refs = calcNameToRef();
            const lookup = (name: string) => refs.get(name.trim().toLowerCase());
            return toScalar(compiled({
                cell: (name) => { const ref = lookup(name); return ref === undefined ? NaN : resolveRef(ref); },
                column: (name) => { const ref = lookup(name); return ref === undefined ? [] : resolveColumn(ref); },
                row: rowIndex,
            }));
        }
        return applyCalcFunction(spec.fn, spec.operands.map(resolveRef));
    };

    const computeUserColumn = (uc: UserCalculatedColumn, item: any, visiting: Set<string>): number =>
        evalCalcSpec(uc, uc.fn === "custom" ? (compiledFormulas().get(uc.id) ?? null) : null, item, visiting);

    // Each user column becomes a regular AutoTableColumn carrying a `calculated`
    // spec, so the existing render/sort/export paths handle it. The compute
    // closure resolves calc-in-calc references and custom formulas; the synthetic
    // sortIdentifier ("_calc_<id>") lets processDataLocally find and sort it.
    const userCalcToColumn = (uc: UserCalculatedColumn): AutoTableColumn => ({
        displayName: uc.displayName,
        displayPosition: uc.displayPosition ?? COL_POS_RIGHT,
        sortable: isLocalFiltering(),
        sortIdentifier: CALC_REF_PREFIX + uc.id,
        csv: true,
        calculated: {
            fn: uc.fn,
            operands: [],
            dataType: uc.dataType,
            precision: uc.precision,
            compute: (item: any) => computeUserColumn(uc, item, new Set([uc.id])),
        },
    });
    const calcColumns = createMemo(() => userCalcColumns().map(userCalcToColumn));
    // props.columns plus user calc columns — used for local sorting and export
    // so calculated columns participate in both.
    const effectiveColumns = createMemo(() => [...props.columns, ...calcColumns()]);

    // Columns the user can reference as operands: data columns with a field key,
    // plus other calculated columns (referenced as "_calc_<id>").
    const calcOperandOptions = createMemo(() => {
        const dataOpts = props.columns
            .filter(c => !!c.sortIdentifier)
            .map(c => ({ value: c.sortIdentifier!, label: c.displayName } as FormSelectOption));
        const calcOpts = userCalcColumns()
            .map(c => ({ value: CALC_REF_PREFIX + c.id, label: c.displayName } as FormSelectOption));
        return [...dataOpts, ...calcOpts];
    });

    const removeCalcColumn = (id: string) => setUserCalcColumns(prev => prev.filter(c => c.id !== id));
    const saveCalcColumn = (col: UserCalculatedColumn) => {
        setUserCalcColumns(prev => {
            const idx = prev.findIndex(c => c.id === col.id);
            if (idx >= 0) {
                const next = [...prev];
                next[idx] = col;
                return next;
            }
            return [...prev, col];
        });
    };

    // -- User-created footer summary rows ------------------------------------
    const loadUserSummaryRows = (): UserSummaryRow[] => {
        const key = opts().summaryRowsStorageKey;
        if (key) {
            try {
                const stored = localStorage.getItem(key);
                if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed; }
            } catch { /* ignore */ }
        }
        return [];
    };
    const [userSummaryRows, setUserSummaryRows] = createSignal(loadUserSummaryRows());
    createEffect(() => {
        const key = opts().summaryRowsStorageKey;
        if (key && opts().summaryRows) {
            try { localStorage.setItem(key, JSON.stringify(userSummaryRows())); } catch { /* ignore */ }
        }
    });
    const saveSummaryRow = (row: UserSummaryRow) => {
        setUserSummaryRows((prev: UserSummaryRow[]) => {
            const idx = prev.findIndex(r => r.id === row.id);
            if (idx >= 0) { const next = [...prev]; next[idx] = row; return next; }
            return [...prev, row];
        });
    };
    const removeSummaryRow = (id: string) => setUserSummaryRows((prev: UserSummaryRow[]) => prev.filter(r => r.id !== id));

    // Evaluate a summary formula once (no current row) over the filtered rows, then
    // format it. Reuses the column engine with item=null: [cell]/ROW() resolve to
    // NaN, {Col} aggregates resolve against aggregateRows().
    const computeSummaryValue = (s: UserSummaryRow): string => {
        const fn = s.fn ?? "custom";
        let n: number;
        if (fn === "custom") {
            let compiled: FormulaNode | null = null;
            try { compiled = compileFormula(s.formula ?? ""); } catch { return "—"; }
            n = evalCalcSpec({ fn: "custom", operands: [], formula: s.formula }, compiled, null, new Set());
        } else {
            // Basic: aggregate the chosen column down the (filtered) rows.
            const key = s.operands && s.operands[0];
            if (!key) return "—";
            const { resolveColumn } = makeCalcResolvers(null, new Set());
            n = applyCalcFunction(fn as CalculatedFunction, resolveColumn(key));
        }
        return formatCalcResult(n, s.dataType, s.precision, undefined, undefined, "—");
    };
    // Computed summary lines for PDF export (label + formatted value).
    const pdfSummaries = () => opts().summaryRows
        ? userSummaryRows().map((s: UserSummaryRow) => ({ label: s.label, value: computeSummaryValue(s) }))
        : [];


    // Columns the calc editor highlights on hover (operand keys: a data column's
    // sortIdentifier or "_calc_<id>"). A header is lit when its sortIdentifier is
    // in this set.
    const [highlightedCols, setHighlightedCols] = createSignal([] as string[]);
    const isColHighlighted = (col: AutoTableColumn) => !!col.sortIdentifier && highlightedCols().includes(col.sortIdentifier);

    // -- Column ordering (drag-to-reorder) -----------------------------------
    // Columns are ordered by stable string keys so data and calculated columns
    // can interleave: data columns are "d<index>", calc columns use their
    // sortIdentifier ("_calc_<id>"). Calc columns join the orderable list only
    // when a cellRenderer is present (dragging needs per-cell control); with a
    // rowRenderer they stay appended after the data columns and aren't draggable.
    const DATA_COLUMN_KEY = (i: number) => "d" + i;
    const calcMerged = () => !!props.cellRenderer;
    const appendedCalcColumns = () => calcMerged() ? [] : calcColumns();

    const allColumnKeys = createMemo(() => {
        const dataKeys = props.columns.map((_, i) => DATA_COLUMN_KEY(i));
        if (!calcMerged()) return dataKeys;
        return [...dataKeys, ...userCalcColumns().map(uc => CALC_REF_PREFIX + uc.id)];
    });

    const columnByKey = createMemo(() => {
        const m = new Map<string, { column: AutoTableColumn; originalIndex: number }>();
        props.columns.forEach((col, i) => m.set(DATA_COLUMN_KEY(i), { column: col, originalIndex: i }));
        if (calcMerged()) calcColumns().forEach((col) => m.set(col.sortIdentifier!, { column: col, originalIndex: -1 }));
        return m;
    });

    const getInitialColumnOrder = (): string[] => {
        if (opts().columnOrderStorageKey) {
            try {
                const stored = localStorage.getItem(opts().columnOrderStorageKey!);
                if (stored) {
                    const parsed = JSON.parse(stored);
                    if (Array.isArray(parsed) && parsed.every((x: any) => typeof x === "string")) {
                        return parsed;
                    }
                }
            } catch {
                // Ignore localStorage errors (and the pre-key numeric format)
            }
        }
        return allColumnKeys();
    };
    const [columnOrder, setColumnOrder] = createSignal(getInitialColumnOrder());

    // Reconcile the saved order with the current column set: keep known keys in
    // their order, append newly added columns, drop removed ones. Handles data
    // column changes and calc columns being added/removed/reordered.
    createEffect(() => {
        const keys = allColumnKeys();
        const keySet = new Set(keys);
        const current = columnOrder();
        const valid = current.filter(k => keySet.has(k));
        const missing = keys.filter(k => !current.includes(k));
        if (missing.length > 0 || valid.length !== current.length) {
            setColumnOrder([...valid, ...missing]);
        }
    });

    createEffect(() => {
        if (opts().columnOrderStorageKey && opts().draggableColumns) {
            try {
                localStorage.setItem(opts().columnOrderStorageKey, JSON.stringify(columnOrder()));
            } catch {
                // Ignore localStorage errors
            }
        }
    });

    let loadingTimerRef: ReturnType<typeof setTimeout> | null = null;
    let debounceTimerRef: ReturnType<typeof setTimeout> | null = null;
    let fetchIdRef = 0;

    const [filter, setFilter] = createSignal({
        Pagination: {
            Disabled: false,
            CurrentPage: 1,
            NextPage: 1,
            PreviousPage: 1,
            TotalPages: 1,
            TotalItems: 0,
            MaxItemsPerPage: opts().hidePagination ? -1 : 25,
            ItemsThisPage: 0,
            ViewRangeLower: 0,
            ViewRangeUpper: 0,
        },
        OrderBy: {
            Identifier: "",
            Descending: false,
        },
        Search: [] as AutoTableSearchEntry[],
        ...props.initialFilter,
    } as AutoTableFilter);

    const [searchInputs, setSearchInputs] = createSignal(props.initialFilter?.Search || []);
    const [searchGeneration, setSearchGeneration] = createSignal(0);
    const bumpSearchGeneration = () => setSearchGeneration((g) => g + 1);

    const sourceData = createMemo(() => isRemote() ? remoteData() : (props.data || []));

    // Rows that cross-row formula refs ({Column}) aggregate over: the filtered
    // set (search applied, sort/pagination ignored). Independent of localProcessed
    // so a calc column that aggregates can still be sorted without a cycle. For
    // server-side filtering only the loaded page is available.
    const aggregateRows = createMemo(() => isLocalFiltering()
        ? applySearchFilters(sourceData(), filter().Search)
        : remoteData());

    const localProcessed = createMemo(() => {
        if (!isLocalFiltering()) return null;
        return processDataLocally(sourceData(), filter(), effectiveColumns());
    });

    const displayData = createMemo(() => isLocalFiltering() ? (localProcessed()?.data || []) : remoteData());
    const allFilteredData = createMemo(() => isLocalFiltering() ? (localProcessed()?.allFilteredData || []) : remoteData());
    const displayPagination = createMemo(() => isLocalFiltering()
        ? (localProcessed()?.pagination || filter().Pagination)
        : filter().Pagination);

    createEffect(() => {
        displayPagination().CurrentPage;
        filter().OrderBy.Identifier;
        filter().OrderBy.Descending;
        filter().Search;
        setExpandedRows(new Set());
    });

    // When a highlightMatch predicate is supplied and the matching row lives
    // on a different page than the one currently shown, jump to that page.
    //
    // The effect tracks only the predicate identity (caller is expected to
    // memoize it - a new function ref means "new highlight target") and
    // props.data (so we re-evaluate once the data finishes loading). All
    // other AutoTable state - filter, allFilteredData, pagination - is read
    // via untrack so user-driven page changes don't re-fire the jump and
    // pin them to the highlighted page.
    createEffect(() => {
        const match = props.highlightMatch;
        if (!match) return;
        // Touch props.data so the effect re-runs when the dataset loads.
        const data = props.data;
        if (!data) return;
        untrack(() => {
            const all = allFilteredData();
            if (!all.length) return;
            const idx = all.findIndex((it: any) => match(it));
            if (idx < 0) return;
            const f = filter();
            const pageSize = f.Pagination.MaxItemsPerPage;
            if (!pageSize || pageSize < 0) return;
            const targetPage = Math.floor(idx / pageSize) + 1;
            if (targetPage !== f.Pagination.CurrentPage) {
                setFilter((prev: AutoTableFilter) => ({
                    ...prev,
                    Pagination: { ...prev.Pagination, CurrentPage: targetPage },
                }));
            }
        });
    });

    const fetchData = async (filterToUse: AutoTableFilter, isInitial = false) => {
        if (!props.url) return;

        const currentFetchId = ++fetchIdRef;
        setError(null);

        if (loadingTimerRef) {
            clearTimeout(loadingTimerRef);
            loadingTimerRef = null;
        }

        if (!isInitial) {
            loadingTimerRef = setTimeout(() => {
                setShowLoading(true);
            }, 500);
        }

        const startTime = isInitial ? Date.now() : 0;
        const minLoadingDuration = 300;

        try {
            const fetchUrl = !props.remoteFiltering ? props.url : props.url + "?" + buildQueryString(filterToUse);
            const response = props.requireAuth
                ? await authFetch(fetchUrl)
                : await fetch(fetchUrl);

            if (!response.ok) {
                throw new Error("HTTP " + response.status + ": " + response.statusText);
            }

            const result = await response.json();

            if (currentFetchId !== fetchIdRef) return;

            if (isInitial) {
                const elapsed = Date.now() - startTime;
                if (elapsed < minLoadingDuration) {
                    await new Promise(resolve => setTimeout(resolve, minLoadingDuration - elapsed));
                }
            }

            if (!props.remoteFiltering) {
                const data = Array.isArray(result) ? result : (result.data || []);
                setRemoteData(data);
            } else {
                setRemoteData(result.data || []);
                setFilter((prev) => ({
                    ...prev,
                    Pagination: result.filter.Pagination,
                    OrderBy: result.filter.OrderBy,
                }));
            }
        } catch (err) {
            if (currentFetchId !== fetchIdRef) return;
            setError(err instanceof Error ? err.message : "Unknown error");
        } finally {
            if (currentFetchId === fetchIdRef) {
                if (loadingTimerRef) {
                    clearTimeout(loadingTimerRef);
                    loadingTimerRef = null;
                }
                setInitialLoading(false);
                setShowLoading(false);
            }
        }
    };

    createEffect(() => {
        void props.refreshSignal;
        if (isRemote()) {
            fetchData(untrack(() => filter()), true);
        }
    });

    createEffect(() => {
        if (!props.onFilterChange) return;
        props.onFilterChange(filter());
    });

    const triggerFetch = (newFilter: AutoTableFilter) => {
        if (debounceTimerRef) {
            clearTimeout(debounceTimerRef);
            debounceTimerRef = null;
        }
        fetchData(newFilter);
    };

    const handleSort = (identifier: string) => {
        if (isLocalFiltering()) {
            setFilter((prev) => ({
                ...prev,
                OrderBy: {
                    Identifier: identifier,
                    Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false,
                },
                Pagination: { ...prev.Pagination, CurrentPage: 1 },
            }));
        } else {
            setFilter((prev) => {
                const newFilter = {
                    ...prev,
                    OrderBy: {
                        Identifier: identifier,
                        Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false,
                    },
                    Pagination: { ...prev.Pagination, CurrentPage: 1 },
                };
                triggerFetch(newFilter);
                return newFilter;
            });
        }
    };

    const getSortIdentifier = (col: AutoTableColumn, colIndex: number): string | null => {
        if (col.sortIdentifier) return col.sortIdentifier;
        if (isLocalFiltering()) return "_col_" + colIndex;
        return null;
    };

    const handlePageChange = (page: number) => {
        if (isLocalFiltering()) {
            setFilter((prev) => ({
                ...prev,
                Pagination: { ...prev.Pagination, CurrentPage: page },
            }));
        } else {
            setFilter((prev) => {
                const newFilter = {
                    ...prev,
                    Pagination: { ...prev.Pagination, CurrentPage: page },
                };
                triggerFetch(newFilter);
                return newFilter;
            });
        }
    };

    const handleItemsPerPageChange = (itemsPerPage: number) => {
        if (isLocalFiltering()) {
            setFilter((prev) => ({
                ...prev,
                Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 },
            }));
        } else {
            setFilter((prev) => {
                const newFilter = {
                    ...prev,
                    Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 },
                };
                triggerFetch(newFilter);
                return newFilter;
            });
        }
    };

    const handleSearch = (identifier: string, value: string, exact = false) => {
        const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => {
            const existingIndex = searchArray.findIndex(s => s.Identifier === identifier);
            let updated: AutoTableSearchEntry[];
            if (existingIndex >= 0) {
                updated = [...searchArray];
                updated[existingIndex] = { Identifier: identifier, Values: [value], Exact: exact };
            } else {
                updated = [...searchArray, { Identifier: identifier, Values: [value], Exact: exact }];
            }
            return updated.filter(s => s.Values.some(v => v));
        };

        if (isLocalFiltering()) {
            setFilter((prev) => ({
                ...prev,
                Search: updateSearchArray(prev.Search),
                Pagination: { ...prev.Pagination, CurrentPage: 1 },
            }));
        } else {
            const newSearchInputs = updateSearchArray(searchInputs());
            setSearchInputs(newSearchInputs);

            if (debounceTimerRef) clearTimeout(debounceTimerRef);

            debounceTimerRef = setTimeout(() => {
                debounceTimerRef = null;
                setFilter((prev) => {
                    const newFilter = {
                        ...prev,
                        Pagination: { ...prev.Pagination, CurrentPage: 1 },
                        Search: updateSearchArray(prev.Search),
                    };
                    fetchData(newFilter);
                    return newFilter;
                });
            }, 150);
        }
        bumpSearchGeneration();
    };

    const readSearchValue = (identifier: string): string => {
        if (isLocalFiltering()) {
            return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || "";
        }
        return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || "";
    };

    const getSearchValue = (identifier: string): string => {
        searchGeneration();
        return readSearchValue(identifier);
    };

    const setSearchValue = (identifier: string, value: string) => {
        handleSearch(identifier, value);
    };

    const setSearchValueExact = (identifier: string, value: string) => {
        handleSearch(identifier, value, true);
    };

    const getSearchValues = (identifier: string): string[] => {
        if (isLocalFiltering()) {
            return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || [];
        }
        return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || [];
    };

    const setSearchValues = (identifier: string, values: string[]) => {
        const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => {
            const existingIndex = searchArray.findIndex(s => s.Identifier === identifier);
            let updated: AutoTableSearchEntry[];
            if (existingIndex >= 0) {
                updated = [...searchArray];
                updated[existingIndex] = { Identifier: identifier, Values: values };
            } else {
                updated = [...searchArray, { Identifier: identifier, Values: values }];
            }
            return updated.filter(s => s.Values.length > 0);
        };

        if (isLocalFiltering()) {
            setFilter((prev) => ({
                ...prev,
                Search: updateSearchArray(prev.Search),
                Pagination: { ...prev.Pagination, CurrentPage: 1 },
            }));
        } else {
            const newSearchInputs = updateSearchArray(searchInputs());
            setSearchInputs(newSearchInputs);

            if (debounceTimerRef) clearTimeout(debounceTimerRef);

            debounceTimerRef = setTimeout(() => {
                debounceTimerRef = null;
                setFilter((prev) => {
                    const newFilter = {
                        ...prev,
                        Pagination: { ...prev.Pagination, CurrentPage: 1 },
                        Search: updateSearchArray(prev.Search),
                    };
                    fetchData(newFilter);
                    return newFilter;
                });
            }, 150);
        }
        bumpSearchGeneration();
    };

    const getMultiSearchValue = (fields: string[]): string => {
        return readSearchValue(createMultiSearchIdentifier(fields));
    };

    const setMultiSearchValue = (fields: string[], value: string) => {
        handleSearch(createMultiSearchIdentifier(fields), value);
    };

    const getHeaderColorClass = () => HEADER_COLOR_CLS[opts().color];
    const getHeaderSortHover = () => HEADER_SORT_HOVER_CLS[opts().color];
    const getHeaderTextClass = () => HEADER_TEXT_CLS[opts().color];
    const getHeaderDragClass = () => HEADER_DRAG_CLS[opts().color];
    const getHeaderSortIconClass = () => HEADER_SORT_ICON_CLS[opts().color];
    const getHeaderPaddingClass = () => HEADER_PADDING_CLS[opts().size];
    const getBodyPaddingClass = () => BODY_PADDING_CLS[opts().size];
    const getPaginationPaddingClass = () => PAGINATION_PADDING_CLS[opts().size];
    const getRowHoverClass = () => opts().hover ? ROW_HOVER_CLS[opts().color] : "";

    const handleDragStart = (e: DragEvent, orderIndex: number) => {
        if (!opts().draggableColumns) return;
        setDraggedColumn(orderIndex);
        e.dataTransfer!.effectAllowed = "move";
        e.dataTransfer!.setData("text/plain", orderIndex.toString());
    };

    const handleDragOver = (e: DragEvent, orderIndex: number) => {
        if (!opts().draggableColumns || draggedColumn() === null) return;
        e.preventDefault();
        e.dataTransfer!.dropEffect = "move";
        if (dragOverColumn() !== orderIndex) {
            setDragOverColumn(orderIndex);
        }
    };

    const handleDragLeave = () => {
        setDragOverColumn(null);
    };

    const handleDrop = (e: DragEvent, targetOrderIndex: number) => {
        if (!opts().draggableColumns || draggedColumn() === null) return;
        e.preventDefault();

        if (draggedColumn() !== targetOrderIndex) {
            setColumnOrder(prev => {
                const newOrder = [...prev];
                const [removed] = newOrder.splice(draggedColumn() as number, 1);
                newOrder.splice(targetOrderIndex, 0, removed);
                return newOrder;
            });
        }

        setDraggedColumn(null);
        setDragOverColumn(null);
    };

    const handleDragEnd = () => {
        setDraggedColumn(null);
        setDragOverColumn(null);
    };

    // -- Summary row reordering (drag-to-reorder, via the grip handle) --------
    const handleSummaryDragStart = (e: DragEvent, index: number) => {
        setDraggedSummary(index);
        e.dataTransfer!.effectAllowed = "move";
        e.dataTransfer!.setData("text/plain", index.toString());
    };
    const handleSummaryDragOver = (e: DragEvent, index: number) => {
        if (draggedSummary() === null) return;
        e.preventDefault();
        e.dataTransfer!.dropEffect = "move";
        if (dragOverSummary() !== index) setDragOverSummary(index);
    };
    const handleSummaryDrop = (e: DragEvent, target: number) => {
        if (draggedSummary() === null) return;
        e.preventDefault();
        const from = draggedSummary() as number;
        if (from !== target) {
            setUserSummaryRows((prev: UserSummaryRow[]) => {
                const next = [...prev];
                const [moved] = next.splice(from, 1);
                next.splice(target, 0, moved);
                return next;
            });
        }
        setDraggedSummary(null);
        setDragOverSummary(null);
    };
    const handleSummaryDragEnd = () => { setDraggedSummary(null); setDragOverSummary(null); };

    const resetColumnOrder = () => {
        setColumnOrder(allColumnKeys());
    };
    const resetColumnWidths = () => setColumnWidths({});
    const resetCalcColumns = () => setUserCalcColumns([]);
    const resetSummaryRows = () => setUserSummaryRows([]);
    const resetAll = () => {
        resetCalcColumns();
        resetSummaryRows();
        resetColumnWidths();
        resetColumnOrder();
    };
    // Show the reset menu only when there's something resettable enabled.
    const canReset = () => opts().resetButton && (opts().draggableColumns || opts().resizableColumns || opts().calculatedColumns || opts().summaryRows);

    const isLoading = () => initialLoading() || showLoading();

    // The ordered, visible columns header & body iterate over. Built from the
    // stable-key order (or natural order when not draggable); calc columns are
    // included here only when merged (cellRenderer present). orderIndex is the
    // position within the full order, so drag handlers splice columnOrder by it.
    const displayColumnsWithIndices = createMemo(() => {
        const byKey = columnByKey();
        const keys = opts().draggableColumns ? columnOrder() : allColumnKeys();
        const mapped = keys
            .map((k, orderIdx) => {
                const entry = byKey.get(k);
                return entry ? { column: entry.column, originalIndex: entry.originalIndex, orderIndex: orderIdx, key: k } : null;
            })
            .filter(Boolean) as { column: AutoTableColumn; originalIndex: number; orderIndex: number; key: string }[];

        if (!opts().toggleColumns) return mapped;

        // Calc columns aren't part of the toggle set — always keep them.
        return mapped.filter(({ column, originalIndex }) =>
            column.calculated || column.toggleable === false || visibleColumns().includes(originalIndex.toString()));
    });

    // Columns shown: those in the ordered list plus calc columns appended when
    // not merged into the order (rowRenderer case).
    const renderedColumnCount = () => displayColumnsWithIndices().length + appendedCalcColumns().length;
    const totalColumnCount = () => renderedColumnCount() + (opts().accordion ? 1 : 0);

    // When every visible column has a pinned width, give the table an explicit
    // px width equal to their sum. table-fixed then honors each width exactly, so
    // resizing one column only moves that boundary (Excel-style) instead of
    // reflowing the others; the table grows/scrolls as widths change. Returns
    // null (→ min-w-full) until all columns are pinned, e.g. before first resize.
    // The accordion toggle column is a fixed 40px (w-10).
    const ACCORDION_TOGGLE_WIDTH = 40;
    const pinnedTableWidth = createMemo(() => {
        if (!opts().resizableColumns) return null;
        const widths = columnWidths();
        if (Object.keys(widths).length === 0) return null;
        let sum = opts().accordion ? ACCORDION_TOGGLE_WIDTH : 0;
        for (const c of displayColumnsWithIndices()) {
            const w = widths[c.key];
            if (w == null) return null;
            sum += w;
        }
        for (const c of appendedCalcColumns()) {
            const w = c.sortIdentifier ? widths[c.sortIdentifier] : undefined;
            if (w == null) return null;
            sum += w;
        }
        return sum;
    });

    const hasCalculatedColumns = createMemo(() => props.columns.some(c => !!c.calculated) || userCalcColumns().length > 0);

    // Calculated columns are rendered by AutoTable itself (the cellRenderer never
    // sees them). Numeric results read best right-aligned, so default to that.
    const renderCalculatedCell = (col: AutoTableColumn, item: any) => {
        const pos = col.displayPosition ?? COL_POS_RIGHT;
        return <td class={POS_CLS[pos]}>{formatCalculatedValue(item, col.calculated!)}</td>;
    };

    const columnToggleOptions = createMemo(() => {
        return props.columns
            .map((col, i) => ({ value: i.toString(), label: col.displayName, toggleable: col.toggleable }))
            .filter(opt => opt.toggleable !== false);
    });

    const handleExportCSV = async (): Promise<void> => {
        if (isExporting()) return;
        setIsExporting(true);

        try {
            const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];

            if (isLocalFiltering()) {
                const filterWithoutPagination: AutoTableFilter = {
                    ...filter(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
                };
                const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
                downloadCSV(processed.data, exportColumns, effectiveExportFilename());
            } else if (props.url && props.remoteFiltering) {
                const exportFilter: AutoTableFilter = {
                    ...filter(),
                    Search: searchInputs(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
                };
                const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
                const response = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);

                if (!response.ok) {
                    throw new Error("HTTP " + response.status + ": " + response.statusText);
                }

                const result = await response.json();
                downloadCSV(result.data || [], exportColumns, effectiveExportFilename());
            }
        } catch (err) {
            console.error("Export failed:", err);
        } finally {
            setIsExporting(false);
        }
    };

    const handleExportPDF = async () => {
        if (isExporting()) return;
        setIsExporting(true);

        try {
            const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];

            if (isLocalFiltering()) {
                const filterWithoutPagination = {
                    ...filter(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
                };
                const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
                await downloadPDF(processed.data, exportColumns, effectiveExportFilename(), effectivePdfHeader(), null, pdfSummaries());
            } else if (props.url && props.remoteFiltering) {
                const exportFilter = {
                    ...filter(),
                    Search: searchInputs(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
                };
                const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
                const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);

                if (!resp.ok) {
                    throw new Error("HTTP " + resp.status + ": " + resp.statusText);
                }

                const result = await resp.json();
                await downloadPDF(result.data || [], exportColumns, effectiveExportFilename(), effectivePdfHeader(), result, pdfSummaries());
            }
        } catch (err) {
            console.error("Export failed:", err);
        } finally {
            setIsExporting(false);
        }
    };

    const handlePrintPDF = async () => {
        if (isExporting()) return;
        setIsExporting(true);

        try {
            const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];
            let pdfBytes = null;

            if (isLocalFiltering()) {
                const filterWithoutPagination = {
                    ...filter(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
                };
                const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
                pdfBytes = await buildTablePDF(processed.data, exportColumns, effectivePdfHeader(), null, pdfSummaries());
            } else if (props.url && props.remoteFiltering) {
                const exportFilter = {
                    ...filter(),
                    Search: searchInputs(),
                    Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
                };
                const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
                const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);

                if (!resp.ok) {
                    throw new Error("HTTP " + resp.status + ": " + resp.statusText);
                }

                const result = await resp.json();
                pdfBytes = await buildTablePDF(result.data || [], exportColumns, effectivePdfHeader(), result, pdfSummaries());
            }

            if (pdfBytes) {
                const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" });
                const url = URL.createObjectURL(blob);
                const iframe = document.createElement("iframe");
                iframe.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;border:none;opacity:0;pointer-events:none";
                iframe.src = url;
                iframe.onload = () => {
                    iframe.contentWindow!.focus();
                    iframe.contentWindow!.print();
                };
                document.body.appendChild(iframe);
            }
        } catch (err) {
            console.error("PDF print failed:", err);
        } finally {
            setIsExporting(false);
        }
    };

    const showExportMenu = () => opts().exportCSV || !!props.exportMenuItems;

    const showToolbar = () => opts().toggleColumns || showExportMenu() || opts().calculatedColumns || opts().summaryRows || !!props.toolbarActions;
    // Actions are only "on the filter row" when a filter row exists. Without
    // searchFields there's nothing to push them right, so fall through to
    // TOOLBAR_ACTIONS (lg:ml-auto) instead of the inline variant and keep the
    // toolbar (e.g. a lone Export button) right-aligned.
    const actionsOnFilterRow = () => !!props.searchFields && (opts().inlineToolbar || showToolbar());
    const toolbarActionsCls = () => actionsOnFilterRow() ? TOOLBAR_ACTIONS_INLINE : TOOLBAR_ACTIONS;
    const desktopRowCls = () => TOOLBAR_DESKTOP_ROW;
    const panelActionsCls = () => actionsOnFilterRow() ? PANEL_ACTIONS_INLINE : PANEL_ACTIONS_DEFAULT;

    const resolveExportMenuItems = () => {
        const items = props.exportMenuItems;
        if (items == null) return null;
        return typeof items === "function" ? items({ allFilteredData }) : items;
    };

    const toolbarButtons = () => showToolbar() ? (
        <div class={toolbarActionsCls()}>
            {props.toolbarActions && props.toolbarActions({ allFilteredData })}
            <Show when={opts().calculatedColumns || opts().summaryRows}>
                <Popover placement="bottom-end">
                    <PopoverTrigger class={CALC_ADD_TRIGGER_CLS}>
                        <Icon icon="plus" size={16} style={{ display: "inline", marginRight: "4px" }} />
                        Add Calculation
                    </PopoverTrigger>
                    <PopoverContent>
                        <AddCalcMenu
                            allowColumn={opts().calculatedColumns}
                            allowSummary={opts().summaryRows}
                            operandOptions={calcOperandOptions}
                            onSaveColumn={saveCalcColumn}
                            onSaveSummary={saveSummaryRow}
                            onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
                        />
                    </PopoverContent>
                </Popover>
            </Show>
            <Show when={opts().toggleColumns}>
                <FormMultiSelectTrigger
                    trigger={<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
                        <Icon icon="table-columns" size={16} style={{ display: "inline", marginRight: "4px" }} />
                        Columns
                    </ButtonUI>}
                    options={columnToggleOptions()}
                    value={visibleColumns()}
                    onchange={setVisibleColumns}
                    align="right"
                    minWidth={180}
                    showSelectAll={true}
                    searchable={true}
                    small={true}
                />
            </Show>
            <Show when={opts().exportCSV && opts().userCustomizeExport}>
                <Popover placement="bottom-end">
                    <PopoverTrigger class={CALC_ADD_TRIGGER_CLS} title="Export options">
                        <Icon icon="cog" size={16} style={{ display: "inline", marginRight: "4px" }} />
                        Export Options
                    </PopoverTrigger>
                    <PopoverContent>
                        <div class="flex flex-col gap-3 p-4 w-72">
                            <div class="text-sm font-semibold text-neutral-700">Export settings</div>
                            <div>
                                <FormLabel>File name</FormLabel>
                                <FormInput small={true} class="w-full" placeholder="export"
                                    value={exportFilenameInput()}
                                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setExportFilenameInput(e.currentTarget.value)} />
                            </div>
                            <div>
                                <FormLabel>PDF title</FormLabel>
                                <FormInput small={true} class="w-full" placeholder="(none)"
                                    value={pdfTitle()}
                                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfTitle(e.currentTarget.value)} />
                            </div>
                            <div>
                                <FormLabel>PDF subtitle</FormLabel>
                                <FormInput small={true} class="w-full" placeholder="(none)"
                                    value={pdfSubtitle()}
                                    oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfSubtitle(e.currentTarget.value)} />
                            </div>
                            <div>
                                <FormLabel>Orientation</FormLabel>
                                <FormSelect small={true} class="w-full"
                                    value={pdfOrientation()}
                                    onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPdfOrientation(e.currentTarget.value)}>
                                    <option value={String(PDF_ORIENTATION_LANDSCAPE)}>Landscape</option>
                                    <option value={String(PDF_ORIENTATION_PORTRAIT)}>Portrait</option>
                                </FormSelect>
                            </div>
                            <label class="flex items-center gap-2 text-sm text-neutral-700 cursor-pointer">
                                <input type="checkbox" checked={pdfShowDate()} onchange={(e: Event & { currentTarget: HTMLInputElement }) => setPdfShowDate(e.currentTarget.checked)} />
                                Show date
                            </label>
                        </div>
                    </PopoverContent>
                </Popover>
            </Show>
            <Show when={showExportMenu()}>
                <Menu placement="bottom-end">
                    <MenuTrigger>
                        <ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} disabled={isExporting()}>
                            <Icon icon="download" size={16} style={{ display: "inline", marginRight: "4px" }} />
                            Export
                        </ButtonUI>
                    </MenuTrigger>
                    <MenuContent>
                        <Show when={opts().exportCSV || props.exportMenuItems}>
                            <MenuSection>Spreadsheet</MenuSection>
                        </Show>
                        <Show when={opts().exportCSV}>
                            <MenuItem
                                icon="download"
                                onclick={(_e: MouseEvent) => handleExportCSV()}
                                disabled={isExporting()}
                            >
                                {isExporting() ? "Exporting..." : "Export CSV"}
                            </MenuItem>
                        </Show>
                        <Show when={!!resolveExportMenuItems()}>
                            {resolveExportMenuItems()}
                        </Show>
                        <Show when={opts().exportCSV}>
                            <MenuSection>PDF</MenuSection>
                            <MenuItem
                                icon="download"
                                onclick={(_e: MouseEvent) => handleExportPDF()}
                                disabled={isExporting()}
                            >
                                {isExporting() ? "Exporting..." : "Export PDF"}
                            </MenuItem>
                            <MenuItem
                                icon="print"
                                onclick={(_e: MouseEvent) => handlePrintPDF()}
                                disabled={isExporting()}
                            >
                                {isExporting() ? "Loading..." : "Print PDF"}
                            </MenuItem>
                        </Show>
                    </MenuContent>
                </Menu>
            </Show>
        </div>
    ) : null;

    // Main render

    const searchFieldsCtx: SearchFieldsContext = {
        getSearchValue, setSearchValue, setSearchValueExact,
        getSearchValues, setSearchValues, getMultiSearchValue,
        setMultiSearchValue, resetColumnOrder,
    };

    const [filtersOpen, setFiltersOpen] = createSignal(false);
    const activeFilterCount = createMemo(() => {
        const searches = isLocalFiltering() ? filter().Search : searchInputs();
        return searches.filter((s: AutoTableSearchEntry) => s.Values.some(v => v)).length;
    });
    const toggleFilters = () => setFiltersOpen(v => !v);
    const inlineFiltersBodyCls = () => {
        let cls = actionsOnFilterRow() ? FILTERS_BODY_INLINE : FILTERS_BODY_BASE + " " + FILTERS_BODY_DESKTOP_FLEX;
        cls += filtersOpen() ? " max-lg:flex" : " max-lg:hidden";
        return cls;
    };
    const asideFiltersBodyCls = () =>
        FILTERS_BODY_ASIDE + (filtersOpen() ? " max-lg:flex" : " max-lg:hidden") + " lg:flex";

    const filtersToggleBtn = <button
        type="button"
        class={FILTERS_TOGGLE}
        onclick={toggleFilters}
        aria-expanded={filtersOpen()}
    >
        <span class="flex items-center gap-2">
            Filters
            <Show when={activeFilterCount() > 0}>
                <span class={FILTERS_BADGE}>{activeFilterCount()}</span>
            </Show>
        </span>
        <Icon
            icon="chevron-down"
            size={16}
            class={"shrink-0 transition-transform " + (filtersOpen() ? "rotate-180" : "")}
        />
    </button>;

    const toolbarPanelActions = () => showToolbar() ? (
        <div class={panelActionsCls()}>
            {toolbarButtons()}
        </div>
    ) : null;

    return <div class="min-w-0 w-full max-w-full">
        <Show when={(props.searchFields && !opts().searchAside) || toolbarButtons()}>
            <div class={TOOLBAR}>
                <div class={TOOLBAR_MOBILE_BAR}>
                    <Show when={props.searchFields && !opts().searchAside}>
                        {filtersToggleBtn}
                    </Show>
                </div>
                <div class={desktopRowCls()}>
                    <Show when={props.searchFields && !opts().searchAside}>
                        <div class={inlineFiltersBodyCls()}>
                            {untrack(() => props.searchFields!(searchFieldsCtx))}
                            {toolbarPanelActions()}
                        </div>
                    </Show>
                    <Show when={!props.searchFields && showToolbar()}>
                        <div class="hidden lg:contents">
                            {toolbarButtons()}
                        </div>
                    </Show>
                </div>
            </div>
        </Show>
        <div class={opts().searchAside ? "flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-start" : ""}>
        <Show when={opts().searchAside && props.searchFields}>
            <aside class={SEARCH_CARD + " flex flex-col gap-3"}>
                {filtersToggleBtn}
                <div class={asideFiltersBodyCls()}>
                    {untrack(() => props.searchFields!(searchFieldsCtx))}
                    {toolbarPanelActions()}
                </div>
            </aside>
        </Show>
        <div class={opts().searchAside ? "flex-1 min-w-0" : ""}>
        {props.aboveTable}
        <div class={TBL_CONTAINER
            + (opts().surroundingBorder ? " border border-neutral-300" : "")
            + (opts().shadow ? " shadow-sm" : "")}>
            <div class={TBL_WRAPPER}>
                <table
                    style={pinnedTableWidth() != null ? { width: pinnedTableWidth() + "px" } : undefined}
                    class={"border-collapse" + (pinnedTableWidth() != null ? "" : " " + TBL_BASE) + (opts().tableLayoutAuto ? "" : " table-fixed")}
                >
                    <thead class="[&_th]:border-b [&_th]:border-neutral-300">
                        <tr>
                            <Show when={opts().accordion}>
                                <th class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + ACCORDION_TOGGLE_TH} />
                            </Show>
                            <Show when={renderedColumnCount() === 0}>
                                <th class={getHeaderPaddingClass() + " " + getHeaderColorClass()}>&nbsp;</th>
                            </Show>
                            <For each={displayColumnsWithIndices()}>
                                {({ column: col, originalIndex: originalIdx, orderIndex: orderIdx, key: colKey }, displayIdx) => {
                                    const isDragging = () => draggedColumn() === orderIdx;
                                    const isDragOver = () => dragOverColumn() === orderIdx && draggedColumn() !== orderIdx;
                                    const pos = col.displayPosition ?? COL_POS_LEFT;
                                    const posCls = POS_CLS[pos];
                                    const headerInnerPosCls = HEADER_INNER_POS[pos];

                                    return <th
                                        data-colkey={colKey}
                                        draggable={opts().draggableColumns && resizingColumn() === null}
                                        onDragStart={(e) => handleDragStart(e, orderIdx)}
                                        onDragOver={(e) => handleDragOver(e, orderIdx)}
                                        onDragLeave={handleDragLeave}
                                        onDrop={(e) => handleDrop(e, orderIdx)}
                                        onDragEnd={handleDragEnd}
                                        style={columnWidthStyle(colKey)}
                                        class={getHeaderPaddingClass() + " " + getHeaderColorClass()
                                            + (opts().resizableColumns ? " relative" : "")
                                            + (posCls ? " " + posCls : "")
                                            + (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "")
                                            + (col.sortable ? " cursor-pointer" : "")
                                            + (col.sortable && !resizingColumn() ? " " + getHeaderSortHover() : "")
                                            + (opts().draggableColumns ? " select-none cursor-grab active:cursor-grabbing" : "")
                                            + (isDragOver() ? "outline-2 outline-sky-500 -outline-offset-2" : "")
                                            + (isColHighlighted(col) ? "outline-2 -outline-offset-2 outline-amber-400" : "")
                                            + (col.headerClasses ? " " + col.headerClasses : "")}
                                        onclick={() => {
                                            if (col.sortable) {
                                                const sortId = getSortIdentifier(col, originalIdx);
                                                if (sortId) handleSort(sortId);
                                            }
                                        }}
                                    >
                                        <Show when={opts().resizableColumns && !(appendedCalcColumns().length === 0 && displayIdx() === displayColumnsWithIndices().length - 1)}>
                                            <div class={RESIZE_HANDLE_CLS}
                                                onMouseDown={(e: MouseEvent) => startColumnResize(e, colKey)}
                                                onclick={(e: MouseEvent) => e.stopPropagation()}></div>
                                        </Show>
                                        <div class={HEADER_CONTENT + (isDragging() ? " scale-95" : "")}>
                                            <div class={HEADER_INNER_BASE + (headerInnerPosCls ? " " + headerInnerPosCls : "")}>
                                                <Show when={opts().draggableColumns}>
                                                    <span class={DRAG_HANDLE + " " + getHeaderDragClass()}>
                                                        <Icon icon="grip-vertical" size={14} />
                                                    </span>
                                                </Show>
                                                <Show when={!!col.calculated}>
                                                    <span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
                                                        <Popover placement="bottom-end">
                                                            <PopoverTrigger
                                                                class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
                                                                title="Edit calculation">
                                                                <Icon icon="pen-to-square" size={13} />
                                                            </PopoverTrigger>
                                                            <PopoverContent>
                                                                <CalculatedColumnForm
                                                                    column={() => calcById().get((col.sortIdentifier || "").slice(CALC_REF_PREFIX.length)) ?? null}
                                                                    operandOptions={calcOperandOptions}
                                                                    onSave={saveCalcColumn}
                                                                    onRemove={removeCalcColumn}
                                                                    onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
                                                                />
                                                            </PopoverContent>
                                                        </Popover>
                                                    </span>
                                                </Show>
                                                <div class={"grow text-sm " + getHeaderTextClass()}>
                                                    {col.displayName}
                                                </div>
                                                <Show when={col.sortable}>
                                                    <div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
                                                        <Show when={filter().OrderBy.Identifier && filter().OrderBy.Identifier === getSortIdentifier(col, originalIdx)}>
                                                            <Show when={filter().OrderBy.Descending}
                                                                fallback={<Icon icon="caret-up" size={16} solid={true} />}>
                                                                <Icon icon="caret-down" size={16} solid={true} />
                                                            </Show>
                                                        </Show>
                                                    </div>
                                                </Show>
                                            </div>
                                        </div>
                                    </th>;
                                }}
                            </For>
                            <For each={calcMerged() ? [] : userCalcColumns()}>
                                {(uc: UserCalculatedColumn, calcIdx) => {
                                    const sortId = "_calc_" + uc.id;
                                    const sortable = () => isLocalFiltering();
                                    const pos = uc.displayPosition ?? COL_POS_RIGHT;
                                    const isLastColumn = () => calcIdx() === userCalcColumns().length - 1;
                                    return <th
                                        data-colkey={sortId}
                                        style={columnWidthStyle(sortId)}
                                        class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + POS_CLS[pos]
                                        + (opts().resizableColumns ? " relative" : "")
                                        + (opts().headerBorderY ? " border-l border-l-neutral-300" : "")
                                        + (highlightedCols().includes(sortId) ? "outline-2 -outline-offset-2 outline-amber-400" : "")
                                        + (sortable() ? " cursor-pointer" : "")
                                        + (sortable() && !resizingColumn() ? " " + getHeaderSortHover() : "")}
                                        onclick={() => { if (sortable()) handleSort(sortId); }}>
                                        <Show when={opts().resizableColumns && !isLastColumn()}>
                                            <div class={RESIZE_HANDLE_CLS}
                                                onMouseDown={(e: MouseEvent) => startColumnResize(e, sortId)}
                                                onclick={(e: MouseEvent) => e.stopPropagation()}></div>
                                        </Show>
                                        <div class={HEADER_CONTENT}>
                                            <div class={HEADER_INNER_BASE + " " + HEADER_INNER_POS[pos]}>
                                                <span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
                                                    <Popover placement="bottom-end">
                                                        <PopoverTrigger
                                                            class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
                                                            title="Edit calculation">
                                                            <Icon icon="pen-to-square" size={13} />
                                                        </PopoverTrigger>
                                                        <PopoverContent>
                                                            <CalculatedColumnForm
                                                                column={() => uc}
                                                                operandOptions={calcOperandOptions}
                                                                onSave={saveCalcColumn}
                                                                onRemove={removeCalcColumn}
                                                                onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
                                                            />
                                                        </PopoverContent>
                                                    </Popover>
                                                </span>
                                                <div class={"grow text-sm " + getHeaderTextClass()}>
                                                    {uc.displayName}
                                                </div>
                                                <Show when={sortable()}>
                                                    <div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
                                                        <Show when={filter().OrderBy.Identifier === sortId}>
                                                            <Show when={filter().OrderBy.Descending}
                                                                fallback={<Icon icon="caret-up" size={16} solid={true} />}>
                                                                <Icon icon="caret-down" size={16} solid={true} />
                                                            </Show>
                                                        </Show>
                                                    </div>
                                                </Show>
                                            </div>
                                        </div>
                                    </th>;
                                }}
                            </For>
                        </tr>
                    </thead>
                    <tbody class={getBodyPaddingClass() + (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-neutral-300" : "")}>
                        <Show when={isLoading()}>
                            <For each={Array.from({ length: 5 })}>
                                {(_, rowIdx) => <tr class={opts().alternate && rowIdx() % 2 === 1 ? "bg-neutral-100" : ""}>
                                    <Show when={opts().accordion}>
                                        <td></td>
                                    </Show>
                                    <For each={[...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]}>
                                        {() => <td>
                                            <div
                                                class={SKELETON}
                                                style={{ width: (60 + Math.random() * 30) + "%" }}
                                            ></div>
                                        </td>}
                                    </For>
                                </tr>}
                            </For>
                        </Show>
                        <Show when={!isLoading() && error()}>
                            <tr>
                                <td colspan={totalColumnCount() || 1} class={ERROR_CELL}>
                                    Error: {error()}
                                </td>
                            </tr>
                        </Show>
                        <Show when={!isLoading() && !error() && renderedColumnCount() === 0}>
                            <tr>
                                <td colspan={totalColumnCount() || 1} class={EMPTY_CELL}>
                                    No columns selected.
                                </td>
                            </tr>
                        </Show>
                        <Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length === 0}>
                            <tr>
                                <td colspan={totalColumnCount()}>
                                    {(typeof props.emptyMessage === "function" ? props.emptyMessage() : props.emptyMessage) || "No entries found."}
                                </td>
                            </tr>
                        </Show>
                        <Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length > 0}>
                            <For each={displayData()}>
                                {(item, rowIdx) => {
                                    const position = () => (displayPagination().CurrentPage - 1) * displayPagination().MaxItemsPerPage + rowIdx() + 1;
                                    const isLastRow = () => rowIdx() === displayData().length - 1;
                                    const rowKey = () => item.id ?? rowIdx();
                                    const isExpanded = () => opts().accordion && expandedRows().has(rowKey());
                                    const isHighlighted = () => !!(props.highlightMatch && props.highlightMatch(item));
                                    return [
                                        <tr
                                            class={(() => {
                                                let c = "";
                                                if (isHighlighted()) {
                                                    // Highlight overrides alternate + hover with !important.
                                                    c += "bg-amber-100! [&>td:first-child]:shadow-[inset_3px_0_0_var(--color-amber-500)] ";
                                                } else {
                                                    if (opts().alternate && rowIdx() % 2 === 1) c += "bg-neutral-100 ";
                                                    c += getRowHoverClass() + " ";
                                                }
                                                if (isExpanded() || (opts().borderX && !isLastRow())) c += "border-b border-neutral-300 ";
                                                if (opts().accordion) c += "cursor-pointer select-none ";
                                                return c.trim();
                                            })()}
                                            onclick={opts().accordion ? () => toggleAccordion(rowKey()) : undefined}
                                        >
                                            <Show when={opts().accordion}>
                                                <td class={ACCORDION_TOGGLE_TD}>
                                                    <span class={ACCORDION_ICON + (isExpanded() ? " rotate-90" : "")}>
                                                        <Icon icon="chevron-right" size={14} />
                                                    </span>
                                                </td>
                                            </Show>
                                            <Show when={(opts().draggableColumns || opts().toggleColumns || opts().accordion || opts().resizableColumns || hasCalculatedColumns()) && props.cellRenderer}
                                                fallback={props.rowRenderer ? props.rowRenderer(item, position(), rowIdx()) : null}
                                            >
                                                <For each={displayColumnsWithIndices()}>
                                                    {({ column: col, originalIndex: originalIdx }) => {
                                                        if (col.calculated) return renderCalculatedCell(col, item);
                                                        return props.cellRenderer!(item, col, originalIdx, position(), rowIdx());
                                                    }}
                                                </For>
                                            </Show>
                                            <For each={appendedCalcColumns()}>
                                                {(col: AutoTableColumn) => renderCalculatedCell(col, item)}
                                            </For>
                                        </tr>,
                                        <Show when={isExpanded() && props.accordionRenderer}>
                                            <tr class={opts().borderX && !isLastRow() ? "border-b border-neutral-300" : ""}>
                                                <td colspan={totalColumnCount()}>
                                                    {props.accordionRenderer!(item, item[accordionKey()])}
                                                </td>
                                            </tr>
                                        </Show>
                                    ];
                                }}
                            </For>
                        </Show>
                    </tbody>
                    <Show when={opts().summaryRows && userSummaryRows().length > 0}>
                        <tfoot class={getBodyPaddingClass()
                            + " bg-neutral-50 text-neutral-700"
                            + " [&_tr:first-child_td]:border-t [&_tr:first-child_td]:border-neutral-300 [&_tr:not(:first-child)_td]:border-t [&_tr:not(:first-child)_td]:border-neutral-200"
                            + (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-neutral-300" : "")}>
                            <For each={userSummaryRows()}>
                                {(s: UserSummaryRow, sIdx: () => number) => {
                                    const value = () => computeSummaryValue(s);
                                    // Always right-aligned: the value sits in the last column and the label
                                    // in the cell immediately to its left (or they share the one cell in a
                                    // single-column table). All other cells stay empty.
                                    const cols = () => [...displayColumnsWithIndices().map((e: { column: AutoTableColumn }) => e.column), ...appendedCalcColumns()];
                                    const valueIdx = () => cols().length - 1;
                                    const labelIdx = () => { const v = valueIdx(); return v > 0 ? v - 1 : v; };
                                    return <tr
                                        class={(draggedSummary() === sIdx() ? "opacity-50 " : "") + (dragOverSummary() === sIdx() && draggedSummary() !== sIdx() ? "bg-neutral-100" : "")}
                                        onDragOver={(e: DragEvent) => handleSummaryDragOver(e, sIdx())}
                                        onDragLeave={() => setDragOverSummary(null)}
                                        onDrop={(e: DragEvent) => handleSummaryDrop(e, sIdx())}>
                                        <Show when={opts().accordion}><td></td></Show>
                                        <For each={cols()}>
                                            {(_col: AutoTableColumn, idx: () => number) => {
                                                const isLbl = () => idx() === labelIdx();
                                                const isVal = () => idx() === valueIdx();
                                                const isFirst = () => idx() === 0;
                                                return <td class={isFirst() ? "text-left" : "text-right"}>
                                                    <Show when={isFirst() && userSummaryRows().length > 1}>
                                                        <button type="button" draggable={true}
                                                            class="inline-flex items-center cursor-grab active:cursor-grabbing text-neutral-300 hover:text-neutral-500 bg-transparent border-0 p-0 leading-none align-middle"
                                                            title="Drag to reorder"
                                                            onDragStart={(e: DragEvent) => handleSummaryDragStart(e, sIdx())}
                                                            onDragEnd={handleSummaryDragEnd}>
                                                            <Icon icon="grip-vertical" size={12} />
                                                        </button>
                                                    </Show>
                                                    <Show when={isLbl() || isVal()}>
                                                        <div class="flex items-center justify-end gap-2 whitespace-nowrap">
                                                            <Show when={isLbl()}>
                                                                <Popover placement="bottom-end">
                                                                    <PopoverTrigger class="inline-flex items-center gap-1 text-black hover:text-neutral-600 cursor-pointer bg-transparent border-0 p-0 font-bold">
                                                                        {s.label}
                                                                        <Icon icon="pen-to-square" size={11} class="opacity-50" />
                                                                    </PopoverTrigger>
                                                                    <PopoverContent>
                                                                        <SummaryRowForm
                                                                            row={() => s}
                                                                            operandOptions={calcOperandOptions}
                                                                            onSave={saveSummaryRow}
                                                                            onRemove={removeSummaryRow}
                                                                            onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} />
                                                                    </PopoverContent>
                                                                </Popover>
                                                            </Show>
                                                            <Show when={isVal()}>
                                                                <span>{value()}</span>
                                                            </Show>
                                                        </div>
                                                    </Show>
                                                </td>;
                                            }}
                                        </For>
                                    </tr>;
                                }}
                            </For>
                        </tfoot>
                    </Show>
                </table>
            </div>

            <Show when={!opts().hidePagination || canReset()}>
                <div class={PAGINATION_BASE + " " + getPaginationPaddingClass()}>
                    <div class="flex items-center">
                        <Show when={canReset()}>
                            <div class="mr-4">
                                <Menu placement="top-start">
                                    <MenuTrigger>
                                        <ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
                                            <Icon icon="arrow-rotate-left" size={16} style={{ display: "inline", marginRight: "4px" }} />
                                            Reset
                                        </ButtonUI>
                                    </MenuTrigger>
                                    <MenuContent>
                                        <Show when={opts().draggableColumns}>
                                            <MenuItem icon="table-columns" onclick={(_e: MouseEvent) => resetColumnOrder()}>Reset column order</MenuItem>
                                        </Show>
                                        <Show when={opts().resizableColumns}>
                                            <MenuItem icon="ruler-horizontal" onclick={(_e: MouseEvent) => resetColumnWidths()}>Reset column widths</MenuItem>
                                        </Show>
                                        <Show when={opts().calculatedColumns}>
                                            <MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetCalcColumns()}>Reset calculations</MenuItem>
                                        </Show>
                                        <Show when={opts().summaryRows}>
                                            <MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetSummaryRows()}>Reset summaries</MenuItem>
                                        </Show>
                                        <MenuDivider />
                                        <MenuItem icon="arrows-rotate" onclick={(_e: MouseEvent) => resetAll()}>Reset all</MenuItem>
                                    </MenuContent>
                                </Menu>
                            </div>
                        </Show>
                        <div class={PAGINATION_INFO}>
                            <Show when={!opts().hidePagination}>
                                <b class="leading-none">
                                    <Icon icon="list-ol" size={16} />
                                </b>
                                <span class="ml-3">
                                    {displayPagination().ViewRangeLower}-{displayPagination().ViewRangeUpper} of {displayPagination().TotalItems}
                                </span>
                            </Show>
                        </div>
                    </div>

                    <div class={PAGINATION_CONTROLS}>
                        <Show when={!opts().hidePagination}>
                            <div class={PAGINATION_LABEL}>
                                Items per page:
                            </div>
                            <FormSelect
                                small={true}
                                class="mr-5"
                                value={displayPagination().MaxItemsPerPage}
                                onchange={(e: Event & { currentTarget: HTMLSelectElement }) => handleItemsPerPageChange(parseInt(e.currentTarget.value))}
                            >
                                <option value="5">5</option>
                                <option value="10">10</option>
                                <option value="25">25</option>
                                <option value="50">50</option>
                                <option value="100">100</option>
                                <Show when={opts().paginationShowAll}>
                                    <option value="-1">All</option>
                                </Show>
                            </FormSelect>

                            <PaginationButton
                                onclick={() => handlePageChange(1)}
                                disabled={displayPagination().CurrentPage <= 1}
                            >
                                <Icon icon="angles-left" size={16} />
                            </PaginationButton>
                            <PaginationButton
                                onclick={() => handlePageChange(displayPagination().CurrentPage - 1)}
                                disabled={displayPagination().CurrentPage <= 1}
                            >
                                <Icon icon="chevron-left" size={16} />
                            </PaginationButton>

                            <div class={PAGINATION_PAGE}>
                                Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
                            </div>

                            <PaginationButton
                                onclick={() => handlePageChange(displayPagination().CurrentPage + 1)}
                                disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
                            >
                                <Icon icon="chevron-right" size={16} />
                            </PaginationButton>
                            <PaginationButton
                                onclick={() => handlePageChange(displayPagination().TotalPages)}
                                disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
                            >
                                <Icon icon="angles-right" size={16} />
                            </PaginationButton>
                        </Show>
                    </div>
                </div>
            </Show>
        </div>
        {props.belowTable}
        </div>
        </div>
    </div>;
}


function PaginationButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
    return <button
        class={PAGINATION_BTN}
        onclick={props.onclick}
        disabled={props.disabled}
    >
        {props.children}
    </button>;
}

interface TdProps {
    class?: string;
    children: any;
}

export function TdLeft(props: TdProps) {
    return <td class={"text-left " + (props.class || "")}>{props.children}</td>;
}

export function TdRight(props: TdProps) {
    return <td class={"text-right " + (props.class || "")}>{props.children}</td>;
}

export function TdCenter(props: TdProps) {
    return <td class={"text-center " + (props.class || "")}>{props.children}</td>;
}

export function TdUniformLeft(props: TdProps) {
    return <td class={"text-left " + (props.class || "")}>{props.children}</td>;
}

export function TdUniformRight(props: TdProps) {
    return <td class={"text-right " + (props.class || "")}>{props.children}</td>;
}

export function TdUniformCenter(props: TdProps) {
    return <td class={"text-center " + (props.class || "")}>{props.children}</td>;
}

export default AutoTable;
