@namespace Lumeo
@typeparam TItem
@attribute [CascadingTypeParameter(nameof(TItem))]
@implements IAsyncDisposable
@inject Lumeo.Services.Localization.ILumeoLocalizer L
@inject Lumeo.Services.ToastService Toast

@{
    // Defensive: keep the roving-focus cell within the current data extents.
    // This is a pure clamp of two ints — it never triggers a re-render — so it's
    // safe to run during render and covers every path that mutates _displayedItems
    // (sort, filter, paging, grouping, external data changes).
    ClampFocusedCell();
    var context = new DataGridContext<TItem>(
        Columns: _visibleColumns,
        // _visibleColumns is itself the filtered cache (Visible == true) — we
        // expose it explicitly under VisibleColumns so descendants don't have
        // to re-filter via Columns.Where(c => c.Visible) every render.
        VisibleColumns: _visibleColumns,
        DisplayedItems: _displayedItems.AsReadOnly(),
        SelectedItems: _selectedItems.AsReadOnly(),
        Sorts: _sorts,
        Filters: _filters,
        GlobalSearch: _globalSearch,
        CurrentPage: _currentPage,
        PageSize: PageSize,
        TotalCount: EffectiveTotalCount,
        SelectionMode: SelectionMode,
        EditMode: EditMode,
        IsLoading: IsLoading || (_serverService?.IsLoading ?? false),
        RowReorderable: RowReorderable,
        RowContextMenuEnabled: RowContextMenu is not null,
        OnRowClick: OnRowClick,
        OnRowDoubleClick: OnRowDoubleClick,
        OnSort: EventCallback.Factory.Create<SortDescriptor>(this, HandleSort),
        OnFilter: EventCallback.Factory.Create<FilterDescriptor>(this, HandleFilter),
        OnSelectionChanged: EventCallback.Factory.Create<TItem>(this, HandleSelectionChanged),
        OnCellEdit: OnCellEdit,
        IsRowSelected: IsRowSelected,
        IsRowExpanded: IsRowExpanded,
        IsRowEditing: IsRowEditing,
        ToggleSelection: ToggleSelection,
        SelectAll: SelectAll,
        ClearSelection: ClearSelection,
        ToggleRowExpand: ToggleRowExpand,
        StartRowEdit: StartRowEdit,
        CancelRowEdit: CancelRowEdit,
        CommitRowEdit: CommitRowEditAsync,
        GetEditValue: GetEditValue,
        SetEditValue: SetEditValue,
        RowClass: RowClass,
        RowStyle: RowStyle,
        PinnedLeftOffsets: ComputePinnedLeftOffsets(),
        PinnedRightOffsets: ComputePinnedRightOffsets(),
        GroupedSections: _groupedSections.Count > 0 ? _groupedSections.AsReadOnly() : null,
        GroupBy: GroupBy,
        IsGrouped: !IsTreeGridMode && EffectiveGroupFields.Count == 1 && _groupedSections.Count > 0,
        IsGroupExpanded: IsGroupExpanded,
        ToggleGroupExpand: ToggleGroupExpand,
        Culture: EffectiveCulture,
        GroupFields: _runtimeGroupFields.AsReadOnly(),
        GroupTree: (!IsTreeGridMode && EffectiveGroupFields.Count > 1 && _groupedTree.Count > 0)
            ? _groupedTree.AsReadOnly() : null,
        IsGroupPathExpanded: IsGroupPathExpanded,
        ToggleGroupPath: ToggleGroupPath,
        AddGroupField: AddGroupField,
        RemoveGroupField: RemoveGroupField,
        ShowGroupPanel: ShowGroupPanel,
        ChildItemsSelector: ChildItemsSelector,
        IsTreeGrid: IsTreeGridMode,
        TreeColumnField: EffectiveTreeColumnField,
        IsTreeNodeExpanded: IsTreeNodeExpanded,
        ToggleTreeNode: it => _ = ToggleTreeNodeAsync(it),
        TreeLevelOf: TreeLevelOf,
        TreeHasChildren: TreeHasChildrenOf,
        // --- Batch-edit context fields — wired to the per-cell pending-changes
        // buffer (_batchChanges) and the additive new-row buffer (_batchAdded).
        IsBatchEdit: EditMode == DataGridEditMode.Batch,
        IsCellDirty: IsCellDirty,
        GetBatchValue: GetBatchValue,
        SetBatchValue: SetBatchValue,
        GridId: _gridId,
        FocusedRow: _focusedRow,
        FocusedCol: _focusedCol,
        RowCount: _displayedItems.Count,
        ColCount: _visibleColumns.Count,
        OnCellKeyDown: HandleCellKeyDownAsync,
        OnHeaderKeyDown: HandleHeaderKeyDownAsync
    );
}

@{
    RenderFragment gridRoot = @<div data-slot="datagrid" class="@RootCssClass" style="@HeightStyle" @attributes="AdditionalAttributes">

        @* Declarative column definitions *@
        <div class="hidden">@ChildContent</div>

        @if (_serverService?.Error is not null)
        {
            <div class="flex items-center gap-2 px-4 py-2.5 border-b border-destructive/30 bg-destructive/10 text-sm">
                <Blazicon Svg="Lucide.CircleAlert" class="h-4 w-4 shrink-0 text-destructive" />
                <span class="flex-1 text-destructive-text">@L["DataGrid.ErrorLoadingData", _serverService.Error ?? ""]</span>
                <button type="button"
                        class="text-xs text-destructive underline hover:no-underline focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
                        @onclick="@(async () => { _serverService!.ClearError(); await RequestServerData(); })">
                    @L["DataGrid.Retry"]
                </button>
            </div>
        }

        @if (ShowToolbar)
        {
            <DataGridToolbar TItem="TItem"
                             SearchText="@_globalSearch"
                             SearchTextChanged="HandleGlobalSearch"
                             RegisteredContent="@RegisteredToolbarContent"
                             OnColumnToggle="HandleColumnVisibilityChanged"
                             OnColumnReorder="HandleColumnReorder"
                             OnExport="HandleExport"
                             OnRemoveFilter="RemoveFilter"
                             SelectedItems="_selectedItems"
                             AllItems="@(ServerMode ? _displayedItems : _processedItems)"
                             EffectiveColumns="EffectiveColumns.AsReadOnly()"
                             EnableLayoutPersistence="EnableLayoutPersistence"
                             OnSaveLayout="HandleSaveLayout"
                             OnResetLayout="HandleResetLayout"
                             GlobalLayouts="GlobalLayouts"
                             OnSaveNamedLayout="HandleSaveNamedLayout"
                             OnDeleteNamedLayout="OnDeleteNamedLayout"
                             OnApplyNamedLayout="HandleApplyNamedLayout"
                             LayoutStorageKey="LayoutStorageKey"
                             OnError="OnError"
                             Expandable="Expandable"
                             IsExpanded="_isExpanded"
                             OnToggleExpanded="ToggleExpanded"
                             ShowSearch="ShowSearch"
                             ShowColumnChooser="ShowColumnChooser"
                             ShowExport="ShowExport"
                             ExportFormats="ExportFormats" />
        }

        @if (_contextMenuVisible)
        {
            <div id="@_contextMenuId"
                 class="fixed z-50 min-w-[160px] rounded-md border border-border/40 bg-popover p-1 shadow-md"
                 style="left: @(_contextMenuX)px; top: @(_contextMenuY)px;"
                 @onclick:stopPropagation="true">
                @if (RowContextMenu is not null && _contextMenuItem is not null)
                {
                    @RowContextMenu(_contextMenuItem)
                }
            </div>
        }

        @if (EditMode == DataGridEditMode.Batch && HasPendingChanges)
        {
            @* Batch-edit pending-changes strip. Uses warning/amber theme tokens
               from lumeo.css (--color-warning, --color-warning-text). Sits
               above the table body so it's visually associated with dirty cells. *@
            <div data-slot="datagrid-batch-strip"
                 class="flex items-center gap-2 px-4 py-2 border-b border-warning/40 bg-warning/10 text-xs"
                 role="status"
                 aria-live="polite">
                <Blazicon Svg="Lucide.CircleAlert" class="h-4 w-4 text-warning-text shrink-0" />
                <span class="text-warning-text font-medium">
                    @($"{_batchChanges.Count + _batchAdded.Count} pending change(s)")
                </span>
                <div class="ml-auto flex items-center gap-2">
                    <button type="button"
                            data-slot="datagrid-batch-save"
                            class="inline-flex h-7 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2"
                            @onclick="SaveBatchAsync">
                        @BatchSaveAllText
                    </button>
                    <button type="button"
                            data-slot="datagrid-batch-discard"
                            class="inline-flex h-7 items-center rounded-md border border-border/60 bg-background px-3 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2"
                            @onclick="DiscardBatch">
                        @BatchDiscardText
                    </button>
                </div>
            </div>
        }

        @if (ShowGroupPanel)
        {
            @* rc.41: real drop zone — header DnD now lands here. Visual highlight
               via _isGroupPanelDragOver. The dropped column's field comes from
               _dragState.SourceColumnId (set by HeaderCell.HandleDragStart). *@
            <div data-slot="datagrid-group-panel"
                 class="@($"flex flex-wrap items-center gap-2 px-3 py-2 border-b border-dashed text-xs transition-colors {(_isGroupPanelDragOver ? "border-primary bg-primary/10" : "border-border/40 bg-muted/30")}")"
                 @ondragover="HandleGroupPanelDragOver"
                 @ondragover:preventDefault="true"
                 @ondragenter="HandleGroupPanelDragEnter"
                 @ondragleave="HandleGroupPanelDragLeave"
                 @ondrop="HandleGroupPanelDrop">
                @if (_runtimeGroupFields.Count == 0)
                {
                    <span class="text-muted-foreground select-none pointer-events-none">
                        @(GroupPanelText ?? L["DataGrid.GroupPanelPlaceholder"])
                    </span>
                }
                else
                {
                    @for (var gi = 0; gi < _runtimeGroupFields.Count; gi++)
                    {
                        var field = _runtimeGroupFields[gi];
                        var col = EffectiveColumns.FirstOrDefault(c => c.Field == field);
                        var label = col?.Title ?? field;
                        <span class="inline-flex items-center gap-1 rounded bg-card border border-border/60 px-2 py-0.5">
                            <Blazicon Svg="Lucide.Group" class="h-3 w-3 text-muted-foreground" />
                            <span>@label</span>
                            <button type="button"
                                    class="inline-flex items-center justify-center h-4 w-4 rounded hover:bg-muted text-muted-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-1"
                                    title="@L["DataGrid.RemoveGrouping"]"
                                    @onclick="@(() => RemoveGroupField(field))">
                                <Blazicon Svg="Lucide.X" class="h-3 w-3" />
                            </button>
                        </span>
                        @if (gi < _runtimeGroupFields.Count - 1)
                        {
                            <Blazicon Svg="Lucide.ChevronRight" class="h-3 w-3 text-muted-foreground/60" />
                        }
                    }
                }
                @{
                    var addableCols = EffectiveColumns
                        .Where(c => c.Groupable && c.Field is not null && !_runtimeGroupFields.Contains(c.Field!))
                        .ToList();
                }
                @if (_runtimeGroupFields.Count > 0)
                {
                    @* Clear-all button: lives next to the chips so it's discoverable
                       and matches the per-chip X visual. rc.41 polish. *@
                    <button type="button"
                            class="ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-1"
                            title="@L["DataGrid.ClearAllGrouping"]"
                            @onclick="ClearGroupFields">
                        <Blazicon Svg="Lucide.X" class="h-3 w-3" />
                        Clear
                    </button>
                }
                @if (addableCols.Count > 0)
                {
                    @* 2.2.0 — was a native <select> with @key-on-count to work
                       around the browser's value-sticking, but the browser
                       chrome (white-on-blue on Windows, OS-default everywhere)
                       broke the theme entirely. DropdownMenu is the right
                       pattern here anyway: this is an "execute an action"
                       picker, not a "bind a value" picker, so it shouldn't
                       maintain a selected state in the first place. *@
                    @* The DropdownMenu component itself renders a relative-inline-block
                       wrapper div as its root, so ml-auto on the inner trigger button has
                       no effect on the flex parent. Wrap the whole component in a div that
                       takes the ml-auto so the picker sits hard-right on the panel. *@
                    <div class="ml-auto">
                        <DropdownMenu>
                            <DropdownMenuTrigger>
                                <button type="button"
                                        data-slot="datagrid-group-add-trigger"
                                        class="inline-flex items-center gap-1 h-6 rounded border border-border/60 bg-card px-2 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors">
                                    <span>@L["DataGrid.AddGroupLevel"]</span>
                                    <Blazicon Svg="Lucide.ChevronDown" class="h-3 w-3" />
                                </button>
                            </DropdownMenuTrigger>
                            <DropdownMenuContent>
                                @foreach (var c in addableCols)
                                {
                                    var field = c.Field!;
                                    <DropdownMenuItem AdditionalAttributes="@(new Dictionary<string, object> { ["data-group-add-field"] = field })"
                                                      OnClick="@(() => { AddGroupField(field); return Task.CompletedTask; })">
                                        @(c.Title ?? c.Field)
                                    </DropdownMenuItem>
                                }
                            </DropdownMenuContent>
                        </DropdownMenu>
                    </div>
                }
            </div>
        }

        <div class="overflow-auto flex-1" @onclick="CloseContextMenu">
            <table class="w-full caption-bottom text-sm"
                   id="@_gridId"
                   role="@(IsTreeGridMode ? "treegrid" : "grid")"
                   aria-rowcount="@(_displayedItems.Count + 1)"
                   aria-colcount="@_visibleColumns.Count"
                   aria-readonly="@(EditMode == DataGridEditMode.None ? "true" : null)"
                   aria-multiselectable="@(SelectionMode == DataGridSelectionMode.Multiple ? "true" : null)">
                <DataGridHeader TItem="TItem"
                                HasDetailTemplate="@(DetailTemplate is not null)"
                                ActiveFilterColumn="@_activeFilterColumn"
                                OnFilterToggle="HandleFilterToggle"
                                OnFilterApply="HandleFilterApply"
                                Reorderable="Reorderable"
                                OnColumnReorder="HandleColumnReorder" />

                <DataGridBody TItem="TItem"
                              @ref="_bodyRef"
                              DetailTemplate="DetailTemplate"
                              EmptyContent="EmptyContent"
                              UseVirtualization="@UseVirtualization"
                              VirtualItemSize="@VirtualItemSize"
                              VirtualOverscanCount="@VirtualOverscanCount"
                              ServerVirtualizationProvider="@(_useServerVirtualization ? ServerVirtualizationProviderImpl : null)"
                              HasDetailTemplate="@(DetailTemplate is not null)"
                              SkeletonRowCount="@PageSize"
                              OnContextMenu="@(args => HandleContextMenu(args.Item1, args.Item2, args.Item3))" />

                <DataGridFooter TItem="TItem"
                                AllItems="@_processedItems"
                                HasDetailTemplate="@(DetailTemplate is not null)" />
            </table>
            @if (EditMode == DataGridEditMode.Batch && ShowAddRow && NewItemFactory is not null)
            {
                <div data-slot="datagrid-add-row"
                     class="flex items-center border-t border-border/40 bg-muted/20 px-3 py-1.5">
                    <button type="button"
                            data-slot="datagrid-add-row-button"
                            class="inline-flex h-7 items-center gap-1 rounded-md border border-dashed border-border/60 bg-card px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-2"
                            @onclick="AddBatchRow">
                        <Blazicon Svg="Lucide.Plus" class="h-3.5 w-3.5" />
                        <span>@BatchAddRowText</span>
                    </button>
                </div>
            }
        </div>

        @if (ShowPagination && !IsLoading && !_useServerVirtualization)
        {
            <DataGridPagination TItem="TItem"
                                PageChanged="HandlePageChanged"
                                PageSizeChanged="HandlePageSizeChanged"
                                PageSizeOptions="@PageSizeOptions" />
        }
    </div>;
}

