@namespace Lumeo
@typeparam TItem
@inject Lumeo.Services.Localization.ILumeoLocalizer L

<div class="@WrapperClass" @attributes="AdditionalAttributes">
    @if (_isEmpty)
    {
        <div class="p-6 text-sm text-muted-foreground text-center">
            @if (EmptyContent is not null)
            {
                @EmptyContent
            }
            else
            {
                <span>No data to display.</span>
            }
        </div>
    }
    else
    {
        <div class="relative w-full overflow-auto">
            <table role="table" class="w-full caption-bottom text-sm border-collapse">
                <thead>
                    @* --- column-field levels (one <tr> per level, top-down) --- *@
                    @for (var levelIdx = 0; levelIdx < _columnHeaderRows.Count; levelIdx++)
                    {
                        var row = _columnHeaderRows[levelIdx];
                        <tr role="row" class="border-b border-border/40">
                            @if (levelIdx == 0)
                            {
                                @foreach (var rf in _rowFields)
                                {
                                    <th role="columnheader" rowspan="@HeaderRowSpan"
                                        class="@HeaderCellClass align-bottom text-left">@rf.Header</th>
                                }
                            }
                            @foreach (var entry in row)
                            {
                                <th role="columnheader"
                                    colspan="@entry.ColSpan"
                                    rowspan="@entry.RowSpan"
                                    class="@HeaderClassFor(entry)">@entry.Label</th>
                            }
                        </tr>
                    }
                    @* --- measure-name row (always shown when we have a column tree OR when no column fields and multiple measures need labels) --- *@
                    @if (_hasColumnField)
                    {
                        <tr role="row" class="border-b border-border/40">
                            @foreach (var leaf in _columnLeaves)
                            {
                                @foreach (var m in _measures)
                                {
                                    <th role="columnheader"
                                        class="@($"{HeaderCellClass} text-right {(leaf.IsGrandTotal ? "bg-muted/50" : "")}")">@m.Header</th>
                                }
                            }
                        </tr>
                    }
                    else
                    {
                        @* No column fields: measure headers in the single top-row of the thead *@
                        @if (_columnHeaderRows.Count == 0)
                        {
                            <tr role="row" class="border-b border-border/40">
                                @foreach (var rf in _rowFields)
                                {
                                    <th role="columnheader" class="@HeaderCellClass align-bottom text-left">@rf.Header</th>
                                }
                                @foreach (var m in _measures)
                                {
                                    <th role="columnheader" class="@HeaderCellClass text-right">@m.Header</th>
                                }
                            </tr>
                        }
                    }
                </thead>
                <tbody>
                    @foreach (var row in _displayRows)
                    {
                        <tr role="row" class="@RowClass(row)">
                            @* --- row-header columns --- *@
                            @if (row.Kind == DisplayRowKind.Leaf)
                            {
                                @for (var i = 0; i < _rowFields.Count; i++)
                                {
                                    <td role="rowheader" class="@DataCellClass">@(i == row.Level ? row.Label : "")</td>
                                }
                            }
                            else
                            {
                                @for (var i = 0; i < row.Level; i++)
                                {
                                    <td role="rowheader" class="@DataCellClass"></td>
                                }
                                <td role="rowheader" colspan="@(_rowFields.Count - row.Level)"
                                    class="@DataCellClass font-medium">
                                    @if (row.Kind == DisplayRowKind.GroupHeader && Collapsible)
                                    {
                                        <button type="button" aria-expanded="@((!row.Collapsed).ToString().ToLowerInvariant())"
                                                class="inline-flex items-center gap-1 hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring focus-visible:ring-offset-1 rounded"
                                                @onclick="() => ToggleCollapse(row.Node)">
                                            <Blazicon Svg="@(row.Collapsed ? Lucide.ChevronRight : Lucide.ChevronDown)" class="w-3.5 h-3.5 shrink-0" />
                                            <span>@row.Label</span>
                                        </button>
                                    }
                                    else if (row.Kind == DisplayRowKind.Subtotal)
                                    {
                                        <span>@row.Label Total</span>
                                    }
                                    else
                                    {
                                        <span>@row.Label</span>
                                    }
                                </td>
                            }
                            @* --- data columns --- *@
                            @if (_hasColumnField)
                            {
                                @foreach (var col in _columnLeaves)
                                {
                                    @for (var mi = 0; mi < _measures.Count; mi++)
                                    {
                                        var measure = _measures[mi];
                                        var items = LookupCellItems(row.Node.Key, col.PathKey);
                                        var mIndex = mi;
                                        var theCol = col;
                                        <td role="cell"
                                            class="@($"{DataCellClass} tabular-nums text-right {(col.IsGrandTotal ? "bg-muted/30 font-medium" : "")}")"
                                            @onclick="() => OnCellClickHandler(row, theCol, mIndex)">@measure.FormatValue(measure.Compute(items))</td>
                                    }
                                }
                            }
                            else
                            {
                                @for (var mi = 0; mi < _measures.Count; mi++)
                                {
                                    var measure = _measures[mi];
                                    var mIndex = mi;
                                    <td role="cell" class="@DataCellClass tabular-nums text-right"
                                        @onclick="() => OnCellClickHandler(row, null, mIndex)">@measure.FormatValue(measure.Compute(row.Node.Items))</td>
                                }
                            }
                        </tr>
                    }
                    @if (ShowColumnGrandTotal)
                    {
                        <tr role="row" class="bg-muted/50 font-semibold border-t border-border/40">
                            <td role="rowheader" colspan="@_rowFields.Count" class="@DataCellClass">@EffectiveGrandTotalLabel</td>
                            @if (_hasColumnField)
                            {
                                @foreach (var col in _columnLeaves)
                                {
                                    @foreach (var m in _measures)
                                    {
                                        <td role="cell"
                                            class="@($"{DataCellClass} tabular-nums text-right {(col.IsGrandTotal ? "bg-muted/50" : "")}")">@m.FormatValue(m.Compute(col.Items))</td>
                                    }
                                }
                            }
                            else
                            {
                                @foreach (var m in _measures)
                                {
                                    <td role="cell" class="@DataCellClass tabular-nums text-right">@m.FormatValue(m.Compute(_allItems))</td>
                                }
                            }
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</div>

@code {
    // ---- Parameters ----------------------------------------------------

    [Parameter] public IEnumerable<TItem>? Items { get; set; }
    [Parameter] public IReadOnlyList<PivotField<TItem>> RowFields { get; set; } = Array.Empty<PivotField<TItem>>();
    [Parameter] public IReadOnlyList<PivotField<TItem>> ColumnFields { get; set; } = Array.Empty<PivotField<TItem>>();
    [Parameter] public IReadOnlyList<PivotMeasure<TItem>> Measures { get; set; } = Array.Empty<PivotMeasure<TItem>>();

    [Parameter] public bool ShowRowGrandTotal { get; set; } = true;
    [Parameter] public bool ShowColumnGrandTotal { get; set; } = true;
    [Parameter] public bool ShowSubtotals { get; set; } = true;
    [Parameter] public bool Collapsible { get; set; } = true;
    [Parameter] public bool Compact { get; set; }
    [Parameter] public string? GrandTotalLabel { get; set; }
    private string EffectiveGrandTotalLabel => GrandTotalLabel ?? L["PivotGrid.GrandTotal"];

    [Parameter] public RenderFragment? EmptyContent { get; set; }

    /// <summary>Invoked when a data cell is clicked. Carries the clicked cell context for drill-down.</summary>
    [Parameter] public EventCallback<PivotCellClickArgs> OnCellClick { get; set; }

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

    // ---- Cached state (computed in OnParametersSet) --------------------

    private List<TItem> _allItems = new();
    private IReadOnlyList<PivotField<TItem>> _rowFields = Array.Empty<PivotField<TItem>>();
    private IReadOnlyList<PivotField<TItem>> _columnFields = Array.Empty<PivotField<TItem>>();
    private IReadOnlyList<PivotMeasure<TItem>> _measures = Array.Empty<PivotMeasure<TItem>>();
    private bool _hasColumnField;
    private bool _isEmpty;

    // Multi-level column structures
    private PivotColumnNode _columnRoot = new("", "", "", -1, isGrandTotal: false);
    private List<ColumnLeaf> _columnLeaves = new();
    private List<List<HeaderEntry>> _columnHeaderRows = new();

    // Row hierarchy
    private RowNode _root = new(Array.Empty<object?>(), -1);
    private List<DisplayRow> _displayRows = new();

    // Aggregation cache: keyed by row-node key, then by column-leaf path-key, holds the matched item list
    private Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> _cellItems = new();
    private static readonly IReadOnlyList<TItem> EmptyItems = Array.Empty<TItem>();

    private readonly HashSet<string> _collapsed = new();

    // Short-circuit guard for OnParametersSet: BuildColumnRoot + BuildNode +
    // BuildCellLookup are O(N · fields) and rerunning them on every render
    // (e.g. when a parent passes a freshly-projected IEnumerable that's
    // logically the same data) is wasteful. We track the last-seen Items
    // reference + a hash of the dimension references; if both match we skip
    // the rebuild. Toggle-collapse paths still recompute _displayRows
    // explicitly via FlattenRows — they don't depend on this gate.
    private object? _lastItemsRef;
    private int _lastDimsHash;
    private bool _lastShowRowGrandTotal;
    private bool _lastShowSubtotals;

    private string WrapperClass => $"rounded-lg border border-border/40 bg-card text-foreground overflow-hidden {Class}".Trim();
    private string HeaderCellClass => (Compact ? "px-2 py-1.5" : "px-3 py-2") + " text-xs font-medium text-foreground bg-muted/40 border border-border/40 whitespace-nowrap";
    private string DataCellClass => (Compact ? "px-2 py-1.5" : "px-3 py-2") + " border border-border/40 whitespace-nowrap";

    // Total row-span for the top-left corner: column-tree depth + 1 (for the measure-name row).
    private int HeaderRowSpan => _hasColumnField ? _columnHeaderRows.Count + 1 : 1;

    private string HeaderClassFor(HeaderEntry entry)
    {
        var bg = entry.IsGrandTotal ? "bg-muted/50" : "";
        return $"{HeaderCellClass} text-center {bg}".Trim();
    }

    protected override void OnParametersSet()
    {
        // Cheap short-circuit: if every input that drives the tree-build is
        // reference-identical to last time, and the flags that influence
        // ShowRowGrandTotal/ShowSubtotals (which feed into _columnRoot and
        // _displayRows) match too, skip the rebuild entirely. The hash uses
        // reference identity of the parameter values (RowFields/ColumnFields/
        // Measures are typically Array.Empty or stable consumer-side lists),
        // so we don't pay an O(N) equality check.
        // Reference-identity hashes: cheap and stable even when consumers
        // pass Array.Empty<T>() (same singleton). Null-safe wrapper avoids
        // RuntimeHelpers.GetHashCode's ArgumentNullException.
        static int IdHash(object? o) =>
            o is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(o);
        var dimsHash = HashCode.Combine(IdHash(RowFields), IdHash(ColumnFields), IdHash(Measures));

        if (ReferenceEquals(Items, _lastItemsRef)
            && dimsHash == _lastDimsHash
            && ShowRowGrandTotal == _lastShowRowGrandTotal
            && ShowSubtotals == _lastShowSubtotals)
        {
            return;
        }

        _lastItemsRef = Items;
        _lastDimsHash = dimsHash;
        _lastShowRowGrandTotal = ShowRowGrandTotal;
        _lastShowSubtotals = ShowSubtotals;

        _allItems = (Items ?? Enumerable.Empty<TItem>()).ToList();
        _rowFields = RowFields ?? Array.Empty<PivotField<TItem>>();
        _columnFields = ColumnFields ?? Array.Empty<PivotField<TItem>>();
        _measures = Measures ?? Array.Empty<PivotMeasure<TItem>>();
        _hasColumnField = _columnFields.Count > 0;
        _isEmpty = _allItems.Count == 0;

        if (_isEmpty)
        {
            _columnLeaves = new();
            _columnHeaderRows = new();
            _displayRows = new();
            _cellItems = new();
            return;
        }

        // --- build column tree (multi-level) ---
        _columnRoot = BuildColumnRoot();
        _columnLeaves = new();
        CollectColumnLeaves(_columnRoot, _columnLeaves);

        // --- build column header rows (one entry per level of the column tree) ---
        _columnHeaderRows = BuildColumnHeaderRows(_columnRoot);

        // --- row hierarchy tree ---
        _root = BuildNode(_allItems, Array.Empty<object?>(), -1);

        // --- aggregation lookup: rowKey -> colKey -> items ---
        _cellItems = BuildCellLookup(_root);

        // --- flatten into display rows respecting collapse ---
        _displayRows = new();
        FlattenRows(_root);
    }

    // ---- Column-tree construction --------------------------------------

    private PivotColumnNode BuildColumnRoot()
    {
        var root = new PivotColumnNode(label: "", key: "", pathKey: "", level: -1, isGrandTotal: false)
        {
            Items = _allItems,
        };

        BuildColumnChildren(root, _allItems, 0);

        // Append grand-total leaf at the top level if requested and column fields exist.
        if (ShowRowGrandTotal && _hasColumnField)
        {
            var gt = new PivotColumnNode(
                label: EffectiveGrandTotalLabel,
                key: "__grand_total__",
                pathKey: "__GT__",
                level: 0,
                isGrandTotal: true)
            {
                Items = _allItems,
                LeafCount = 1,
            };
            root.Children.Add(gt);
        }

        // Compute leaf counts bottom-up.
        ComputeLeafCounts(root);
        return root;
    }

    private void BuildColumnChildren(PivotColumnNode parent, IReadOnlyList<TItem> items, int level)
    {
        if (level >= _columnFields.Count) return;
        var field = _columnFields[level];
        var groups = items
            .GroupBy(i => field.Selector(i))
            .Select(g => (Key: g.Key, Items: (IReadOnlyList<TItem>)g.ToList()))
            .ToList();
        groups.Sort((a, b) => CompareValues(a.Key, b.Key));
        if (field.SortComparer is { } sc)
            groups = groups.OrderBy(x => x.Key, sc).ToList();

        foreach (var g in groups)
        {
            var label = field.FormatValue(g.Key);
            var keyStr = g.Key?.ToString() ?? "_";
            var pathKey = parent.PathKey.Length == 0 ? $"L{level}={keyStr}" : $"{parent.PathKey}|L{level}={keyStr}";
            var node = new PivotColumnNode(label, keyStr, pathKey, level, isGrandTotal: false)
            {
                RawKey = g.Key,
                Items = g.Items,
            };
            parent.Children.Add(node);
            BuildColumnChildren(node, g.Items, level + 1);
        }
    }

    private static int ComputeLeafCounts(PivotColumnNode node)
    {
        if (node.Children.Count == 0)
        {
            node.LeafCount = 1;
            return 1;
        }
        var sum = 0;
        foreach (var c in node.Children)
            sum += ComputeLeafCounts(c);
        node.LeafCount = sum;
        return sum;
    }

    private static void CollectColumnLeaves(PivotColumnNode node, List<ColumnLeaf> leaves)
    {
        if (node.Level >= 0 && node.Children.Count == 0)
        {
            // Build path-key array from root-down: this leaf already has a PathKey.
            leaves.Add(new ColumnLeaf(
                Label: node.Label,
                PathKey: node.PathKey,
                Path: BuildPathFromNode(node),
                Items: node.Items,
                IsGrandTotal: node.IsGrandTotal));
            return;
        }
        foreach (var c in node.Children)
            CollectColumnLeaves(c, leaves);
    }

    private static object?[] BuildPathFromNode(PivotColumnNode leaf)
    {
        // The leaf's PathKey encodes ancestor segments — but we need RawKeys.
        // Easier: walk the tree, since we built linearly, recreate via segments.
        // Since we don't keep a parent ref, derive from the PathKey segments isn't ideal.
        // For click-args we'll fall back to splitting PathKey, but raw click info uses Path stored on leaf.
        // We populate Path on leaves below in another pass.
        return leaf.PathOverride ?? Array.Empty<object?>();
    }

    private List<List<HeaderEntry>> BuildColumnHeaderRows(PivotColumnNode root)
    {
        // Number of column header rows == depth of column tree from level-0 onwards.
        // Even for ShowRowGrandTotal with single-level columns, depth is 1.
        if (root.Children.Count == 0) return new();

        // Compute max depth of any path (1-based count of column-field levels involved).
        var depth = MaxDepth(root, 0);
        var rows = new List<List<HeaderEntry>>();
        for (var i = 0; i < depth; i++) rows.Add(new());

        // Per-row width counter for sanity (no longer strictly needed but kept consistent).
        AppendHeaderEntries(root, 0, depth, rows);
        return rows;
    }

    private static int MaxDepth(PivotColumnNode node, int currentDepth)
    {
        if (node.Children.Count == 0) return currentDepth;
        var max = currentDepth;
        foreach (var c in node.Children)
            max = Math.Max(max, MaxDepth(c, currentDepth + 1));
        return max;
    }

    private void AppendHeaderEntries(PivotColumnNode parent, int parentDepth, int totalDepth, List<List<HeaderEntry>> rows)
    {
        foreach (var child in parent.Children)
        {
            var span = child.LeafCount * _measures.Count;
            if (child.Children.Count == 0)
            {
                // Leaf column-field node. Render at parentDepth and add a rowspan filling the remaining depth.
                var remainingDepth = totalDepth - parentDepth;
                rows[parentDepth].Add(new HeaderEntry(
                    Label: child.Label,
                    ColSpan: span,
                    RowSpan: remainingDepth,
                    IsGrandTotal: child.IsGrandTotal));
            }
            else
            {
                rows[parentDepth].Add(new HeaderEntry(
                    Label: child.Label,
                    ColSpan: span,
                    RowSpan: 1,
                    IsGrandTotal: child.IsGrandTotal));
                AppendHeaderEntries(child, parentDepth + 1, totalDepth, rows);
            }
        }
    }

    // ---- Row-tree (unchanged in shape) ---------------------------------

    private RowNode BuildNode(IReadOnlyList<TItem> items, object?[] path, int level)
    {
        var labels = new string[path.Length];
        for (var i = 0; i < path.Length; i++)
            labels[i] = _rowFields[i].FormatValue(path[i]);
        var node = new RowNode(path, level) { Items = items, Labels = labels };

        var childLevel = level + 1;
        if (childLevel < _rowFields.Count)
        {
            var field = _rowFields[childLevel];
            var groups = items
                .GroupBy(i => field.Selector(i))
                .Select(g => (Key: g.Key, Items: (IReadOnlyList<TItem>)g.ToList()))
                .ToList();
            groups.Sort((a, b) => CompareValues(a.Key, b.Key));
            if (field.SortComparer is { } sc)
                groups = groups.OrderBy(x => x.Key, sc).ToList();
            foreach (var g in groups)
            {
                var childPath = path.Append(g.Key).ToArray();
                node.Children.Add(BuildNode(g.Items, childPath, childLevel));
            }
        }
        return node;
    }

    private void FlattenRows(RowNode node)
    {
        foreach (var child in node.Children)
        {
            if (child.Children.Count == 0)
            {
                _displayRows.Add(new DisplayRow(child, DisplayRowKind.Leaf, false, child.Level, LeafLabel(child)));
            }
            else
            {
                var collapsed = _collapsed.Contains(child.Key);
                _displayRows.Add(new DisplayRow(child, DisplayRowKind.GroupHeader, collapsed, child.Level, LeafLabel(child)));
                if (!collapsed)
                    FlattenRows(child);
                if (ShowSubtotals)
                    _displayRows.Add(new DisplayRow(child, DisplayRowKind.Subtotal, false, child.Level, LeafLabel(child)));
            }
        }
    }

    private static string LeafLabel(RowNode n) => n.Labels.Length == 0 ? "" : n.Labels[^1];

    // ---- Aggregation lookup --------------------------------------------

    /// <summary>
    /// Build a cache that, for every row node (group, leaf, subtotal — all share the same .Key),
    /// gives the matched items per column-leaf. The grand-total column leaf gets the full row-node Items.
    /// </summary>
    private Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> BuildCellLookup(RowNode root)
    {
        var cache = new Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>>();
        if (!_hasColumnField) return cache;
        BuildCellLookupRecursive(root, cache);
        return cache;
    }

    private void BuildCellLookupRecursive(RowNode node, Dictionary<string, Dictionary<string, IReadOnlyList<TItem>>> cache)
    {
        // Compute once for this row node.
        var perCol = new Dictionary<string, IReadOnlyList<TItem>>(_columnLeaves.Count);
        // Initialise empty buckets for every column leaf so lookup is O(1) without nulls.
        for (var i = 0; i < _columnLeaves.Count; i++)
        {
            var leaf = _columnLeaves[i];
            if (leaf.IsGrandTotal)
            {
                perCol[leaf.PathKey] = node.Items;
            }
            else
            {
                perCol[leaf.PathKey] = EmptyItems;
            }
        }

        // Bucket items into column leaves by walking the column tree.
        // For each row item, find its leaf path-key.
        if (node.Items.Count > 0)
        {
            // Group the row items into per-column-leaf lists.
            var buckets = new Dictionary<string, List<TItem>>();
            foreach (var item in node.Items)
            {
                var pathKey = ComputeItemColumnPathKey(item);
                if (!buckets.TryGetValue(pathKey, out var list))
                {
                    list = new List<TItem>();
                    buckets[pathKey] = list;
                }
                list.Add(item);
            }
            foreach (var kv in buckets)
            {
                perCol[kv.Key] = kv.Value;
            }
        }

        cache[node.Key] = perCol;

        foreach (var child in node.Children)
            BuildCellLookupRecursive(child, cache);
    }

    private string ComputeItemColumnPathKey(TItem item)
    {
        // Walk the column-field chain for this item and produce its leaf path-key,
        // formatted identically to BuildColumnChildren.
        var sb = new System.Text.StringBuilder();
        for (var level = 0; level < _columnFields.Count; level++)
        {
            if (level > 0) sb.Append('|');
            var key = _columnFields[level].Selector(item);
            var keyStr = key?.ToString() ?? "_";
            sb.Append('L').Append(level).Append('=').Append(keyStr);
        }
        return sb.ToString();
    }

    private IReadOnlyList<TItem> LookupCellItems(string rowKey, string columnPathKey)
    {
        if (_cellItems.TryGetValue(rowKey, out var perCol) &&
            perCol.TryGetValue(columnPathKey, out var items))
            return items;
        return EmptyItems;
    }

    private void ToggleCollapse(RowNode node)
    {
        if (!Collapsible || node.Children.Count == 0) return;
        if (!_collapsed.Add(node.Key)) _collapsed.Remove(node.Key);
        _displayRows = new();
        FlattenRows(_root);
        StateHasChanged();
    }

    private async Task OnCellClickHandler(DisplayRow row, ColumnLeaf? col, int measureIndex)
    {
        if (!OnCellClick.HasDelegate) return;
        var matched = col is not null
            ? LookupCellItems(row.Node.Key, col.PathKey)
            : row.Node.Items;
        // Decode raw column-keys from the leaf path (skip if grand-total or no columns).
        var columnKeys = (col is null || col.IsGrandTotal)
            ? Array.Empty<object?>()
            : DecodeColumnKeys(col);
        var args = new PivotCellClickArgs(
            row.Node.Path.ToArray(),
            columnKeys,
            (measureIndex >= 0 && measureIndex < _measures.Count) ? _measures[measureIndex].Header : "",
            matched.Cast<object?>().ToArray());
        await OnCellClick.InvokeAsync(args);
    }

    private object?[] DecodeColumnKeys(ColumnLeaf leaf)
    {
        // PathKey format: "L0=val0|L1=val1|..." — return raw string segments per level.
        if (string.IsNullOrEmpty(leaf.PathKey)) return Array.Empty<object?>();
        var segments = leaf.PathKey.Split('|');
        var result = new object?[segments.Length];
        for (var i = 0; i < segments.Length; i++)
        {
            var seg = segments[i];
            var eq = seg.IndexOf('=');
            result[i] = eq >= 0 ? seg.Substring(eq + 1) : seg;
        }
        return result;
    }

    private static int CompareValues(object? a, object? b)
    {
        if (a is null && b is null) return 0;
        if (a is null) return -1;
        if (b is null) return 1;
        if (a is IComparable ca && a.GetType() == b.GetType()) return ca.CompareTo(b);
        return string.Compare(a.ToString(), b.ToString(), StringComparison.OrdinalIgnoreCase);
    }

    private string RowClass(DisplayRow row) => row.Kind switch
    {
        DisplayRowKind.Subtotal => "bg-muted/30 font-medium border-b border-border/40",
        DisplayRowKind.GroupHeader => "bg-muted/20 font-medium border-b border-border/40",
        _ => "border-b border-border/40 hover:bg-muted/30 transition-colors"
    };

    // ---- Internal model -----------------------------------------------

    private sealed class RowNode
    {
        public object?[] Path { get; }
        public int Level { get; }
        public IReadOnlyList<TItem> Items { get; set; } = Array.Empty<TItem>();
        public string[] Labels { get; set; } = Array.Empty<string>();
        public List<RowNode> Children { get; } = new();
        public string Key { get; }

        public RowNode(object?[] path, int level)
        {
            Path = path;
            Level = level;
            Key = "L" + level + "|" + string.Join("", path.Select(p => p?.ToString() ?? " "));
        }
    }

    /// <summary>
    /// A node in the column-field hierarchy. Internal node => header group; leaf node (Children empty) => actual data column.
    /// </summary>
    private sealed class PivotColumnNode
    {
        public string Label { get; }
        public string Key { get; }
        public string PathKey { get; }
        public int Level { get; }
        public bool IsGrandTotal { get; }
        public object? RawKey { get; set; }
        public object?[]? PathOverride { get; set; }
        public IReadOnlyList<TItem> Items { get; set; } = Array.Empty<TItem>();
        public List<PivotColumnNode> Children { get; } = new();
        public int LeafCount { get; set; }

        public PivotColumnNode(string label, string key, string pathKey, int level, bool isGrandTotal)
        {
            Label = label;
            Key = key;
            PathKey = pathKey;
            Level = level;
            IsGrandTotal = isGrandTotal;
        }
    }

    private sealed record ColumnLeaf(
        string Label,
        string PathKey,
        object?[] Path,
        IReadOnlyList<TItem> Items,
        bool IsGrandTotal);

    private sealed record HeaderEntry(string Label, int ColSpan, int RowSpan, bool IsGrandTotal);

    private enum DisplayRowKind { GroupHeader, Leaf, Subtotal }

    private sealed record DisplayRow(RowNode Node, DisplayRowKind Kind, bool Collapsed, int Level, string Label);
}