<CascadingValue Value="this" IsFixed="true">
<CascadingValue Value="_dragState" IsFixed="true">
<CascadingValue Value="context" IsFixed="false">
<CascadingValue Value="_toolbarContext" IsFixed="false">
    @if (_isExpanded)
    {
        <div class="fixed inset-0 lumeo-datagrid-fullscreen-backdrop"
             style="z-index: 99;"
             aria-hidden="true"
             @onclick="ToggleExpanded"></div>
        <div class="fixed bg-background flex flex-col border border-border/60 rounded-xl shadow-2xl overflow-hidden lumeo-datagrid-fullscreen lumeo-datagrid-fullscreen-panel"
             style="z-index: 100; inset: 2.5rem;"
             role="dialog"
             aria-modal="true"
             aria-label="@(FullscreenTitle ?? "Expanded data grid")"
             tabindex="-1"
             @ref="_fullscreenRef"
             @onkeydown="HandleFullscreenKeyDown">
            @* Title strip — only shown when FullscreenTitle is set. The X was removed because the toolbar
               already renders a Minimize icon (DataGridToolbarFullscreen) that does the same thing. Users
               without that tool in a custom ToolbarContent can still press Esc. *@
            @if (!string.IsNullOrEmpty(FullscreenTitle))
            {
                <div class="flex items-center border-b border-border/40 px-5 py-2.5 shrink-0 bg-muted/30">
                    <Heading Level="3" Class="text-sm font-semibold m-0 tracking-tight">@FullscreenTitle</Heading>
                </div>
            }
            @* No extra padding — the grid component carries its own borders/rounded corners and fills the
               remaining modal height via CSS (see `.lumeo-datagrid-fullscreen-panel [data-slot=datagrid]`). *@
            <div class="flex-1 min-h-0 flex flex-col">
                @gridRoot
            </div>
        </div>
    }
    else
    {
        @gridRoot
    }
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>

@code {
    // --- Data ---
    [Parameter] public IEnumerable<TItem>? Items { get; set; }
    [Parameter] public List<DataGridColumn<TItem>>? Columns { get; set; }

    // --- Appearance ---
    [Parameter] public bool Striped { get; set; }
    [Parameter] public bool Bordered { get; set; }
    [Parameter] public bool Compact { get; set; }
    [Parameter] public bool Hoverable { get; set; } = true;
    [Parameter] public string? Height { get; set; }

    // --- Features ---
    [Parameter] public DataGridSelectionMode SelectionMode { get; set; } = DataGridSelectionMode.None;
    [Parameter] public DataGridEditMode EditMode { get; set; } = DataGridEditMode.None;
    [Parameter] public int PageSize { get; set; } = 10;
    /// <summary>The list of page-size options shown in the rows-per-page
    /// selector. Defaults to <c>{ 10, 25, 50, 100 }</c>. Set to e.g.
    /// <c>{ 10, 50, 100, 200, 300 }</c> for larger datasets, or to an
    /// empty array to hide the selector entirely.</summary>
    [Parameter] public int[] PageSizeOptions { get; set; } = new[] { 10, 25, 50, 100 };
    [Parameter] public bool ShowPagination { get; set; } = true;
    [Parameter] public bool ShowToolbar { get; set; }

    /// <summary>Show the global search box in the toolbar. Default true.</summary>
    [Parameter] public bool ShowSearch { get; set; } = true;

    /// <summary>Show the Columns button (visibility/reorder menu). Default true.</summary>
    [Parameter] public bool ShowColumnChooser { get; set; } = true;

    /// <summary>
    /// Show the Export button and its format dropdown. Default true.
    /// Set false to hide export entirely. For fine-grained per-format control see
    /// <see cref="ExportFormats"/>.
    /// </summary>
    [Parameter] public bool ShowExport { get; set; } = true;

    /// <summary>
    /// Which export formats appear in the Export dropdown. Default <see cref="DataGridExportFormat.All"/>.
    /// Set to e.g. <c>Csv | Excel</c> to hide PDF in WebAssembly where QuestPDF isn't supported.
    /// When no flags are set (or <see cref="ShowExport"/> is false), the Export button is hidden.
    /// </summary>
    [Parameter] public DataGridExportFormat ExportFormats { get; set; } = DataGridExportFormat.All;
    /// <summary>Item count above which Blazor's <c>&lt;Virtualize&gt;</c>
    /// kicks in. Only relevant when <see cref="ShowPagination"/> is false
    /// (or <see cref="PageSize"/> &gt; this threshold) — paged grids show
    /// at most <see cref="PageSize"/> rows at once and never trigger
    /// virtualization. Default 500.</summary>
    [Parameter] public int VirtualizeThreshold { get; set; } = 500;
    /// <summary>Estimated row height in CSS pixels — used by
    /// <c>&lt;Virtualize&gt;</c> to size the scroll spacers. Tune if your
    /// rows are taller (dense badges, two-line cells). Default 41px.</summary>
    [Parameter] public float VirtualItemSize { get; set; } = 41f;
    /// <summary>Extra rows rendered above and below the viewport to
    /// hide pop-in during fast scrolling. Default 3.</summary>
    [Parameter] public int VirtualOverscanCount { get; set; } = 3;
    [Parameter] public bool Reorderable { get; set; }
    [Parameter] public bool RowReorderable { get; set; }
    [Parameter] public string? GroupBy { get; set; }
    [Parameter] public bool GroupsExpandedByDefault { get; set; } = true;

    /// <summary>
    /// Ordered list of fields for multi-level grouping. When set (non-empty) this
    /// takes precedence over the single <see cref="GroupBy"/> parameter. Each entry
    /// must be a column <c>Field</c>. The grid renders nested group rows with
    /// increasing indentation per level; per-level aggregates are shown on each
    /// group header row.
    /// </summary>
    [Parameter] public IReadOnlyList<string>? GroupByFields { get; set; }

    /// <summary>
    /// Shows a slim bar above the grid that lists the current grouping levels as
    /// removable chips, plus a dropdown to add a new level from any
    /// <see cref="DataGridColumn{TItem}.Groupable"/> column. Default false.
    /// </summary>
    [Parameter] public bool ShowGroupPanel { get; set; }

    /// <summary>Placeholder/hint text shown in the group panel when no fields are grouped.</summary>
    [Parameter] public string? GroupPanelText { get; set; }

    // --- Tree-grid mode ---
    /// <summary>
    /// When set, the grid renders a hierarchical tree-grid: this selector returns a
    /// row's child rows (null/empty = leaf). Each parent row gets an expand chevron
    /// in the tree column (see <see cref="TreeColumnField"/>). Distinct from
    /// <see cref="DetailTemplate"/>, which is a flat expand-for-detail fragment.
    /// Tree-grid mode and grouping are mutually exclusive — when both are configured,
    /// tree-grid mode wins.
    /// </summary>
    [Parameter] public Func<TItem, IEnumerable<TItem>?>? ChildItemsSelector { get; set; }

    /// <summary>Whether tree nodes start expanded. Default false.</summary>
    [Parameter] public bool TreeGridDefaultExpanded { get; set; }

    /// <summary>Field of the column that carries the tree chevron + indentation.
    /// Null (default) uses the first visible column.</summary>
    [Parameter] public string? TreeColumnField { get; set; }

    /// <summary>Raised when a tree node is expanded or collapsed (the item, plus its new expanded state).</summary>
    [Parameter] public EventCallback<TItem> OnTreeNodeExpand { get; set; }

    // --- Fullscreen / Expandable ---
    /// <summary>
    /// When true, adds a small Expand icon to the toolbar. Clicking it
    /// renders the grid in a fullscreen modal overlay. Default false.
    /// </summary>
    [Parameter] public bool Expandable { get; set; }

    /// <summary>
    /// When true, the grid is currently rendered in fullscreen. Bind this
    /// for programmatic control, or leave unbound to let the grid manage
    /// its own state via the toolbar button.
    /// </summary>
    [Parameter] public bool IsExpanded { get; set; }

    [Parameter] public EventCallback<bool> IsExpandedChanged { get; set; }

    /// <summary>
    /// Optional title shown in the fullscreen header bar. Defaults to "Data Grid".
    /// </summary>
    [Parameter] public string? FullscreenTitle { get; set; }

    // --- Conditional Row Styling ---
    [Parameter] public Func<TItem, string>? RowClass { get; set; }
    [Parameter] public Func<TItem, string>? RowStyle { get; set; }

    // --- Layout Persistence ---
    [Parameter] public bool EnableLayoutPersistence { get; set; }
    [Parameter] public string? LayoutStorageKey { get; set; }
    [Parameter] public EventCallback<DataGridLayout> OnLayoutSave { get; set; }
    [Parameter] public DataGridLayout? SavedLayout { get; set; }

    // --- Named Layouts ---
    [Parameter] public List<DataGridNamedLayout>? GlobalLayouts { get; set; }
    [Parameter] public EventCallback<DataGridNamedLayout> OnSaveNamedLayout { get; set; }
    [Parameter] public EventCallback<string> OnDeleteNamedLayout { get; set; }

    // --- Server mode ---
    [Parameter] public bool ServerMode { get; set; }
    [Parameter] public EventCallback<DataGridServerRequest> OnServerRequest { get; set; }
    [Parameter] public int TotalCount { get; set; }
    [Parameter] public bool IsLoading { get; set; }

    // --- Virtualised server mode (infinite-scroll, window-fetched) ---
    /// <summary>
    /// Enables <see cref="Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize{TItem}"/>
    /// with an <c>ItemsProvider</c> — rows are fetched from the backend
    /// in scroll-driven windows instead of paged. Requires
    /// <see cref="OnRangeRequest"/>. When active, pagination is hidden
    /// automatically and the grid renders a single tall scrollable body.
    /// </summary>
    [Parameter] public bool Virtualized { get; set; }
    /// <summary>
    /// Range-fetch callback used in virtualised server mode. The grid
    /// invokes this whenever the viewport scrolls into an unloaded
    /// window. The callback must return the requested slice plus the
    /// current total row count so the scroll spacers stay accurate.
    /// </summary>
    [Parameter] public Func<DataGridRangeRequest, ValueTask<DataGridRangeResponse<TItem>>>? OnRangeRequest { get; set; }

    // --- Templates ---
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public RenderFragment<TItem>? DetailTemplate { get; set; }
    [Parameter] public RenderFragment? EmptyContent { get; set; }
    [Parameter] public RenderFragment<TItem>? RowContextMenu { get; set; }

    // --- Events ---
    [Parameter] public EventCallback<TItem> OnRowClick { get; set; }
    [Parameter] public EventCallback<TItem> OnRowDoubleClick { get; set; }
    [Parameter] public EventCallback<CellEditEventArgs<TItem>> OnCellEdit { get; set; }
    [Parameter] public EventCallback<RowEditEventArgs<TItem>> OnRowEdit { get; set; }

    // --- Batch / buffered edit mode (rc.36) ---
    /// <summary>
    /// Fired when the user clicks "Save all" while <see cref="EditMode"/> is
    /// <see cref="DataGridEditMode.Batch"/>. Args carry the buffered
    /// modifications + added rows. On a clean callback return (no exception),
    /// the grid's pending-changes buffer is cleared automatically.
    /// </summary>
    [Parameter] public EventCallback<DataGridBatchSaveEventArgs<TItem>> OnBatchSave { get; set; }

    /// <summary>
    /// When true and <see cref="NewItemFactory"/> is set, renders a thin
    /// "+ Add row" trigger below the body. Each click creates a TItem via
    /// the factory, pushes it into the added-rows buffer, and immediately
    /// shows it inline so cells can be filled in.
    /// Only meaningful in <see cref="DataGridEditMode.Batch"/>.
    /// </summary>
    [Parameter] public bool ShowAddRow { get; set; }

    /// <summary>
    /// Factory used by the "+ Add row" trigger to produce a fresh TItem.
    /// Required when <see cref="ShowAddRow"/> is true. The returned item
    /// is stored in the added-rows buffer; the user fills its cells inline
    /// and the row is delivered via <see cref="OnBatchSave"/>.
    /// </summary>
    [Parameter] public Func<TItem>? NewItemFactory { get; set; }

    /// <summary>
    /// True when the batch-edit buffer carries at least one pending change
    /// (a buffered cell edit or a new row). Bind via <c>@ref</c> for warn-
    /// on-navigate workflows.
    /// </summary>
    public bool HasPendingChanges => _batchChanges.Count > 0 || _batchAdded.Count > 0;

    /// <summary>Read-only view of currently-buffered modified rows.</summary>
    public IReadOnlyCollection<TItem> PendingModifiedItems => _batchChanges.Keys;

    /// <summary>Read-only view of currently-buffered new rows.</summary>
    public IReadOnlyList<TItem> PendingAddedItems => _batchAdded.AsReadOnly();

    // --- Column virtualization (rc.36) ---
    /// <summary>
    /// Simplified column virtualization: caps the number of horizontally-
    /// rendered data columns at <see cref="MaxVisibleColumns"/>. Pinned
    /// columns always make the cut. Default false.
    /// </summary>
    [Parameter] public bool ColumnVirtualize { get; set; }

    /// <summary>Maximum columns rendered when <see cref="ColumnVirtualize"/> is true. Default 30.</summary>
    [Parameter] public int MaxVisibleColumns { get; set; } = 30;

    /// <summary>Label for the batch-mode "Save all" button. Override for localization.</summary>
    [Parameter] public string? BatchSaveAllText { get; set; } = "Save all";
    /// <summary>Label for the batch-mode "Discard" button. Override for localization.</summary>
    [Parameter] public string? BatchDiscardText { get; set; } = "Discard";
    /// <summary>Label for the batch-mode "+ Add row" trigger. Override for localization.</summary>
    [Parameter] public string? BatchAddRowText { get; set; } = "Add row";
    [Parameter] public EventCallback<ColumnReorderEventArgs> OnColumnReorder { get; set; }
    [Parameter] public EventCallback<RowReorderEventArgs<TItem>> OnRowReorder { get; set; }
    [Parameter] public EventCallback<Exception> OnError { get; set; }
    [Parameter] public IReadOnlyList<TItem>? SelectedItems { get; set; }
    [Parameter] public EventCallback<IReadOnlyList<TItem>> SelectedItemsChanged { get; set; }

    // --- Standard ---
    [Parameter] public string? Class { get; set; }
    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }

    // --- Inject ---
    [Inject] private ComponentInteropService Interop { get; set; } = default!;
    [Inject] private Lumeo.Services.IDataGridExportService Export { get; set; } = default!;

    // --- Culture ---
    /// <summary>
    /// CultureInfo used for cell value formatting (dates/numbers) and exports.
    /// When null (default), falls back to <see cref="System.Globalization.CultureInfo.CurrentCulture"/>.
    /// Useful when your app sets a different culture than the UI locale — e.g. force
    /// <c>de-DE</c> formatting in an English UI.
    /// </summary>
    [Parameter] public System.Globalization.CultureInfo? Culture { get; set; }

    internal System.Globalization.CultureInfo EffectiveCulture =>
        Culture ?? System.Globalization.CultureInfo.CurrentCulture;

    // --- Internal state ---
    private DataGridBody<TItem>? _bodyRef;
    private bool _useServerVirtualization => Virtualized && OnRangeRequest is not null;

    // --- Keyboard navigation / roving tabindex (rc.32) ---
    private readonly string _gridId = $"dg-{Guid.NewGuid():N}";
    // Roving-focus cell coordinates. _focusedRow == -1 → header row.
    private int _focusedRow = -1;
    private int _focusedCol = 0;
    // Approximate "page" jump for PageUp/PageDown in rows.
    private int KeyboardPageStep => Math.Max(1, Math.Min(PageSize, 10));

    private string CellElementId(int row, int col) => $"{_gridId}-cell-{(row < 0 ? "h" : row.ToString())}-{col}";

    /// <summary>Clamp the roving-focus coordinates to the current data extents.
    /// Called after any data/sort/filter/page change so the stored cell never
    /// dangles past the visible range.</summary>
    private void ClampFocusedCell()
    {
        var colCount = _visibleColumns.Count;
        if (colCount == 0) { _focusedCol = 0; _focusedRow = -1; return; }
        if (_focusedCol < 0) _focusedCol = 0;
        if (_focusedCol >= colCount) _focusedCol = colCount - 1;
        var rowCount = _displayedItems.Count;
        if (_focusedRow < -1) _focusedRow = -1;
        if (_focusedRow >= rowCount) _focusedRow = rowCount - 1; // may land on -1 if no rows
    }

    private async Task MoveFocusAsync(int newRow, int newCol)
    {
        var colCount = _visibleColumns.Count;
        if (colCount == 0) return;
        newCol = Math.Clamp(newCol, 0, colCount - 1);
        var rowCount = _displayedItems.Count;
        // Allow -1 (header). Don't allow going past last body row.
        if (newRow < -1) newRow = -1;
        if (newRow > rowCount - 1) newRow = Math.Max(-1, rowCount - 1);
        if (newRow == _focusedRow && newCol == _focusedCol)
        {
            // Still re-focus the DOM element (e.g. Home pressed twice).
            await FocusCellAsync(newRow, newCol);
            return;
        }
        _focusedRow = newRow;
        _focusedCol = newCol;
        StateHasChanged();
        await FocusCellAsync(newRow, newCol);
    }

    private async Task FocusCellAsync(int row, int col)
    {
        try { await Interop.FocusElement(CellElementId(row, col)); }
        catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
        catch { /* element may not be rendered (virtualized / grouped) — acceptable */ }
    }

    /// <summary>Keydown handler invoked from a body <see cref="DataGridCell{TItem}"/>
    /// whose <c>&lt;td&gt;</c> currently has focus.</summary>
    internal async Task HandleCellKeyDownAsync(int row, int col, KeyboardEventArgs e)
    {
        // Keep our notion of the focused cell in sync with where the event came from.
        _focusedRow = row;
        _focusedCol = col;
        var colCount = _visibleColumns.Count;
        var rowCount = _displayedItems.Count;
        if (colCount == 0) return;

        switch (e.Key)
        {
            case "ArrowDown":
                await MoveFocusAsync(row + 1, col);
                break;
            case "ArrowUp":
                // From the first body row, jump up into the header row.
                await MoveFocusAsync(row <= 0 ? -1 : row - 1, col);
                break;
            case "ArrowRight":
                await MoveFocusAsync(row, col + 1);
                break;
            case "ArrowLeft":
                await MoveFocusAsync(row, col - 1);
                break;
            case "Home":
                if (e.CtrlKey) await MoveFocusAsync(0, 0);
                else await MoveFocusAsync(row, 0);
                break;
            case "End":
                if (e.CtrlKey) await MoveFocusAsync(rowCount - 1, colCount - 1);
                else await MoveFocusAsync(row, colCount - 1);
                break;
            case "PageDown":
                await MoveFocusAsync(row + KeyboardPageStep, col);
                break;
            case "PageUp":
                await MoveFocusAsync(Math.Max(0, row - KeyboardPageStep), col);
                break;
            case " ":
            case "Spacebar":
                if (SelectionMode != DataGridSelectionMode.None && row >= 0 && row < rowCount)
                {
                    ToggleSelection(_displayedItems[row]);
                }
                break;
            case "a":
            case "A":
                if (e.CtrlKey && SelectionMode == DataGridSelectionMode.Multiple)
                {
                    SelectAll();
                }
                break;
            // Enter / F2 (enter edit mode) are handled inside DataGridCell itself
            // because that's where the per-cell edit state lives.
        }
    }

    /// <summary>Keydown handler invoked from a header <see cref="DataGridHeaderCell{TItem}"/>.</summary>
    internal async Task HandleHeaderKeyDownAsync(int col, KeyboardEventArgs e)
    {
        _focusedRow = -1;
        _focusedCol = col;
        var colCount = _visibleColumns.Count;
        if (colCount == 0) return;

        switch (e.Key)
        {
            case "ArrowRight":
                await MoveFocusAsync(-1, col + 1);
                break;
            case "ArrowLeft":
                await MoveFocusAsync(-1, col - 1);
                break;
            case "ArrowDown":
                await MoveFocusAsync(0, col);
                break;
            case "Home":
                await MoveFocusAsync(-1, 0);
                break;
            case "End":
                await MoveFocusAsync(-1, colCount - 1);
                break;
            // Enter / Space → trigger sort is handled inside DataGridHeaderCell.
        }
    }

    private async ValueTask<Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>>
        ServerVirtualizationProviderImpl(Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest req)
    {
        if (OnRangeRequest is null)
        {
            return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>(
                Array.Empty<DataGridBody<TItem>.IndexedItem>(), 0);
        }
        var rangeRequest = new DataGridRangeRequest(
            req.StartIndex,
            req.Count,
            _sorts.Count > 0 ? new List<SortDescriptor>(_sorts) : null,
            _filters.Count > 0 ? new List<FilterDescriptor>(_filters) : null,
            _globalSearch,
            req.CancellationToken);
        var resp = await OnRangeRequest(rangeRequest);
        var indexed = resp.Items
            .Select((it, i) => new DataGridBody<TItem>.IndexedItem(it, req.StartIndex + i))
            .ToList();
        TotalCount = resp.TotalCount;
        return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult<DataGridBody<TItem>.IndexedItem>(
            indexed, resp.TotalCount);
    }

    /// <summary>
    /// Forces the virtualised server view to discard its window cache and
    /// refetch from the current scroll position. Called automatically when
    /// sort/filter/search change; expose as <c>@ref</c> if you need to
    /// trigger it manually (e.g. after an external data mutation).
    /// </summary>
    public Task RefreshVirtualizedAsync() =>
        _bodyRef is not null ? _bodyRef.RefreshVirtualizeAsync() : Task.CompletedTask;

    private List<DataGridColumn<TItem>> _columnDefs = new();
    private List<DataGridColumn<TItem>> _orderedColumns = new();
    // Cached visible-columns projection — recomputed only when columns change or
    // visibility is toggled, not on every Blazor render cycle.
    private System.Collections.ObjectModel.ReadOnlyCollection<DataGridColumn<TItem>> _visibleColumns =
        new List<DataGridColumn<TItem>>().AsReadOnly();

    private void RefreshVisibleColumns()
    {
        IEnumerable<DataGridColumn<TItem>> q = _orderedColumns.Where(c => c.Visible);
        // Simplified column virtualization (rc.36): cap the rendered-column
        // count to keep wide grids cheap. Pinned columns always make the cut;
        // unpinned columns are taken in order until MaxVisibleColumns is hit.
        if (ColumnVirtualize && MaxVisibleColumns > 0)
        {
            var all = q.ToList();
            if (all.Count > MaxVisibleColumns)
            {
                var pinned = all.Where(c => c.Pin != PinDirection.None).ToList();
                var unpinned = all.Where(c => c.Pin == PinDirection.None).ToList();
                var roomForUnpinned = Math.Max(0, MaxVisibleColumns - pinned.Count);
                q = pinned.Concat(unpinned.Take(roomForUnpinned));
            }
            else
            {
                q = all;
            }
        }
        _visibleColumns = q.ToList().AsReadOnly();
    }
    private List<TItem> _processedItems = new();
    private List<TItem> _displayedItems = new();
    private List<TItem> _selectedItems = new();
    private HashSet<TItem> _selectedSet = new();
    private HashSet<TItem> _expandedRows = new();
    private HashSet<TItem> _editingRows = new();
    // CS8714: Dictionary<TKey,_> wants `TKey : notnull`, which TItem isn't. The grid
    // only ever uses non-null items as keys; disable from here on so the tree-grid /
    // batch-edit dictionaries further down don't each need their own pragma.
#pragma warning disable CS8714
    private Dictionary<TItem, Dictionary<string, object?>> _rowEditValues = new();

    // --- Batch / buffered edit mode (rc.36) ---
    // Per-row pending field changes. Outer key = the row instance; inner dict
    // maps the column's Field to the buffered value. When EditMode == Batch,
    // cell commits write here instead of mutating the row; "Save all" hands
    // these to the consumer via OnBatchSave.
    private Dictionary<TItem, Dictionary<string, object?>> _batchChanges = new();
    // Rows created via the "+ Add row" trigger. Each entry is also represented
    // in _batchChanges (so its dirty fields render the same way). We split
    // Added vs Modified at save time using this list.
    private List<TItem> _batchAdded = new();
    private List<SortDescriptor> _sorts = new();
    private List<FilterDescriptor> _filters = new();
    private string? _globalSearch;
    private int _currentPage = 1;
    private string? _activeFilterColumn;
    private bool _contextMenuVisible;
    private double _contextMenuX;
    private double _contextMenuY;
    private TItem? _contextMenuItem;
    private string _contextMenuId = $"dg-ctx-{Guid.NewGuid():N}";
    private bool _isExpanded;
    private bool _lastIsExpandedParam;
    private bool _fullscreenNeedsFocus;
    private ElementReference _fullscreenRef;
    private List<DataGridGroupSection<TItem>> _groupedSections = new();
    private HashSet<string> _expandedGroups = new();
    private HashSet<string> _knownGroupKeys = new();
    private string? _lastGroupBy;
    private bool? _lastGroupsExpandedByDefault;

    // --- Multi-level grouping + group panel (rc.35) ---
    // Effective grouping levels. Seeded from GroupByFields ?? [GroupBy] but mutable
    // at runtime via the group panel chips / add-dropdown.
    private List<string> _runtimeGroupFields = new();
    // Tracks the (GroupBy, GroupByFields) parameter values last applied so an
    // external parameter change re-seeds _runtimeGroupFields, but runtime panel
    // edits are not stomped on the next render.
    private string? _seededGroupBy;
    private string? _seededGroupByFieldsKey;
    private List<DataGridGroupNode<TItem>> _groupedTree = new();
    private HashSet<string> _expandedGroupPaths = new();
    private HashSet<string> _knownGroupPaths = new();

    // --- Tree-grid mode (rc.35) ---
    private HashSet<TItem> _expandedTreeNodes = new();
    private bool _treeExpandSeeded;
    // Per-render caches rebuilt by RebuildTreeView().
    private List<TItem> _treeDisplayRows = new();
    private Dictionary<TItem, int> _treeLevelOf = new();
    private Dictionary<TItem, bool> _treeHasChildren = new();
    private string _layoutId = $"datagrid-{Guid.NewGuid():N}";
    private DataGridLayout? _defaultLayout; // snapshot of original column state for reset
    private bool _savedLayoutApplied; // tracks whether the SavedLayout parameter has been applied
    private DataGridLayoutService? _layoutService;
    private DataGridServerService? _serverService;
    private DataGridToolbarContext<TItem> _toolbarContext = new();

    /// <summary>
    /// Stable per-instance id used to scope drag state. Allocated once per
    /// grid instance and cascaded via <see cref="DataGridDragState"/> so
    /// header cells / rows can reject drops that originated in a different
    /// grid instance on the same page.
    /// </summary>
    private readonly string _gridInstanceId = Guid.NewGuid().ToString("N");
    private readonly DataGridDragState _dragState;

    public DataGrid()
    {
        _dragState = new DataGridDragState(_gridInstanceId);
    }

    // --- ToolbarContent component registration ---

    /// <summary>
    /// The <see cref="ToolbarContent{TItem}"/> component currently registered inside
    /// this grid (at most one). Forwarded to <see cref="DataGridToolbar"/> so it can
    /// render the custom ChildContent + Class in the toolbar's left-hand slot.
    /// </summary>
    internal ToolbarContent<TItem>? RegisteredToolbarContent { get; private set; }

    internal void RegisterToolbarContent(ToolbarContent<TItem> tc)
    {
        // Re-registration from the child's OnParametersSet happens on every render;
        // only flag a re-render when the instance actually changed, otherwise we'd
        // loop (StateHasChanged → child re-renders → re-registers → StateHasChanged).
        // Class/ChildContent updates on the SAME instance are already read by the
        // next natural parent render (we dereference them at render time).
        if (!ReferenceEquals(RegisteredToolbarContent, tc))
        {
            RegisteredToolbarContent = tc;
            InvokeAsync(StateHasChanged);
        }
    }

    internal void UnregisterToolbarContent(ToolbarContent<TItem> tc)
    {
        if (RegisteredToolbarContent == tc)
        {
            RegisteredToolbarContent = null;
            InvokeAsync(StateHasChanged);
        }
    }

    private List<DataGridColumn<TItem>> SourceColumns =>
        Columns is not null && Columns.Count > 0 ? Columns : _columnDefs;

    private List<DataGridColumn<TItem>> EffectiveColumns =>
        _orderedColumns.Count > 0 ? _orderedColumns : SourceColumns;

    private int EffectiveTotalCount => ServerMode ? TotalCount : _processedItems.Count;

    // Row virtualization is incompatible with the recursive group-tree / tree-grid
    // renderers (they need every group/parent row present to compute indentation
    // and expand state), so it's disabled when those modes are active.
    private bool UseVirtualization =>
        _displayedItems.Count > VirtualizeThreshold
        && !IsTreeGridMode
        && EffectiveGroupFields.Count <= 1;

    private string? HeightStyle => Height is not null ? $"height: {Height};" : null;

    private string RootCssClass
    {
        get
        {
            return string.Join(" ", new[]
            {
                "w-full overflow-auto border border-border/40 rounded-lg bg-card text-card-foreground flex flex-col",
                Compact ? "text-sm" : null,
                Bordered ? "[&_td]:border [&_td]:border-border/40 [&_th]:border [&_th]:border-border/40" : null,
                Striped ? "[&_tbody>tr:nth-child(even)]:bg-muted/30" : null,
                Class
            }.Where(s => !string.IsNullOrWhiteSpace(s)));
        }
    }

    /// <summary>
    /// Registers a column definition with the grid. Idempotent: if a column with
    /// the same stable key (Field when available, else Id) is already registered
    /// the existing entry is replaced in-place. This matters because Blazor can
    /// re-initialize <see cref="DataGridColumnDef{TItem}"/> children on every
    /// parent render when the ChildContent is captured inside a RenderFragment
    /// local — without this dedup the column list would accumulate duplicates
    /// (causing the filter popover to open N times, the "Toggle Columns" menu
    /// to list every column N times, etc.).
    /// </summary>
    public void AddColumnDef(DataGridColumn<TItem> column)
    {
        var key = ColumnKey(column);
        var existingIndex = _columnDefs.FindIndex(c => ColumnKey(c) == key);
        if (existingIndex >= 0)
        {
            // Preserve user-mutable state on the existing column object so a
            // re-init from the razor child doesn't blow away width/visibility/pin.
            var existing = _columnDefs[existingIndex];
            existing.Title = column.Title;
            existing.Field = column.Field;
            existing.FieldSelector = column.FieldSelector;
            existing.Sortable = column.Sortable;
            existing.Filterable = column.Filterable;
            existing.Resizable = column.Resizable;
            existing.FilterType = column.FilterType;
            existing.Aggregate = column.Aggregate;
            existing.Format = column.Format;
            existing.CellTemplate = column.CellTemplate;
            existing.HeaderTemplate = column.HeaderTemplate;
            existing.EditTemplate = column.EditTemplate;
            existing.FilterOptions = column.FilterOptions;
            existing.Operators = column.Operators;
            existing.FilterTemplate = column.FilterTemplate;
            existing.MinWidth = column.MinWidth;
            existing.MaxWidth = column.MaxWidth;
            existing.Pinnable = column.Pinnable;
            existing.Groupable = column.Groupable;
            existing.Reorderable = column.Reorderable;
            existing.CellClass = column.CellClass;
            existing.HeaderCssClass = column.HeaderCssClass;
            existing.CustomSort = column.CustomSort;
            existing.CssClass = column.CssClass;
            // Only apply width/pin from the redeclared column if the user has
            // not customised them at runtime (e.g. via resize/toggle).
            if (!existing.Visible && column.Visible) { /* keep user hidden */ }
            return;
        }

        _columnDefs.Add(column);
        SyncOrderedColumns();
        // DataGridColumnDef children register themselves from their own
        // OnInitialized, which runs AFTER the parent has already built its
        // render tree with an empty column list. Without this nudge the
        // header + body render blank until some other state change retriggers
        // a render. InvokeAsync defers the re-render to the next dispatcher
        // cycle so we don't recurse during the child's init.
        _ = InvokeAsync(StateHasChanged);
    }

    /// <summary>
    /// Removes a previously-registered column definition (called when a
    /// <see cref="DataGridColumnDef{TItem}"/> child is disposed).
    /// </summary>
    public void RemoveColumnDef(DataGridColumn<TItem> column)
    {
        var key = ColumnKey(column);
        var removed = _columnDefs.RemoveAll(c => ColumnKey(c) == key) > 0;
        if (removed)
        {
            _orderedColumns.RemoveAll(c => ColumnKey(c) == key);
            RefreshVisibleColumns();
            _ = InvokeAsync(StateHasChanged);
        }
    }

    /// <summary>Stable identity for a column: prefer Field (user-declared), fallback to Id.</summary>
    private static string ColumnKey(DataGridColumn<TItem> column) =>
        !string.IsNullOrWhiteSpace(column.Field) ? column.Field! : column.Id;

    public void UpdateColumnWidth(string columnId, double width)
    {
        var column = EffectiveColumns.FirstOrDefault(c => c.Id == columnId);
        if (column is not null)
        {
            column.Width = Math.Max(column.MinWidth ?? 50, width);
            if (column.MaxWidth.HasValue)
                column.Width = Math.Min(column.MaxWidth.Value, column.Width.Value);
            ScheduleAutoSave();
            StateHasChanged();
        }
    }

    private void SyncOrderedColumns()
    {
        var source = SourceColumns;

        // If ordered columns has different column set (by ID), rebuild from source
        // Preserve user-reordered order for existing columns
        var sourceIds = source.Select(c => c.Id).ToHashSet();
        var orderedIds = _orderedColumns.Select(c => c.Id).ToHashSet();

        if (sourceIds.SetEquals(orderedIds)) return; // Same columns, keep user order

        // Columns changed — rebuild keeping existing order for known columns, append new ones
        var newOrder = _orderedColumns.Where(c => sourceIds.Contains(c.Id)).ToList();
        var added = source.Where(c => !orderedIds.Contains(c.Id)).ToList();
        newOrder.AddRange(added);
        _orderedColumns = newOrder;
        RefreshVisibleColumns();
    }

    protected override async Task OnParametersSetAsync()
    {
        // Sync IsExpanded parameter with internal state (allow external binding to drive it)
        if (IsExpanded != _lastIsExpandedParam)
        {
            _lastIsExpandedParam = IsExpanded;
            if (_isExpanded != IsExpanded)
            {
                _isExpanded = IsExpanded;
                if (_isExpanded)
                {
                    _fullscreenNeedsFocus = true;
                    try { await Interop.LockScroll(); } catch { /* best-effort */ }
                }
                else
                {
                    try { await Interop.UnlockScroll(); } catch { /* best-effort */ }
                }
            }
        }

        SyncOrderedColumns();
        // Keep the cached visible-columns list in sync whenever parameters are evaluated.
        // SyncOrderedColumns only calls RefreshVisibleColumns when the column-ID set changes;
        // calling it here covers the first render when Columns are provided via the Columns
        // parameter rather than via declarative ColumnDef children.
        RefreshVisibleColumns();

        // Re-seed runtime grouping levels from GroupBy / GroupByFields if those params changed.
        SyncGroupFieldsFromParameters();

        // Snapshot default layout once (before any external SavedLayout is applied)
        if (_defaultLayout is null && EffectiveColumns.Count > 0)
        {
            _defaultLayout = SnapshotCurrentLayout();
        }

        // Apply SavedLayout when provided externally and not yet applied
        if (SavedLayout is not null && !_savedLayoutApplied)
        {
            _savedLayoutApplied = true;
            await ApplyLayoutAsync(SavedLayout);
            return; // ApplyLayoutAsync calls ProcessClientData
        }

        if (ServerMode)
        {
            _displayedItems = Items?.ToList() ?? new List<TItem>();
            RegroupServerItems();
        }
        else
        {
            ProcessClientData();
        }

        if (SelectedItems is not null)
        {
            _selectedItems = SelectedItems.ToList();
            _selectedSet = new HashSet<TItem>(_selectedItems);
        }

        PopulateToolbarContext();
    }

    /// <summary>
    /// Rebuilds the cascading <see cref="DataGridToolbarContext{TItem}"/> used by the
    /// extracted toolbar tool components (Fullscreen, CopySelected, Columns, Export,
    /// Layouts) as well as any custom tools placed inside <see cref="ToolbarContent{TItem}"/>.
    /// Called from OnParametersSetAsync so dependent values reflect the latest parameters.
    /// </summary>
    private void PopulateToolbarContext()
    {
        _toolbarContext.IsExpanded = _isExpanded;
        _toolbarContext.Expandable = Expandable;
        _toolbarContext.OnToggleExpanded = EventCallback.Factory.Create(this, ToggleExpanded);

        _toolbarContext.SelectedItems = _selectedItems;
        _toolbarContext.EffectiveColumns = EffectiveColumns.AsReadOnly();
        _toolbarContext.OnColumnToggle = EventCallback.Factory.Create<DataGridColumn<TItem>>(this, HandleColumnVisibilityChanged);
        _toolbarContext.OnColumnReorder = EventCallback.Factory.Create<ColumnReorderEventArgs>(this, HandleColumnReorder);

        _toolbarContext.ExportFormats = ExportFormats;
        _toolbarContext.OnExport = EventCallback.Factory.Create<string>(this, HandleExport);

        _toolbarContext.EnableLayoutPersistence = EnableLayoutPersistence;
        _toolbarContext.GlobalLayouts = GlobalLayouts;
        _toolbarContext.OnSaveLayout = EventCallback.Factory.Create(this, HandleSaveLayout);
        _toolbarContext.OnResetLayout = EventCallback.Factory.Create(this, HandleResetLayout);
        _toolbarContext.OnSaveNamedLayout = EventCallback.Factory.Create<DataGridNamedLayout>(this, HandleSaveNamedLayout);
        _toolbarContext.OnDeleteNamedLayout = OnDeleteNamedLayout;
        _toolbarContext.OnApplyNamedLayout = EventCallback.Factory.Create<DataGridNamedLayout>(this, HandleApplyNamedLayout);
        _toolbarContext.LayoutStorageKey = LayoutStorageKey;
        _toolbarContext.OnError = OnError;

        _toolbarContext.Interop = Interop;
        _toolbarContext.Localizer = L;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _layoutService ??= new DataGridLayoutService(Interop, ex =>
            {
                if (OnError.HasDelegate)
                    InvokeAsync(() => OnError.InvokeAsync(ex));
            });
            _serverService ??= new DataGridServerService();

            // Snapshot default layout now that columns are registered (via ColumnDef)
            if (_defaultLayout is null && EffectiveColumns.Count > 0)
            {
                _defaultLayout = SnapshotCurrentLayout();
            }

            if (EnableLayoutPersistence && !_layoutService.LayoutLoaded)
            {
                var key = LayoutStorageKey ?? _layoutId;
                await _layoutService.LoadPersistedLayoutAsync(key, ApplyLayoutAsync);
            }
        }

        if (_isExpanded && _fullscreenNeedsFocus)
        {
            _fullscreenNeedsFocus = false;
            try { await _fullscreenRef.FocusAsync(); } catch { /* element may not be ready */ }
        }
    }

    /// <summary>
    /// Toggles fullscreen expand mode for the grid. No-op when Expandable is false.
    /// Preserves grid state (selection, filters, sorts, pagination) across the transition
    /// because the underlying grid markup is rendered as the same render-tree sub-tree.
    /// </summary>
    public async Task ToggleExpanded()
    {
        _isExpanded = !_isExpanded;
        _lastIsExpandedParam = _isExpanded;
        IsExpanded = _isExpanded;

        if (_isExpanded)
        {
            _fullscreenNeedsFocus = true;
            try { await Interop.LockScroll(); } catch { /* best-effort */ }
            try { await Interop.SetHtmlClass("lumeo-fullscreen-active", true); } catch { /* best-effort */ }
        }
        else
        {
            try { await Interop.UnlockScroll(); } catch { /* best-effort */ }
            try { await Interop.SetHtmlClass("lumeo-fullscreen-active", false); } catch { /* best-effort */ }
        }

        if (IsExpandedChanged.HasDelegate)
            await IsExpandedChanged.InvokeAsync(_isExpanded);

        // Rebuild the cascading toolbar context so DataGridToolbarFullscreen sees the updated
        // IsExpanded and swaps its Maximize2/Minimize2 icon. Without this, the context was
        // only refreshed on parameter changes and the fullscreen icon stayed stale.
        PopulateToolbarContext();
        StateHasChanged();
    }

    private async Task HandleFullscreenKeyDown(KeyboardEventArgs e)
    {
        if (e.Key == "Escape" && _isExpanded)
        {
            await ToggleExpanded();
        }
    }

    protected override async Task OnInitializedAsync()
    {
        _layoutService ??= new DataGridLayoutService(Interop, ex =>
        {
            if (OnError.HasDelegate)
                InvokeAsync(() => OnError.InvokeAsync(ex));
        });
        _serverService ??= new DataGridServerService();

        if (ServerMode)
        {
            await RequestServerData();
        }
    }

    // Applies grouping to the server-returned page in ServerMode. The server has
    // already done sort/filter/page, so we treat _displayedItems as the buffer
    // and delegate to the same grouping routines client-mode uses — which keeps
    // multi-level, expand-state preservation, and aggregates symmetrical across
    // modes. Caveat: grouping is per-page; cross-page grouping requires the API
    // to deliver all rows (no pagination) or pre-aggregated groups.
    private void RegroupServerItems()
    {
        _processedItems = _displayedItems;
        var fields = EffectiveGroupFields;
        if (fields.Count == 1 && _displayedItems.Count > 0)
        {
            _groupedTree.Clear();
            ProcessSingleLevelGrouping(fields[0]);
        }
        else if (fields.Count > 1 && _displayedItems.Count > 0)
        {
            _groupedSections.Clear();
            ProcessMultiLevelGrouping(fields);
        }
        else
        {
            _lastGroupBy = null;
            _groupedSections.Clear();
            _groupedTree.Clear();
        }
    }

    private void ProcessClientData()
    {
        if (Items is null)
        {
            _processedItems = new List<TItem>();
            _displayedItems = new List<TItem>();
            return;
        }

        // Clean stale selections that reference items no longer in the data source
        if (_selectedItems.Count > 0)
        {
            var currentItems = new HashSet<TItem>(Items);
            var staleCount = _selectedItems.RemoveAll(item => !currentItems.Contains(item));
            if (staleCount > 0)
            {
                _selectedSet = new HashSet<TItem>(_selectedItems);
                NotifySelectionChanged();
            }
        }

        var query = Items.AsEnumerable();

        // Global search
        if (!string.IsNullOrWhiteSpace(_globalSearch))
        {
            var search = _globalSearch;
            query = query.Where(item =>
                EffectiveColumns.Any(col =>
                {
                    var value = col.GetFormattedValue(item);
                    return value.Contains(search, StringComparison.OrdinalIgnoreCase);
                }));
        }

        // Column filters
        foreach (var filter in _filters)
        {
            var column = EffectiveColumns.FirstOrDefault(c => c.Field == filter.Field);
            if (column is null) continue;

            query = query.Where(item =>
            {
                var value = column.GetValue(item);
                return DataGridFilterOperator.Evaluate(filter, value);
            });
        }

        // Sort
        IOrderedEnumerable<TItem>? ordered = null;
        foreach (var sort in _sorts.Where(s => s.Direction != SortDirection.None))
        {
            var column = EffectiveColumns.FirstOrDefault(c => c.Field == sort.Field);
            if (column is null) continue;

            if (ordered is null)
            {
                if (column.CustomSort is not null)
                {
                    var comp = column.CustomSort;
                    ordered = sort.Direction == SortDirection.Ascending
                        ? query.OrderBy(item => column.GetValue(item), Comparer<object?>.Create(comp))
                        : query.OrderByDescending(item => column.GetValue(item), Comparer<object?>.Create(comp));
                }
                else
                {
                    ordered = sort.Direction == SortDirection.Ascending
                        ? query.OrderBy(item => column.GetValue(item) as IComparable)
                        : query.OrderByDescending(item => column.GetValue(item) as IComparable);
                }
            }
            else
            {
                if (column.CustomSort is not null)
                {
                    var comp = column.CustomSort;
                    ordered = sort.Direction == SortDirection.Ascending
                        ? ordered.ThenBy(item => column.GetValue(item), Comparer<object?>.Create(comp))
                        : ordered.ThenByDescending(item => column.GetValue(item), Comparer<object?>.Create(comp));
                }
                else
                {
                    ordered = sort.Direction == SortDirection.Ascending
                        ? ordered.ThenBy(item => column.GetValue(item) as IComparable)
                        : ordered.ThenByDescending(item => column.GetValue(item) as IComparable);
                }
            }
        }

        _processedItems = (ordered as IEnumerable<TItem> ?? query).ToList();

        // --- Tree-grid mode takes priority over grouping ---
        if (IsTreeGridMode)
        {
            _groupedSections.Clear();
            _groupedTree.Clear();
            _runtimeGroupFields.Clear();
            RebuildTreeView();
            _displayedItems = _treeDisplayRows;
            return; // tree-grid manages its own row set (no pagination)
        }
        _treeDisplayRows = new List<TItem>();

        // --- Row grouping (single- or multi-level) ---
        var groupFields = EffectiveGroupFields;
        if (groupFields.Count == 1)
        {
            _groupedTree.Clear();
            if (ProcessSingleLevelGrouping(groupFields[0]))
                return; // skip normal pagination when grouped
        }
        else if (groupFields.Count > 1)
        {
            _groupedSections.Clear();
            if (ProcessMultiLevelGrouping(groupFields))
                return;
        }
        _lastGroupBy = null;
        _groupedSections.Clear();
        _groupedTree.Clear();

        // Paginate
        if (ShowPagination && PageSize > 0)
        {
            var totalPages = Math.Max(1, (int)Math.Ceiling((double)_processedItems.Count / PageSize));
            if (_currentPage > totalPages) _currentPage = totalPages;
            if (_currentPage < 1) _currentPage = 1;

            _displayedItems = _processedItems
                .Skip((_currentPage - 1) * PageSize)
                .Take(PageSize)
                .ToList();
        }
        else
        {
            _displayedItems = _processedItems;
        }

        // Batch-edit: surface added-but-unsaved rows at the end of the
        // displayed list so the user can fill them in inline. They are NOT in
        // _processedItems (so not in sort/filter/aggregates) — they live only
        // in the batch buffer until OnBatchSave commits them upstream.
        if (EditMode == DataGridEditMode.Batch && _batchAdded.Count > 0)
        {
            if (ReferenceEquals(_displayedItems, _processedItems))
            {
                _displayedItems = new List<TItem>(_processedItems);
            }
            _displayedItems.AddRange(_batchAdded);
        }
    }

    // =====================================================================
    //  Multi-level grouping + tree-grid (rc.35)
    // =====================================================================

    /// <summary>True when the grid is in hierarchical tree-grid mode (a
    /// <see cref="ChildItemsSelector"/> is set and no grouping is active —
    /// tree-grid wins over grouping when both are configured).</summary>
    internal bool IsTreeGridMode => ChildItemsSelector is not null;

    /// <summary>The grouping levels actually in effect — runtime panel edits if
    /// any have been made, otherwise seeded from the parameters.</summary>
    private IReadOnlyList<string> EffectiveGroupFields => _runtimeGroupFields;

    /// <summary>Re-seeds <see cref="_runtimeGroupFields"/> from the parameters when
    /// <see cref="GroupBy"/> / <see cref="GroupByFields"/> change. Runtime panel
    /// edits (via AddGroupField/RemoveGroupField) are preserved across renders
    /// because the seed only re-applies when the parameter values themselves change.</summary>
    private void SyncGroupFieldsFromParameters()
    {
        var byFieldsKey = GroupByFields is null ? null : string.Join("", GroupByFields);
        if (byFieldsKey == _seededGroupByFieldsKey && GroupBy == _seededGroupBy) return;
        _seededGroupByFieldsKey = byFieldsKey;
        _seededGroupBy = GroupBy;

        _runtimeGroupFields = (GroupByFields is { Count: > 0 }
            ? GroupByFields.ToList()
            : (!string.IsNullOrEmpty(GroupBy) ? new List<string> { GroupBy! } : new List<string>()))
            .Where(f => EffectiveColumns.Any(c => c.Field == f))
            .Distinct()
            .ToList();
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _lastGroupBy = null;
    }

    private void AddGroupField(string field)
    {
        if (string.IsNullOrEmpty(field)) return;
        if (_runtimeGroupFields.Contains(field)) return;
        if (!EffectiveColumns.Any(c => c.Field == field && c.Groupable)) return;
        _runtimeGroupFields.Add(field);
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _currentPage = 1;
        if (ServerMode) RegroupServerItems();
        else ProcessClientData();
        // Mirror Sort/Filter behaviour — persist the grouping change so a
        // reload restores it. Without this the snapshot serialization built
        // in v2.1.1 had nothing to act on for runtime-panel additions.
        ScheduleAutoSave();
        StateHasChanged();
    }

    private void RemoveGroupField(string field)
    {
        if (!_runtimeGroupFields.Remove(field)) return;
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _currentPage = 1;
        if (ServerMode) RegroupServerItems();
        else ProcessClientData();
        ScheduleAutoSave();
        StateHasChanged();
    }

    /// <summary>Removes all grouping levels at once. Wired to the panel's "Clear"
    /// button (rc.41). Resets group expansion state the same way single-field
    /// removal does so re-grouping after Clear has a clean slate.</summary>
    private void ClearGroupFields()
    {
        if (_runtimeGroupFields.Count == 0) return;
        _runtimeGroupFields.Clear();
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _currentPage = 1;
        if (ServerMode) RegroupServerItems();
        else ProcessClientData();
        ScheduleAutoSave();
        StateHasChanged();
    }

    // --- Drag-to-group: panel drop-zone handlers (rc.41) -----------------
    // HeaderCell sets _dragState.SourceColumnId on dragstart (when the column
    // is Groupable + ShowGroupPanel). The panel reads it on drop and resolves
    // back to the column field to add a grouping level.
    private bool _isGroupPanelDragOver;

    private void HandleGroupPanelDragOver(DragEventArgs e)
    {
        // No-op — :preventDefault is the only thing the browser needs to accept
        // the drop. We track over/enter/leave via the dedicated events below
        // because dragover fires continuously and would over-render.
    }

    private void HandleGroupPanelDragEnter(DragEventArgs e)
    {
        // Only show the drop-accept highlight when the dragged column is
        // actually a valid drop target — Groupable, has a field, not already
        // grouped. Avoids the misleading "primary-blue ready" highlight for
        // Reorderable-only drags or columns the panel can't accept.
        if (_dragState.SourceColumnIndex < 0) return;
        var srcCol = EffectiveColumns.FirstOrDefault(c => c.Id == _dragState.SourceColumnId);
        if (srcCol?.Groupable != true || srcCol.Field is null) return;
        if (_runtimeGroupFields.Contains(srcCol.Field)) return;
        _isGroupPanelDragOver = true;
    }

    private void HandleGroupPanelDragLeave(DragEventArgs e)
    {
        _isGroupPanelDragOver = false;
    }

    private void HandleGroupPanelDrop(DragEventArgs e)
    {
        _isGroupPanelDragOver = false;
        if (_dragState.SourceColumnIndex < 0) return;

        // Resolve source column → field. Fall back to ID-based lookup if the
        // index drifted (column reorder during the drag); ID is stable.
        var srcCol = EffectiveColumns.FirstOrDefault(c => c.Id == _dragState.SourceColumnId)
                    ?? EffectiveColumns.ElementAtOrDefault(_dragState.SourceColumnIndex);
        _dragState.ResetColumn();

        if (srcCol?.Field is null || !srcCol.Groupable) return;
        AddGroupField(srcCol.Field);
    }

    /// <summary>Computes the per-group aggregate strip for a set of items.</summary>
    private IReadOnlyList<DataGridGroupAggregate> ComputeGroupAggregates(IReadOnlyList<TItem> items)
    {
        var result = new List<DataGridGroupAggregate>();
        foreach (var col in EffectiveColumns.Where(c => c.Visible && c.Aggregate != AggregateType.None))
        {
            result.Add(new DataGridGroupAggregate(
                col.Id,
                AggregateLabel(col.Aggregate),
                ComputeColumnAggregate(col, items)));
        }
        return result;
    }

    private string AggregateLabel(AggregateType t) => t switch
    {
        AggregateType.Sum => L["DataGrid.AggregateSum"],
        AggregateType.Average => L["DataGrid.AggregateAvg"],
        AggregateType.Count => L["DataGrid.AggregateCount"],
        AggregateType.Min => L["DataGrid.AggregateMin"],
        AggregateType.Max => L["DataGrid.AggregateMax"],
        _ => ""
    };

    private string ComputeColumnAggregate(DataGridColumn<TItem> col, IReadOnlyList<TItem> items)
    {
        if (items.Count == 0) return "-";
        var values = items.Select(col.GetValue).Where(v => v is not null).ToList();
        if (col.Aggregate == AggregateType.Count) return values.Count.ToString();

        var numeric = new List<double>();
        foreach (var v in values)
        {
            if (v is IConvertible conv)
            {
                try { numeric.Add(conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture)); }
                catch { /* skip non-numeric */ }
            }
        }
        if (numeric.Count == 0) return "-";
        double r = col.Aggregate switch
        {
            AggregateType.Sum => numeric.Sum(),
            AggregateType.Average => numeric.Average(),
            AggregateType.Min => numeric.Min(),
            AggregateType.Max => numeric.Max(),
            _ => 0
        };
        return r.ToString("N2");
    }

    // ---- Single-level grouping (classic; now with per-group aggregates) ----
    private bool ProcessSingleLevelGrouping(string field)
    {
        var groupCol = EffectiveColumns.FirstOrDefault(c => c.Field == field);
        if (groupCol is null) return false;

        var groupByChanged = field != _lastGroupBy;
        _lastGroupBy = field;

        _groupedSections = _processedItems
            .GroupBy(item => groupCol.GetFormattedValue(item, EffectiveCulture))
            .OrderBy(g => g.Key, StringComparer.CurrentCulture)
            .Select(g =>
            {
                var its = g.ToList();
                return new DataGridGroupSection<TItem>(g.Key, its, its.Count)
                {
                    Aggregates = ComputeGroupAggregates(its)
                };
            })
            .ToList();

        var defaultChanged = _lastGroupsExpandedByDefault is not null && _lastGroupsExpandedByDefault != GroupsExpandedByDefault;
        _lastGroupsExpandedByDefault = GroupsExpandedByDefault;

        if (groupByChanged || defaultChanged)
        {
            _expandedGroups.Clear();
            _knownGroupKeys.Clear();
            foreach (var section in _groupedSections)
            {
                _knownGroupKeys.Add(section.Key);
                if (GroupsExpandedByDefault) _expandedGroups.Add(section.Key);
            }
        }
        else
        {
            var validKeys = _groupedSections.Select(s => s.Key).ToHashSet();
            _expandedGroups.IntersectWith(validKeys);
            _knownGroupKeys.IntersectWith(validKeys);
            foreach (var section in _groupedSections)
            {
                if (_knownGroupKeys.Contains(section.Key)) continue;
                _knownGroupKeys.Add(section.Key);
                if (GroupsExpandedByDefault) _expandedGroups.Add(section.Key);
            }
        }

        _displayedItems = _groupedSections
            .Where(g => _expandedGroups.Contains(g.Key))
            .SelectMany(g => g.Items)
            .ToList();
        return true;
    }

    // ---- Multi-level grouping (nested tree) ----
    private bool ProcessMultiLevelGrouping(IReadOnlyList<string> fields)
    {
        _groupedTree = BuildGroupNodes(_processedItems, fields, 0, "");
        if (_groupedTree.Count == 0)
        {
            _groupedSections.Clear();
            return false;
        }

        var defaultChanged = _lastGroupsExpandedByDefault is not null && _lastGroupsExpandedByDefault != GroupsExpandedByDefault;
        _lastGroupsExpandedByDefault = GroupsExpandedByDefault;

        // Collect every node path (depth-first) to manage expand state.
        var allPaths = new List<string>();
        void Collect(IEnumerable<DataGridGroupNode<TItem>> nodes)
        {
            foreach (var n in nodes) { allPaths.Add(n.Path); Collect(n.Children); }
        }
        Collect(_groupedTree);
        var validSet = allPaths.ToHashSet();

        if (defaultChanged)
        {
            _expandedGroupPaths.Clear();
            _knownGroupPaths.Clear();
        }
        _expandedGroupPaths.IntersectWith(validSet);
        _knownGroupPaths.IntersectWith(validSet);
        foreach (var p in allPaths)
        {
            if (_knownGroupPaths.Contains(p)) continue;
            _knownGroupPaths.Add(p);
            if (GroupsExpandedByDefault) _expandedGroupPaths.Add(p);
        }

        // Flatten leaf items that live under fully-expanded ancestor chains.
        var visible = new List<TItem>();
        void Flatten(IEnumerable<DataGridGroupNode<TItem>> nodes)
        {
            foreach (var n in nodes)
            {
                if (!_expandedGroupPaths.Contains(n.Path)) continue;
                if (n.Children.Count > 0) Flatten(n.Children);
                else visible.AddRange(n.Items);
            }
        }
        Flatten(_groupedTree);
        _displayedItems = visible;
        return true;
    }

    private List<DataGridGroupNode<TItem>> BuildGroupNodes(
        IReadOnlyList<TItem> items, IReadOnlyList<string> fields, int level, string parentPath)
    {
        if (level >= fields.Count) return new();
        var col = EffectiveColumns.FirstOrDefault(c => c.Field == fields[level]);
        if (col is null)
        {
            // Skip a missing field — try the next level so grouping degrades gracefully.
            return BuildGroupNodes(items, fields, level + 1, parentPath);
        }
        return items
            .GroupBy(it => col.GetFormattedValue(it, EffectiveCulture))
            .OrderBy(g => g.Key, StringComparer.CurrentCulture)
            .Select(g =>
            {
                var its = g.ToList();
                var path = string.IsNullOrEmpty(parentPath) ? g.Key : parentPath + " / " + g.Key;
                var children = level + 1 < fields.Count
                    ? BuildGroupNodes(its, fields, level + 1, path)
                    : new List<DataGridGroupNode<TItem>>();
                return new DataGridGroupNode<TItem>(
                    Key: g.Key,
                    Path: path,
                    Level: level,
                    Field: fields[level],
                    TotalCount: its.Count,
                    Items: children.Count > 0 ? new List<TItem>() : its,
                    Children: children)
                {
                    Aggregates = ComputeGroupAggregates(its)
                };
            })
            .ToList();
    }

    private bool IsGroupPathExpanded(string path) => _expandedGroupPaths.Contains(path);

    private void ToggleGroupPath(string path)
    {
        if (!_expandedGroupPaths.Remove(path)) _expandedGroupPaths.Add(path);
        ProcessClientData();
        StateHasChanged();
    }

    // ---- Tree-grid mode ----

    /// <summary>Currently-resolved tree column field (null = first visible column).</summary>
    private string? EffectiveTreeColumnField
    {
        get
        {
            if (!string.IsNullOrEmpty(TreeColumnField) &&
                _visibleColumns.Any(c => c.Field == TreeColumnField))
                return TreeColumnField;
            return _visibleColumns.FirstOrDefault()?.Field;
        }
    }

    private bool MatchesCurrentFilters(TItem item)
    {
        if (!string.IsNullOrWhiteSpace(_globalSearch))
        {
            var s = _globalSearch;
            if (!EffectiveColumns.Any(col => col.GetFormattedValue(item, EffectiveCulture).Contains(s, StringComparison.OrdinalIgnoreCase)))
                return false;
        }
        foreach (var filter in _filters)
        {
            var column = EffectiveColumns.FirstOrDefault(c => c.Field == filter.Field);
            if (column is null) continue;
            if (!DataGridFilterOperator.Evaluate(filter, column.GetValue(item))) return false;
        }
        return true;
    }

    private IEnumerable<TItem> SortSiblings(IEnumerable<TItem> items)
    {
        IOrderedEnumerable<TItem>? ordered = null;
        foreach (var sort in _sorts.Where(s => s.Direction != SortDirection.None))
        {
            var column = EffectiveColumns.FirstOrDefault(c => c.Field == sort.Field);
            if (column is null) continue;
            Func<TItem, object?> keySel = column.GetValue;
            if (ordered is null)
            {
                ordered = column.CustomSort is not null
                    ? (sort.Direction == SortDirection.Ascending
                        ? items.OrderBy(keySel, Comparer<object?>.Create(column.CustomSort))
                        : items.OrderByDescending(keySel, Comparer<object?>.Create(column.CustomSort)))
                    : (sort.Direction == SortDirection.Ascending
                        ? items.OrderBy(it => column.GetValue(it) as IComparable)
                        : items.OrderByDescending(it => column.GetValue(it) as IComparable));
            }
            else
            {
                ordered = column.CustomSort is not null
                    ? (sort.Direction == SortDirection.Ascending
                        ? ordered.ThenBy(keySel, Comparer<object?>.Create(column.CustomSort))
                        : ordered.ThenByDescending(keySel, Comparer<object?>.Create(column.CustomSort)))
                    : (sort.Direction == SortDirection.Ascending
                        ? ordered.ThenBy(it => column.GetValue(it) as IComparable)
                        : ordered.ThenByDescending(it => column.GetValue(it) as IComparable));
            }
        }
        return ordered ?? items;
    }

    /// <summary>Recursively filters a tree: keeps a node when it matches the current
    /// filters OR has a kept descendant. Returns null when the node is pruned.</summary>
    private bool TreeNodeKept(TItem item)
    {
        if (MatchesCurrentFilters(item)) return true;
        var kids = ChildItemsSelector?.Invoke(item);
        return kids is not null && kids.Any(TreeNodeKept);
    }

    /// <summary>Rebuilds the flattened tree-grid view (visible rows + per-row level
    /// and has-children caches) from <see cref="_processedItems"/>.</summary>
    private void RebuildTreeView()
    {
        _treeDisplayRows = new List<TItem>();
        _treeLevelOf = new Dictionary<TItem, int>();
        _treeHasChildren = new Dictionary<TItem, bool>();

        var hasFilter = !string.IsNullOrWhiteSpace(_globalSearch) || _filters.Count > 0;

        // Seed expand state once (or when the default flips).
        if (!_treeExpandSeeded)
        {
            _treeExpandSeeded = true;
            if (TreeGridDefaultExpanded)
            {
                void SeedExpand(IEnumerable<TItem> nodes)
                {
                    foreach (var n in nodes)
                    {
                        var kids = ChildItemsSelector?.Invoke(n);
                        if (kids is not null && kids.Any())
                        {
                            _expandedTreeNodes.Add(n);
                            SeedExpand(kids);
                        }
                    }
                }
                SeedExpand(_processedItems);
            }
        }

        void Walk(IEnumerable<TItem> nodes, int level)
        {
            // Sort siblings; in tree mode _processedItems was already sorted at root,
            // but children come straight from the selector, so sort them here too.
            var siblings = (level == 0 ? nodes : SortSiblings(nodes)).ToList();
            foreach (var node in siblings)
            {
                if (hasFilter && !TreeNodeKept(node)) continue;
                var kids = ChildItemsSelector?.Invoke(node)?.ToList();
                var keptKids = kids is null ? null : (hasFilter ? kids.Where(TreeNodeKept).ToList() : kids);
                var hasKids = keptKids is { Count: > 0 };
                _treeLevelOf[node] = level;
                _treeHasChildren[node] = hasKids;
                _treeDisplayRows.Add(node);
                if (hasKids && _expandedTreeNodes.Contains(node))
                    Walk(keptKids!, level + 1);
            }
        }
        Walk(_processedItems, 0);
    }

    private bool IsTreeNodeExpanded(TItem item) => _expandedTreeNodes.Contains(item);

    private int TreeLevelOf(TItem item) => _treeLevelOf.TryGetValue(item, out var l) ? l : 0;

    private bool TreeHasChildrenOf(TItem item) => _treeHasChildren.TryGetValue(item, out var b) && b;

    private async Task ToggleTreeNodeAsync(TItem item)
    {
        if (!_expandedTreeNodes.Remove(item)) _expandedTreeNodes.Add(item);
        if (!ServerMode) ProcessClientData();
        if (OnTreeNodeExpand.HasDelegate) await OnTreeNodeExpand.InvokeAsync(item);
        StateHasChanged();
    }

    private async Task HandleSort(SortDescriptor sort)
    {
        _sorts.RemoveAll(s => s.Field == sort.Field);
        if (sort.Direction != SortDirection.None)
        {
            _sorts.Add(sort);
        }

        _currentPage = 1;
        ScheduleAutoSave();

        if (_useServerVirtualization)
        {
            await RefreshVirtualizedAsync();
        }
        else if (ServerMode)
        {
            await RequestServerData();
        }
        else
        {
            ProcessClientData();
        }
    }

    private async Task HandleFilter(FilterDescriptor filter)
    {
        _filters.RemoveAll(f => f.Field == filter.Field);
        _filters.Add(filter);
        _currentPage = 1;
        ScheduleAutoSave();

        if (_useServerVirtualization)
        {
            await RefreshVirtualizedAsync();
        }
        else if (ServerMode)
        {
            await RequestServerData();
        }
        else
        {
            ProcessClientData();
        }
    }

    private async Task RemoveFilter(FilterDescriptor filter)
    {
        _filters.RemoveAll(f => f.Field == filter.Field);
        _currentPage = 1;
        ScheduleAutoSave();

        if (_useServerVirtualization)
        {
            await RefreshVirtualizedAsync();
        }
        else if (ServerMode)
        {
            await RequestServerData();
        }
        else
        {
            ProcessClientData();
        }
    }

    private async Task HandleGlobalSearch(string search)
    {
        _globalSearch = string.IsNullOrWhiteSpace(search) ? null : search;
        _currentPage = 1;
        ScheduleAutoSave();

        if (_useServerVirtualization)
        {
            // Debounce the same way ServerMode does — typing should not
            // round-trip on every keystroke. Guard against lazy init: the
            // service is normally created in OnAfterRender, but search may
            // fire before that on a slow first paint.
            _serverService ??= new DataGridServerService();
            _serverService.DebounceSearch(async ct =>
            {
                if (ct.IsCancellationRequested) return;
                await InvokeAsync(async () =>
                {
                    await RefreshVirtualizedAsync();
                    StateHasChanged();
                });
            });
        }
        else if (ServerMode)
        {
            _serverService!.DebounceSearch(async ct =>
            {
                if (ct.IsCancellationRequested) return;
                await InvokeAsync(async () =>
                {
                    await RequestServerData();
                    StateHasChanged();
                });
            });
        }
        else
        {
            ProcessClientData();
            StateHasChanged();
        }
    }

    private async Task HandlePageChanged(int page)
    {
        _currentPage = page;

        if (ServerMode)
        {
            await RequestServerData();
        }
        else
        {
            ProcessClientData();
            StateHasChanged();
        }
    }

    private async Task HandlePageSizeChanged(int pageSize)
    {
        PageSize = pageSize;
        _currentPage = 1;
        ScheduleAutoSave();

        if (ServerMode)
        {
            await RequestServerData();
        }
        else
        {
            ProcessClientData();
            StateHasChanged();
        }
    }

    private void HandleSelectionChanged(TItem item)
    {
        // Handled by ToggleSelection
    }

    private bool IsRowSelected(TItem item) => _selectedSet.Contains(item);

    private bool IsRowExpanded(TItem item) => _expandedRows.Contains(item);

    private bool IsRowEditing(TItem item) => _editingRows.Contains(item);

    private void ToggleSelection(TItem item)
    {
        if (SelectionMode == DataGridSelectionMode.Single)
        {
            var wasSelected = _selectedSet.Contains(item);
            _selectedSet.Clear();
            _selectedItems.Clear();

            if (!wasSelected)
            {
                _selectedSet.Add(item);
                _selectedItems.Add(item);
            }
        }
        else if (SelectionMode == DataGridSelectionMode.Multiple)
        {
            if (_selectedSet.Contains(item))
            {
                _selectedSet.Remove(item);
                _selectedItems.Remove(item);
            }
            else
            {
                _selectedSet.Add(item);
                _selectedItems.Add(item);
            }
        }

        NotifySelectionChanged();
        StateHasChanged();
    }

    private void SelectAll()
    {
        _selectedItems = _displayedItems.ToList();
        _selectedSet = new HashSet<TItem>(_selectedItems);
        NotifySelectionChanged();
        StateHasChanged();
    }

    private void ClearSelection()
    {
        _selectedItems.Clear();
        _selectedSet.Clear();
        NotifySelectionChanged();
        StateHasChanged();
    }

    /// <summary>
    /// Invokes the <see cref="SelectedItemsChanged"/> EventCallback safely from sync
    /// call sites. The previous implementation discarded the returned Task; a faulting
    /// async consumer handler would surface as an UnobservedTaskException AND the parent
    /// re-render after <see cref="StateHasChanged"/> could race a still-running consumer
    /// callback. <see cref="Lumeo.Services.SafeAsyncDispatcher.FireAndForget"/> dispatches
    /// the InvokeAsync via the component's renderer, swallows lifecycle-class
    /// exceptions silently, and logs anything unexpected with a structured tag.
    /// </summary>
    private void NotifySelectionChanged()
    {
        if (!SelectedItemsChanged.HasDelegate) return;
        var snapshot = _selectedItems.AsReadOnly();
        Lumeo.Services.SafeAsyncDispatcher.FireAndForget(
            InvokeAsync,
            () => SelectedItemsChanged.InvokeAsync(snapshot),
            "DataGrid.SelectionChanged");
    }

    private void ToggleGroupExpand(string key)
    {
        if (_expandedGroups.Contains(key))
            _expandedGroups.Remove(key);
        else
            _expandedGroups.Add(key);
        ProcessClientData();
        StateHasChanged();
    }

    private bool IsGroupExpanded(string key) => _expandedGroups.Contains(key);

    private void ToggleRowExpand(TItem item)
    {
        if (_expandedRows.Contains(item))
            _expandedRows.Remove(item);
        else
            _expandedRows.Add(item);
        StateHasChanged();
    }

    // --- Row Editing ---

    private void StartRowEdit(TItem item)
    {
        if (_editingRows.Contains(item)) return;
        _editingRows.Add(item);
        // Snapshot current values for all editable columns
        var editDict = new Dictionary<string, object?>();
        foreach (var col in EffectiveColumns.Where(c => c.Field is not null))
        {
            editDict[col.Field!] = col.GetValue(item);
        }
        _rowEditValues[item] = editDict;
        StateHasChanged();
    }

    private void CancelRowEdit(TItem item)
    {
        _editingRows.Remove(item);
        _rowEditValues.Remove(item);
        StateHasChanged();
    }

    private async Task CommitRowEditAsync(TItem item)
    {
        if (!_editingRows.Contains(item)) return;
        _editingRows.Remove(item);

        if (_rowEditValues.TryGetValue(item, out var editDict))
        {
            _rowEditValues.Remove(item);
            if (OnRowEdit.HasDelegate)
            {
                await OnRowEdit.InvokeAsync(new RowEditEventArgs<TItem>(item, editDict));
            }
        }
        StateHasChanged();
    }

    private object? GetEditValue(TItem item, DataGridColumn<TItem> column)
    {
        if (column.Field is null) return null;
        if (_rowEditValues.TryGetValue(item, out var dict) && dict.TryGetValue(column.Field, out var val))
            return val;
        return column.GetValue(item);
    }

    private void SetEditValue(TItem item, DataGridColumn<TItem> column, object? value)
    {
        if (column.Field is null) return;
        if (!_rowEditValues.ContainsKey(item))
            _rowEditValues[item] = new Dictionary<string, object?>();
        _rowEditValues[item][column.Field] = value;
    }

    // --- Batch-edit buffer ---

    /// <summary>True when (item, field) currently has a buffered value.</summary>
    private bool IsCellDirty(TItem item, string field) =>
        item is not null
        && _batchChanges.TryGetValue(item, out var dict)
        && dict.ContainsKey(field);

    /// <summary>Returns the buffered batch value for (item, column) if any,
    /// else the row's current value. Used by cells in batch mode.</summary>
    private object? GetBatchValue(TItem item, DataGridColumn<TItem> column)
    {
        if (column.Field is not null
            && item is not null
            && _batchChanges.TryGetValue(item, out var dict)
            && dict.TryGetValue(column.Field, out var v))
        {
            return v;
        }
        return column.GetValue(item);
    }

    /// <summary>Writes a buffered value into the pending-changes buffer. No
    /// row mutation and no immediate OnCellEdit/OnRowEdit; the value lives
    /// until "Save all" or "Discard".</summary>
    internal void SetBatchValue(TItem item, DataGridColumn<TItem> column, object? value)
    {
        if (item is null || column.Field is null) return;
        if (!_batchChanges.TryGetValue(item, out var dict))
        {
            dict = new Dictionary<string, object?>();
            _batchChanges[item] = dict;
        }
        dict[column.Field] = value;
        StateHasChanged();
    }

    /// <summary>Fires <see cref="OnBatchSave"/> with the buffered changes.
    /// On a clean return the buffer is cleared.</summary>
    private async Task SaveBatchAsync()
    {
        if (!HasPendingChanges) return;

        var addedSet = new HashSet<TItem>(_batchAdded);
        var modified = new List<DataGridBatchChange<TItem>>();
        var added = new List<DataGridBatchChange<TItem>>();
        foreach (var (item, fields) in _batchChanges)
        {
            var copy = new Dictionary<string, object?>(fields);
            var change = new DataGridBatchChange<TItem>(item, copy, addedSet.Contains(item));
            (addedSet.Contains(item) ? added : modified).Add(change);
        }
        // Surface added rows that have no buffered field edits yet.
        foreach (var item in _batchAdded)
        {
            if (!_batchChanges.ContainsKey(item))
            {
                added.Add(new DataGridBatchChange<TItem>(item, new Dictionary<string, object?>(), true));
            }
        }

        var args = new DataGridBatchSaveEventArgs<TItem>(modified, added);
        if (OnBatchSave.HasDelegate)
        {
            await OnBatchSave.InvokeAsync(args);
        }
        // Consumer exceptions propagate to Blazor's error boundary; we don't
        // swallow them — but on clean return we clear the buffer.
        _batchChanges.Clear();
        _batchAdded.Clear();
        StateHasChanged();
    }

    /// <summary>Drops all buffered edits and any unsaved added rows.</summary>
    private void DiscardBatch()
    {
        if (!HasPendingChanges) return;
        _batchChanges.Clear();
        _batchAdded.Clear();
        StateHasChanged();
    }

    /// <summary>Append a new row via <see cref="NewItemFactory"/>. The row
    /// lives in the batch buffer until the consumer wires it up via
    /// <see cref="OnBatchSave"/>.</summary>
    private void AddBatchRow()
    {
        if (NewItemFactory is null) return;
        var item = NewItemFactory();
        if (item is null) return;
        _batchAdded.Add(item);
        // Seed an empty pending-changes dict so HasPendingChanges flips true
        // even before the user types anything.
        if (!_batchChanges.ContainsKey(item))
        {
            _batchChanges[item] = new Dictionary<string, object?>();
        }
        StateHasChanged();
    }

    /// <summary>Test-only hook for inspecting a row's dirty-field set.</summary>
    internal IReadOnlyDictionary<string, object?>? GetBatchChanges(TItem item) =>
        item is not null && _batchChanges.TryGetValue(item, out var d) ? d : null;

    // --- Column Reordering ---

    private async Task HandleColumnReorder(ColumnReorderEventArgs args)
    {
        if (args.OldIndex < 0 || args.NewIndex < 0 || args.OldIndex >= _orderedColumns.Count || args.NewIndex >= _orderedColumns.Count)
            return;

        var col = _orderedColumns[args.OldIndex];
        _orderedColumns.RemoveAt(args.OldIndex);
        _orderedColumns.Insert(args.NewIndex, col);
        RefreshVisibleColumns();

        // Await consumer handler so async handlers complete before we re-render.
        // Previously the returned Task was discarded which let the next
        // StateHasChanged race a still-running consumer callback and let
        // exceptions in async handlers go unobserved.
        if (OnColumnReorder.HasDelegate)
            await OnColumnReorder.InvokeAsync(args);

        ScheduleAutoSave();
        StateHasChanged();
    }

    // --- Row Reordering ---

    public void MoveRow(int oldIndex, int newIndex)
    {
        if (oldIndex < 0 || newIndex < 0 || oldIndex >= _displayedItems.Count || newIndex >= _displayedItems.Count)
            return;

        var item = _displayedItems[oldIndex];
        _displayedItems.RemoveAt(oldIndex);
        _displayedItems.Insert(newIndex, item);

        // Public sync API — can't change return type without breaking consumers.
        // Dispatch the consumer callback through SafeAsyncDispatcher so faults
        // in async handlers are observed (logged) instead of becoming
        // UnobservedTaskExceptions, and so a faulting outer InvokeAsync (e.g.
        // disposed renderer) is also handled.
        if (OnRowReorder.HasDelegate)
        {
            var args = new RowReorderEventArgs<TItem>(item, oldIndex, newIndex);
            Lumeo.Services.SafeAsyncDispatcher.FireAndForget(
                InvokeAsync,
                () => OnRowReorder.InvokeAsync(args),
                "DataGrid.RowReorder");
        }

        StateHasChanged();
    }

    // --- Context Menu ---

    private void HandleContextMenu(TItem item, double x, double y)
    {
        if (RowContextMenu is null) return;
        _contextMenuItem = item;
        _contextMenuX = x;
        _contextMenuY = y;
        _contextMenuVisible = true;
        StateHasChanged();
    }

    private void CloseContextMenu()
    {
        if (_contextMenuVisible)
        {
            _contextMenuVisible = false;
            StateHasChanged();
        }
    }

    private void HandleColumnVisibilityChanged(DataGridColumn<TItem> column)
    {
        RefreshVisibleColumns();
        if (!ServerMode) ProcessClientData();
        ScheduleAutoSave();
        StateHasChanged();
    }

    private void HandleFilterToggle(string? field)
    {
        _activeFilterColumn = _activeFilterColumn == field ? null : field;
        StateHasChanged();
    }

    private async Task HandleFilterApply(FilterApplyEventArgs args)
    {
        if (args.Filter is not null)
        {
            await HandleFilter(args.Filter);
        }
        else if (args.Field is not null)
        {
            var existing = _filters.FirstOrDefault(f => f.Field == args.Field);
            if (existing is not null)
            {
                await RemoveFilter(existing);
            }
        }

        _activeFilterColumn = null;
        StateHasChanged();
    }

    private async Task HandleExport(string format)
    {
        var items = ServerMode ? _displayedItems : _processedItems;
        var culture = EffectiveCulture;
        var visibleColumns = EffectiveColumns.Where(c => c.Visible).ToList();
        var exportColumns = visibleColumns.Select(c => new Lumeo.Services.DataGridExportColumn<TItem>(
            Header: c.Title ?? c.Field ?? c.Id,
            Accessor: row => c.GetValue(row),
            Format: c.Format
        )).ToList();

        try
        {
            switch (format)
            {
                case "json":
                    // JSON uses the formatted (culture-aware) string representation per column
                    // so consumers get a row shape that mirrors the visible grid.
                    {
                        var rows = items.Select(item =>
                        {
                            var dict = new Dictionary<string, string?>();
                            foreach (var col in visibleColumns)
                            {
                                var key = col.Title ?? col.Field ?? col.Id;
                                dict[key] = col.GetFormattedValue(item, culture);
                            }
                            return dict;
                        }).ToList();
                        var json = System.Text.Json.JsonSerializer.Serialize(rows, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
                        var bytes = System.Text.Encoding.UTF8.GetBytes(json);
                        await Export.DownloadAsync(bytes, "export.json", "application/json");
                        break;
                    }
                case "excel":
                    {
                        var bytes = Export.ToExcel(items, exportColumns, sheetName: "Data", culture: culture);
                        await Export.DownloadAsync(bytes, "export.xlsx",
                            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
                        break;
                    }
                case "pdf":
                    {
                        // QuestPDF isn't supported on browser-wasm — ToPdf throws PlatformNotSupportedException
                        // on that runtime. Consumers should restrict ExportFormats to exclude Pdf on WASM.
                        var bytes = Export.ToPdf(items, exportColumns, title: FullscreenTitle ?? "Export", culture: culture);
                        await Export.DownloadAsync(bytes, "export.pdf", "application/pdf");
                        break;
                    }
                default:
                    {
                        var bytes = Export.ToCsv(items, exportColumns, culture: culture);
                        await Export.DownloadAsync(bytes, "export.csv", "text/csv; charset=utf-8");
                        break;
                    }
            }
        }
        catch (Exception ex) when (OnError.HasDelegate)
        {
            await OnError.InvokeAsync(ex);
        }
    }

    // --- Layout Persistence ---

    public DataGridLayout GetCurrentLayout() => SnapshotCurrentLayout();

    private DataGridLayout SnapshotCurrentLayout()
    {
        var cols = _orderedColumns.Select((c, i) => new ColumnLayout
        {
            Field = c.Field ?? c.Id,
            Width = c.Width,
            Visible = c.Visible,
            Pin = c.Pin,
            Order = i
        }).ToList();

        return new DataGridLayout
        {
            Columns = cols,
            Sorts = _sorts.Count > 0 ? new List<SortDescriptor>(_sorts) : null,
            Filters = _filters.Count > 0 ? new List<FilterDescriptor>(_filters) : null,
            PageSize = PageSize,
            GlobalSearch = _globalSearch,
            GroupByFields = _runtimeGroupFields.Count > 0
                ? new List<string>(_runtimeGroupFields)
                : null
        };
    }

    public async Task ApplyLayoutAsync(DataGridLayout layout)
    {
        // Restore column order, width, visibility, pin
        if (layout.Columns.Count > 0)
        {
            // Build a lookup from field → column object
            var colByField = EffectiveColumns.ToDictionary(c => c.Field ?? c.Id);

            var ordered = layout.Columns
                .OrderBy(cl => cl.Order)
                .Select(cl =>
                {
                    if (!colByField.TryGetValue(cl.Field, out var col)) return null;
                    col.Visible = cl.Visible;
                    col.Pin = cl.Pin;
                    if (cl.Width.HasValue) col.Width = cl.Width;
                    return col;
                })
                .Where(c => c is not null)
                .Select(c => c!)
                .ToList();

            // Append any columns not included in layout (new columns added since last save)
            var layoutFields = layout.Columns.Select(cl => cl.Field).ToHashSet();
            var missing = EffectiveColumns.Where(c => !layoutFields.Contains(c.Field ?? c.Id));
            ordered.AddRange(missing);

            _orderedColumns = ordered;
            RefreshVisibleColumns();
        }

        // Restore sorts
        _sorts = layout.Sorts is not null ? new List<SortDescriptor>(layout.Sorts) : new List<SortDescriptor>();

        // Restore filters
        _filters = layout.Filters is not null ? new List<FilterDescriptor>(layout.Filters) : new List<FilterDescriptor>();

        // Restore page size
        if (layout.PageSize.HasValue)
            PageSize = layout.PageSize.Value;

        // Restore global search
        _globalSearch = layout.GlobalSearch;

        // Restore group-panel runtime fields (chip-strip). Only consider fields
        // that exist as a Groupable column on the current grid — silently drop
        // any that no longer match so a stale snapshot doesn't crash the grid.
        if (layout.GroupByFields is { Count: > 0 })
        {
            _runtimeGroupFields = layout.GroupByFields
                .Where(f => EffectiveColumns.Any(c => c.Field == f))
                .Distinct()
                .ToList();
        }
        else
        {
            _runtimeGroupFields = new List<string>();
        }
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _lastGroupBy = null;

        _currentPage = 1;

        if (ServerMode)
            await RequestServerData();
        else
            ProcessClientData();

        StateHasChanged();
    }

    public async Task ResetLayoutAsync()
    {
        if (_defaultLayout is not null)
        {
            await ApplyLayoutAsync(_defaultLayout);
        }
        else
        {
            // No snapshot — restore ordering from effective columns
            _orderedColumns = new List<DataGridColumn<TItem>>(EffectiveColumns);
            foreach (var col in _orderedColumns)
            {
                col.Visible = true;
                col.Pin = PinDirection.None;
                col.Width = null;
            }
            _sorts.Clear();
            _filters.Clear();
            _globalSearch = null;
            PageSize = 10;
            _currentPage = 1;
            ProcessClientData();
            StateHasChanged();
        }

        if (EnableLayoutPersistence)
        {
            var key = LayoutStorageKey ?? _layoutId;
            await _layoutService!.ClearPersistedAsync(key);
        }
    }

    private void ScheduleAutoSave()
    {
        if (!EnableLayoutPersistence && !OnLayoutSave.HasDelegate) return;
        var gen = _layoutService!.CurrentSaveGeneration;
        _layoutService.ScheduleAutoSave(async () =>
        {
            await InvokeAsync(() => PersistLayoutAsync(gen + 1));
        });
    }

    private async Task PersistLayoutAsync(int? generation = null)
    {
        // Generation guard — gates BOTH the LocalStorage write AND the consumer callback.
        // Without this, a stale debounced timer could deliver an outdated layout snapshot to
        // OnLayoutSave even when the in-service guard correctly suppressed the LocalStorage write.
        if (generation.HasValue && _layoutService is not null && generation.Value != _layoutService.CurrentSaveGeneration)
            return;

        var layout = SnapshotCurrentLayout();

        if (EnableLayoutPersistence)
        {
            var key = LayoutStorageKey ?? _layoutId;
            await _layoutService!.PersistAsync(key, layout, generation);
        }

        if (OnLayoutSave.HasDelegate)
        {
            // Generation guard above ensures we don't deliver stale snapshots to the consumer's backend.
            await OnLayoutSave.InvokeAsync(layout);
        }
    }

    // --- Toolbar layout actions ---

    private async Task HandleSaveLayout()
    {
        await PersistLayoutAsync();
    }

    private async Task HandleResetLayout()
    {
        await ResetLayoutAsync();
    }

    // --- Named Layout Handlers ---

    private async Task HandleSaveNamedLayout(DataGridNamedLayout namedLayout)
    {
        var layout = SnapshotCurrentLayout();
        var withLayout = namedLayout with { Layout = layout };

        if (namedLayout.Scope == "Personal")
        {
            var namedKey = (LayoutStorageKey ?? _layoutId) + "-named";
            var existing = await _layoutService!.GetPersonalLayoutsAsync(namedKey);
            existing.Add(withLayout);
            await _layoutService.SaveNamedLayoutsAsync(namedKey, existing);
        }

        if (OnSaveNamedLayout.HasDelegate)
            await OnSaveNamedLayout.InvokeAsync(withLayout);

        StateHasChanged();
    }

    private async Task HandleApplyNamedLayout(DataGridNamedLayout namedLayout)
    {
        await ApplyLayoutAsync(namedLayout.Layout);
    }

    public async Task<List<DataGridNamedLayout>> GetPersonalLayoutsAsync()
    {
        var key = (LayoutStorageKey ?? _layoutId) + "-named";
        return await _layoutService!.GetPersonalLayoutsAsync(key);
    }

    // --- Public JSON Layout API -----------------------------------------------
    //
    // These methods let dev consumers persist the grid's layout to any backend
    // (database, file, REST API) by treating a JSON string as the canonical
    // transport format. Use via @ref:
    //
    //     <DataGrid @ref="grid" ...> ... </DataGrid>
    //     var json = grid.ExportLayout();
    //     await grid.ApplyLayoutJsonAsync(json);

    /// <summary>
    /// Snapshot schema version — bump when <see cref="DataGridLayoutSnapshot"/>
    /// changes in a breaking way.
    /// v2 (2.1.1): added <c>GroupByFields</c> for multi-level group-panel
    /// persistence. v1 payloads still load — the new field is nullable and
    /// the legacy single-level <c>GroupBy</c> is honoured as a fallback.
    /// </summary>
    private const int LayoutSnapshotVersion = 2;

    /// <summary>
    /// Cached, shared JsonSerializerOptions used for every ExportLayout / ApplyLayoutJsonAsync
    /// call. Reusing the instance avoids the documented perf pitfall of creating a fresh options
    /// object per call (which rebuilds type metadata every time).
    /// </summary>
    private static readonly System.Text.Json.JsonSerializerOptions _layoutJsonOptions =
        new(System.Text.Json.JsonSerializerDefaults.Web)
        {
            WriteIndented = false,
            DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
            Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
        };

    /// <summary>
    /// Serializes the current grid layout (column order, widths, visibility, pin state,
    /// sorts, filters, global search, current page, page size, group-by) to a JSON string.
    /// Dev consumers can persist this to any storage — DB, file, API. Round-trips via
    /// <see cref="ApplyLayoutJsonAsync"/>.
    /// </summary>
    public string ExportLayout()
    {
        var snapshot = BuildLayoutSnapshot();
        return System.Text.Json.JsonSerializer.Serialize(snapshot, _layoutJsonOptions);
    }

    /// <summary>
    /// Deserializes a JSON layout produced by <see cref="ExportLayout"/> and applies it.
    /// Throws <see cref="System.Text.Json.JsonException"/> on malformed input; silently
    /// ignores columns the JSON references that no longer exist on the grid.
    /// </summary>
    public async Task ApplyLayoutJsonAsync(string json)
    {
        if (string.IsNullOrWhiteSpace(json))
            throw new System.Text.Json.JsonException("Empty or null layout JSON");

        var snapshot = System.Text.Json.JsonSerializer.Deserialize<DataGridLayoutSnapshot>(json, _layoutJsonOptions)
                       ?? throw new System.Text.Json.JsonException("Deserialized layout snapshot was null");

        await ApplyLayoutSnapshotAsync(snapshot);
    }

    /// <summary>
    /// Builds a <see cref="DataGridLayoutSnapshot"/> from the grid's current state.
    /// </summary>
    internal DataGridLayoutSnapshot BuildLayoutSnapshot()
    {
        var cols = _orderedColumns.Select((c, i) => new DataGridColumnLayout(
            Field: c.Field ?? c.Id,
            Order: i,
            Visible: c.Visible,
            Width: c.Width,
            Pin: c.Pin
        )).ToList();

        return new DataGridLayoutSnapshot(
            Version: LayoutSnapshotVersion,
            Columns: cols,
            Sorts: new List<SortDescriptor>(_sorts),
            Filters: new List<FilterDescriptor>(_filters),
            GlobalSearch: _globalSearch,
            CurrentPage: _currentPage,
            PageSize: PageSize,
            // Preserve the static parameter for back-compat readers; new
            // consumers should rely on GroupByFields for multi-level state.
            GroupBy: _runtimeGroupFields.Count > 0 ? _runtimeGroupFields[0] : GroupBy,
            GroupByFields: _runtimeGroupFields.Count > 0
                ? new List<string>(_runtimeGroupFields)
                : null
        );
    }

    /// <summary>
    /// Applies a <see cref="DataGridLayoutSnapshot"/> to the grid. Columns referenced
    /// by Field that no longer exist are silently skipped; columns present on the grid
    /// but missing from the snapshot are appended at the end in their current order.
    /// </summary>
    internal async Task ApplyLayoutSnapshotAsync(DataGridLayoutSnapshot snapshot)
    {
        // Column state (order, visibility, width, pin)
        if (snapshot.Columns.Count > 0)
        {
            var colByKey = EffectiveColumns.ToDictionary(c => c.Field ?? c.Id);
            var ordered = snapshot.Columns
                .OrderBy(cl => cl.Order)
                .Select(cl =>
                {
                    if (!colByKey.TryGetValue(cl.Field, out var col)) return null;
                    col.Visible = cl.Visible;
                    if (cl.Pin.HasValue) col.Pin = cl.Pin.Value;
                    if (cl.Width.HasValue) col.Width = cl.Width;
                    return col;
                })
                .Where(c => c is not null)
                .Select(c => c!)
                .ToList();

            // Append columns on the grid that weren't referenced in the snapshot
            var snapshotKeys = snapshot.Columns.Select(cl => cl.Field).ToHashSet();
            foreach (var col in EffectiveColumns)
            {
                if (!snapshotKeys.Contains(col.Field ?? col.Id))
                    ordered.Add(col);
            }
            _orderedColumns = ordered;
        }

        _sorts = new List<SortDescriptor>(snapshot.Sorts);
        _filters = new List<FilterDescriptor>(snapshot.Filters);
        _globalSearch = snapshot.GlobalSearch;
        if (snapshot.PageSize > 0) PageSize = snapshot.PageSize;
        _currentPage = snapshot.CurrentPage > 0 ? snapshot.CurrentPage : 1;

        // Restore group-panel runtime fields. v2 payloads carry the full
        // ordered list in GroupByFields; v1 payloads only have GroupBy
        // (single level) — fall back to that for back-compat.
        var groupFields = snapshot.GroupByFields is { Count: > 0 }
            ? snapshot.GroupByFields
            : (!string.IsNullOrEmpty(snapshot.GroupBy)
                ? new List<string> { snapshot.GroupBy! }
                : new List<string>());
        _runtimeGroupFields = groupFields
            .Where(f => EffectiveColumns.Any(c => c.Field == f))
            .Distinct()
            .ToList();
        _expandedGroupPaths.Clear();
        _knownGroupPaths.Clear();
        _expandedGroups.Clear();
        _knownGroupKeys.Clear();
        _lastGroupBy = null;

        if (ServerMode)
            await RequestServerData();
        else
            ProcessClientData();

        StateHasChanged();
    }

    private async Task RequestServerData()
    {
        var previousError = _serverService!.Error;

        await _serverService!.RequestDataAsync(
            _currentPage, PageSize,
            _sorts.Count > 0 ? _sorts : null,
            _filters.Count > 0 ? _filters : null,
            _globalSearch, GroupBy,
            OnServerRequest, OnError, StateHasChanged);

        // When no OnError handler is wired and a new error occurred, show a default toast
        if (!OnError.HasDelegate && _serverService.Error is not null && _serverService.Error != previousError)
        {
            Toast.Error(L["DataGrid.ErrorLoadingData", ""], _serverService.Error);
        }
    }

    private IReadOnlyDictionary<string, double> ComputePinnedLeftOffsets()
    {
        var offsets = new Dictionary<string, double>();
        double offset = 0;
        if (RowReorderable) offset += 32;
        if (SelectionMode != DataGridSelectionMode.None) offset += 40;
        if (DetailTemplate is not null) offset += 32;
        foreach (var col in _orderedColumns.Where(c => c.Visible && c.Pin == PinDirection.Left))
        {
            offsets[col.Id] = offset;
            offset += col.Width ?? 150;
        }
        return offsets;
    }

    private IReadOnlyDictionary<string, double> ComputePinnedRightOffsets()
    {
        var offsets = new Dictionary<string, double>();
        double offset = 0;
        if (EditMode == DataGridEditMode.Row) offset += 40;
        foreach (var col in _orderedColumns.Where(c => c.Visible && c.Pin == PinDirection.Right).Reverse())
        {
            offsets[col.Id] = offset;
            offset += col.Width ?? 150;
        }
        return offsets;
    }

    public async ValueTask DisposeAsync()
    {
        _layoutService?.Dispose();
        _serverService?.Dispose();

        // Defensive: if disposed while still in fullscreen, unlock scroll so the host page
        // isn't left with a locked body.
        if (_isExpanded)
        {
            try { await Interop.UnlockScroll(); }
            catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
            catch { /* best-effort */ }
        }
    }
}
