local _, OneWoW_Bags = ...

local OneWoW_GUI = LibStub("OneWoW_GUI-1.0", true)
if not OneWoW_GUI then return end

local L = OneWoW_Bags.L

local tremove, tinsert, wipe, sort = tremove, tinsert, wipe, sort
local pairs, ipairs, type = pairs, ipairs, type
local floor, min, max, ceil, sqrt = math.floor, math.min, math.max, math.ceil, math.sqrt
local tostring = tostring
local SetItemButtonCount = SetItemButtonCount

OneWoW_Bags.CategoryViewHelpers = {}
local H = OneWoW_Bags.CategoryViewHelpers
local filterScratchByCategory = {}

function H.CreateLabelPool()
    local pool = {}
    local active = {}

    local function Acquire(parent)
        local label
        if #pool > 0 then
            label = tremove(pool)
            label:SetParent(parent)
        else
            label = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            label:SetJustifyH("LEFT")
            label:SetWordWrap(false)
        end
        label:ClearAllPoints()
        label:Show()
        active[label] = true
        return label
    end

    local function ReleaseAll()
        for label in pairs(active) do
            label:Hide()
            label:ClearAllPoints()
            tinsert(pool, label)
        end
        wipe(active)
    end

    return Acquire, ReleaseAll
end

function H.ResolveCategoryName(categoryName)
    local localeKey = "CAT_" .. string.upper(string.gsub(categoryName, "%s+", "_"))
    return L[localeKey] or categoryName
end

function H.ApplyCategoryColor(fontString, catMods, categoryName)
    local catMod = catMods[categoryName]
    if catMod and catMod.color then
        local cr = tonumber(catMod.color:sub(1,2), 16) / 255
        local cg = tonumber(catMod.color:sub(3,4), 16) / 255
        local cb = tonumber(catMod.color:sub(5,6), 16) / 255
        fontString:SetTextColor(cr, cg, cb, 1.0)
    else
        fontString:SetTextColor(OneWoW_GUI:GetThemeColor("ACCENT_PRIMARY"))
    end
end

function H.PinSpecialCategories(list, moveRecentToTop, moveOtherToBottom, getName)
    if not moveRecentToTop and not moveOtherToBottom then return list end
    getName = getName or function(entry) return entry end
    local pinRecent, pinBottom, rest = {}, {}, {}
    for _, entry in ipairs(list) do
        local name = getName(entry)
        if name == "Recent Items" and moveRecentToTop then
            tinsert(pinRecent, entry)
        elseif name == "Other" and moveOtherToBottom then
            tinsert(pinBottom, entry)
        else
            tinsert(rest, entry)
        end
    end
    local result = {}
    for _, e in ipairs(pinRecent) do tinsert(result, e) end
    for _, e in ipairs(rest) do tinsert(result, e) end
    for _, e in ipairs(pinBottom) do tinsert(result, e) end
    return result
end

function H.VerticalGap(cellSize, verticalSpacing)
    return floor(cellSize * verticalSpacing * 0.25 + 0.5)
end

function H.RenderItemGrid(parentFrame, items, startY, leftPadding, cellSize, iconSize, cols)
    local Profile = OneWoW_Bags.Profile
    Profile:Start("CategoryView.RenderItemGrid")
    local itemRow = 0
    local itemCol = 0
    for _, button in ipairs(items) do
        local x = leftPadding + (itemCol * cellSize)
        local y = -(startY + itemRow * cellSize)
        button:ClearAllPoints()
        OneWoW_Bags.WindowHelpers:SetPointPixelAligned(button, parentFrame, x, y)
        button:OWB_SetIconSize(iconSize)
        button:Show()
        itemCol = itemCol + 1
        if itemCol >= cols then
            itemCol = 0
            itemRow = itemRow + 1
        end
    end
    local totalRows = (itemCol > 0) and (itemRow + 1) or itemRow
    Profile:Stop("CategoryView.RenderItemGrid")
    return totalRows * cellSize
end

--- Render subgroup label + item grid pairs for a grouped category.
--- Shared by the stacked layout and the compact layout so grouping looks
--- identical in both. Lays out at horizontal offset `opts.x` using `opts.gridCols`
--- columns; subgroup labels span `opts.labelWidth`. Starts at vertical offset
--- `opts.startY` and returns the ending vertical offset.
---@param parent table parent frame to anchor labels/items to
---@param groups table<string, table[]> subgroup name -> button list (from H.GroupItemsBy)
---@param groupOrder { name: string }[] ordered subgroup descriptors (from H.GroupItemsBy)
---@param opts table { x, gridCols, labelWidth, startY, cellSize, iconSize, AcquireLabel }
---@return number endY ending vertical offset after the last subgroup grid
function H.RenderGroupedGrids(parent, groups, groupOrder, opts)
    local AcquireLabel = opts.AcquireLabel
    local x = opts.x
    local gridCols = opts.gridCols
    local labelWidth = opts.labelWidth
    local cellSize = opts.cellSize
    local iconSize = opts.iconSize
    local subY = opts.startY

    for _, groupInfo in ipairs(groupOrder) do
        local groupItems = groups[groupInfo.name]
        if groupItems and #groupItems > 0 then
            local subLabel = AcquireLabel(parent)
            subLabel:SetPoint("TOPLEFT", parent, "TOPLEFT", x, -subY)
            subLabel:SetWidth(labelWidth)
            subLabel:SetText(groupInfo.name)
            subLabel:SetTextColor(OneWoW_GUI:GetThemeColor("TEXT_SECONDARY"))
            subY = subY + 14

            local gridH = H.RenderItemGrid(parent, groupItems, subY, x, cellSize, iconSize, gridCols)
            subY = subY + gridH + 4
        end
    end

    return subY
end

function H.SetupCategorySection(section, contentFrame, yOffset, categoryName, itemCount, catMods)
    section:ClearAllPoints()
    section:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, -yOffset)
    section:SetPoint("RIGHT", contentFrame, "RIGHT", 0, 0)
    section:SetBackdropColor(OneWoW_GUI:GetThemeColor("BG_SECONDARY"))
    section:SetBackdropBorderColor(OneWoW_GUI:GetThemeColor("BORDER_SUBTLE"))

    section.title:SetText(H.ResolveCategoryName(categoryName))
    H.ApplyCategoryColor(section.title, catMods, categoryName)
    section.count:SetText(tostring(itemCount))
    section.count:SetTextColor(OneWoW_GUI:GetThemeColor("TEXT_MUTED"))
end

--- Pack and render a list of category-like blocks using the compact rules:
--- single-row blocks pack side-by-side; multi-row blocks take their own
--- full-width line. A grouped block renders a category title then recurses on
--- its subgroups (exactly one level deep, since subgroups carry no `groupBy`),
--- so subgroups follow the same side-by-side packing as categories. Returns the
--- ending vertical offset WITHOUT a trailing gap; callers add spacing.
---@param blockList table[] list of { name, displayName, items, groupBy? }
---@param contentFrame table parent frame to anchor labels/items to
---@param opts table { yOffset, cols, gapSlots, showHeaders, leftPadding, cellSize, iconSize, catMods, AcquireLabel, verticalSpacing, containerType, labelStyler?, rowTopPad? }
---@return number yOffset ending vertical offset
function H.PackCompactBlocks(blockList, contentFrame, opts)
    if #blockList == 0 then return opts.yOffset end

    local yOffset = opts.yOffset
    local cols = opts.cols
    local gapSlots = opts.gapSlots
    local showHeaders = opts.showHeaders
    local labelHeight = showHeaders and 16 or 0
    -- Empty band drawn ABOVE each category line's label so headers aren't
    -- smooshed against the items/section header above them. Sized to 75% of
    -- labelHeight (the category-title -> subgroup gap) for a touch of breathing
    -- room without being too loose. The subgroup recursion passes 0 to keep
    -- grouped categories looking the same.
    local rowTopPad = opts.rowTopPad or floor(labelHeight * 0.75 + 0.5)
    local leftPadding = opts.leftPadding
    local cellSize = opts.cellSize
    local iconSize = opts.iconSize
    local catMods = opts.catMods
    local AcquireLabel = opts.AcquireLabel
    local verticalSpacing = opts.verticalSpacing
    local containerType = opts.containerType
    -- Category headers render one step larger than subgroup headers so the
    -- hierarchy reads clearly in compact mode (mirrors the stacked layout, where
    -- the category title is GameFontNormal). Labels are pooled and reused, so the
    -- font object MUST be set every render at both levels to avoid a big category
    -- font leaking onto a reused subgroup label (and vice versa).
    local labelStyler = opts.labelStyler or function(label, block)
        label:SetFontObject(GameFontNormal)
        H.ApplyCategoryColor(label, catMods, block.name)
    end

    local lines = {}
    local currentLine = {}
    local curCol = 0

    for _, catInfo in ipairs(blockList) do
        local count = #catInfo.items
        local mod = catMods and catMods[catInfo.name]
        local forceOwn = mod and mod.forceOwnLine and mod.forceOwnLine[containerType]

        -- A grouped category renders its title then packs its subgroups with
        -- these same compact rules (recursively). Grouping only applies when
        -- category headers are shown; the category takes its own line.
        local groupByVal = catInfo.groupBy
        local isGrouped = showHeaders and groupByVal and groupByVal ~= "none"
        local groups, groupOrder
        if isGrouped then
            groups, groupOrder = H.GroupItemsBy(catInfo.items, groupByVal)
            if not (groups and groupOrder) then isGrouped = false end
        end

        if isGrouped then
            if #currentLine > 0 then
                tinsert(lines, currentLine)
                currentLine = {}
            end
            curCol = 0

            tinsert(lines, { {
                name = catInfo.name,
                displayName = catInfo.displayName,
                startCol = 0,
                blockWidth = cols,
                grouped = true,
                groups = groups,
                groupOrder = groupOrder,
            } })
            currentLine = {}
            curCol = 0
        elseif forceOwn then
            if #currentLine > 0 then
                tinsert(lines, currentLine)
                currentLine = {}
            end
            curCol = 0
            local blockWidth = min(count, cols)
            local blockRows = ceil(count / blockWidth)
            tinsert(currentLine, {
                name = catInfo.name,
                displayName = catInfo.displayName,
                items = catInfo.items,
                startCol = 0,
                blockWidth = blockWidth,
                blockRows = blockRows,
            })
            tinsert(lines, currentLine)
            currentLine = {}
            curCol = 0
        else
            local startCol = curCol > 0 and (curCol + gapSlots) or 0
            local avail = floor(cols - startCol)

            if avail < 1 then
                tinsert(lines, currentLine)
                currentLine = {}
                curCol = 0
                startCol = 0
                avail = cols
            end

            local optimalWidth = count <= cols and count or max(2, floor(sqrt(count / 1.618)))
            local blockWidth = min(optimalWidth, avail)
            local blockRows = ceil(count / blockWidth)

            if blockRows > 1 and (curCol > 0 or blockWidth < cols) then
                if #currentLine > 0 then
                    tinsert(lines, currentLine)
                    currentLine = {}
                end
                curCol = 0
                startCol = 0
                blockWidth = min(count, cols)
                blockRows = ceil(count / blockWidth)
            end

            tinsert(currentLine, {
                name = catInfo.name,
                displayName = catInfo.displayName,
                items = catInfo.items,
                startCol = startCol,
                blockWidth = blockWidth,
                blockRows = blockRows,
            })

            if blockRows > 1 then
                tinsert(lines, currentLine)
                currentLine = {}
                curCol = 0
            else
                curCol = startCol + blockWidth
            end
        end
    end
    if #currentLine > 0 then
        tinsert(lines, currentLine)
    end

    for _, line in ipairs(lines) do
        -- A grouped category occupies a line by itself: category title on top,
        -- then its subgroups packed with the same compact rules (recursively).
        local groupedCat = (#line == 1 and line[1].grouped) and line[1] or nil

        yOffset = yOffset + rowTopPad

        if groupedCat then
            local blockX = leftPadding + groupedCat.startCol * cellSize
            if showHeaders then
                local label = AcquireLabel(contentFrame)
                label:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", blockX, -yOffset)
                label:SetWidth(groupedCat.blockWidth * cellSize)
                label:SetText(groupedCat.displayName)
                label:SetFontObject(GameFontNormal)
                H.ApplyCategoryColor(label, catMods, groupedCat.name)
            end
            yOffset = yOffset + labelHeight

            local subBlocks = {}
            for _, groupInfo in ipairs(groupedCat.groupOrder) do
                local groupItems = groupedCat.groups[groupInfo.name]
                if groupItems and #groupItems > 0 then
                    tinsert(subBlocks, {
                        name = groupInfo.name,
                        displayName = groupInfo.name,
                        items = groupItems,
                    })
                end
            end
            yOffset = H.PackCompactBlocks(subBlocks, contentFrame, {
                yOffset = yOffset,
                cols = cols,
                gapSlots = gapSlots,
                showHeaders = showHeaders,
                leftPadding = leftPadding,
                cellSize = cellSize,
                iconSize = iconSize,
                catMods = catMods,
                AcquireLabel = AcquireLabel,
                verticalSpacing = verticalSpacing,
                containerType = containerType,
                rowTopPad = 0,
                labelStyler = function(label)
                    label:SetFontObject(GameFontNormalSmall)
                    label:SetTextColor(OneWoW_GUI:GetThemeColor("TEXT_MUTED"))
                end,
            })
        else
            if showHeaders then
                for _, cat in ipairs(line) do
                    local label = AcquireLabel(contentFrame)
                    label:SetPoint("TOPLEFT", contentFrame, "TOPLEFT",
                        leftPadding + cat.startCol * cellSize, -yOffset)
                    label:SetWidth(cat.blockWidth * cellSize)
                    label:SetText(cat.displayName)
                    labelStyler(label, cat)
                end
            end
            yOffset = yOffset + labelHeight

            local maxRows = 0
            for _, cat in ipairs(line) do
                if cat.blockRows > maxRows then maxRows = cat.blockRows end
                local itemCol = 0
                local itemRow = 0
                for _, button in ipairs(cat.items) do
                    local x = leftPadding + (cat.startCol + itemCol) * cellSize
                    local y = -(yOffset + itemRow * cellSize)
                    button:ClearAllPoints()
                    OneWoW_Bags.WindowHelpers:SetPointPixelAligned(button, contentFrame, x, y)
                    button:OWB_SetIconSize(iconSize)
                    button:Show()
                    itemCol = itemCol + 1
                    if itemCol >= cat.blockWidth then
                        itemCol = 0
                        itemRow = itemRow + 1
                    end
                end
            end
            yOffset = yOffset + maxRows * cellSize
        end
    end

    return yOffset
end

--- Public entry for compact category layout. Packs the categories with no
--- trailing gap: inter-row spacing is carried by `rowTopPad` in
--- `PackCompactBlocks`, and the caller adds a `VerticalGap` only before a
--- visible section header/separator. This keeps a hidden section boundary
--- spaced identically to a normal category row.
---@param catInfoList table[] category blocks from `BuildCatInfo`
---@param contentFrame table parent frame
---@param opts table layout options (see `H.PackCompactBlocks`)
---@return number yOffset ending vertical offset
function H.LayoutCompactGroup(catInfoList, contentFrame, opts)
    if #catInfoList == 0 then return opts.yOffset end
    return H.PackCompactBlocks(catInfoList, contentFrame, opts)
end

function H.GetSortedCategoryNames(itemsByCategory)
    local Categories = OneWoW_Bags.Categories
    local names = {}
    for name in pairs(itemsByCategory) do
        tinsert(names, name)
    end

    local db = OneWoW_Bags.db
    local categoryOrder = db.global.categoryOrder
    if #categoryOrder > 0 then
        local orderMap = {}
        for i, name in ipairs(categoryOrder) do
            orderMap[name] = i
        end
        sort(names, function(a, b)
            local aPos = orderMap[a] or 999
            local bPos = orderMap[b] or 999
            if aPos ~= bPos then return aPos < bPos end
            return a < b
        end)
    else
        local sortMode = db.global.categorySort or "priority"
        Categories:SortCategories(names, sortMode)
    end

    return names
end

local function ResolveSectionShowHeader(sec, containerType)
    if containerType == "backpack" then
        return sec.showHeader or false
    end
    local bankVal = sec.showHeaderBank
    if bankVal == nil then return sec.showHeader or false end
    return bankVal
end

local function GetSectionedLayoutImpl(itemsByCategory, containerType)
    local Categories = OneWoW_Bags.Categories
    local db = OneWoW_Bags.db

    local sections    = db.global.categorySections
    local sectOrder   = db.global.sectionOrder
    local catOrder    = db.global.categoryOrder

    if #sectOrder == 0 then
        return H.GetSortedCategoryNames(itemsByCategory)
    end

    local g = db.global
    local disabled = g.disabledCategories
    local function IsCategoryVisible(catName)
        if disabled[catName] then
            local hasItems = itemsByCategory[catName] and #itemsByCategory[catName] > 0
            if not (g.pinnedCategoryShowsWhenDisabled and hasItems) then
                return false
            end
        end
        if not Categories:CategoryAppliesTo(catName, containerType) then
            return false
        end
        return true
    end

    local displayOrder = db.global.displayOrder

    if #displayOrder > 0 then
        local layout = {}

        local inOrder = {}
        for _, entry in ipairs(displayOrder) do
            if entry ~= "----" and entry ~= "section_end" and not entry:find("^section:") then
                inOrder[entry] = true
            end
        end

        local equipSlotNames = {}
        if db.global.enableInventorySlots then
            local slotKeys = {
                "INVTYPE_HEAD", "INVTYPE_NECK", "INVTYPE_SHOULDER", "INVTYPE_BODY",
                "INVTYPE_CHEST", "INVTYPE_WAIST", "INVTYPE_LEGS", "INVTYPE_FEET",
                "INVTYPE_WRIST", "INVTYPE_HAND", "INVTYPE_FINGER", "INVTYPE_TRINKET",
                "INVTYPE_WEAPON", "INVTYPE_2HWEAPON", "INVTYPE_WEAPONMAINHAND",
                "INVTYPE_WEAPONOFFHAND", "INVTYPE_SHIELD", "INVTYPE_HOLDABLE",
                "INVTYPE_CLOAK", "INVTYPE_RANGED",
            }
            for _, key in ipairs(slotKeys) do
                local displayName = _G[key]
                if displayName and displayName ~= "" then
                    equipSlotNames[displayName] = true
                end
            end
        end

        local i = 1
        while i <= #displayOrder do
            local entry = displayOrder[i]

            if entry == "----" then
                -- Separators are obsolete (every category lives in a section, so
                -- the section header is the divider); ignore stray "----" tokens
                -- left in saved displayOrder data.
            elseif entry:sub(1, 8) == "section:" then
                local sectionID = entry:sub(9)
                local sec = sections[sectionID]

                local sectionCatNames = {}
                i = i + 1
                while i <= #displayOrder and displayOrder[i] ~= "section_end" do
                    local catEntry = displayOrder[i]
                    if catEntry ~= "----" and not catEntry:find("^section:") then
                        tinsert(sectionCatNames, catEntry)
                    end
                    i = i + 1
                end

                if sec then
                    local visibleCats = {}
                    local hasEquipBase = false
                    for _, catName in ipairs(sectionCatNames) do
                        if itemsByCategory[catName] and #itemsByCategory[catName] > 0 and IsCategoryVisible(catName) then
                            tinsert(visibleCats, catName)
                        end
                        if catName == "Weapons" or catName == "Armor" then
                            hasEquipBase = true
                        end
                    end

                    local sectionSlotCats = {}
                    if hasEquipBase and db.global.enableInventorySlots then
                        for name in pairs(itemsByCategory) do
                            if not inOrder[name] and equipSlotNames[name] and #itemsByCategory[name] > 0 and IsCategoryVisible(name) then
                                tinsert(sectionSlotCats, name)
                            end
                        end
                        sort(sectionSlotCats)
                    end

                    local hasContent = #visibleCats > 0 or #sectionSlotCats > 0

                    if hasContent then
                        local showHeader = ResolveSectionShowHeader(sec, containerType)
                        local effectiveCollapsed = showHeader and sec.collapsed
                        tinsert(layout, { type = "section_header", name = sec.name, sectionID = sectionID, collapsed = effectiveCollapsed, showHeader = showHeader })

                        if not effectiveCollapsed then
                            for _, catName in ipairs(visibleCats) do
                                tinsert(layout, { type = "category", name = catName })
                            end
                            for _, catName in ipairs(sectionSlotCats) do
                                tinsert(layout, { type = "category", name = catName })
                            end
                        end
                    end
                end
            elseif entry ~= "section_end" then
                if itemsByCategory[entry] and #itemsByCategory[entry] > 0 and IsCategoryVisible(entry) then
                    tinsert(layout, { type = "category", name = entry })
                end
            end

            i = i + 1
        end

        local claimedSlots = {}
        if db.global.enableInventorySlots then
            for name in pairs(itemsByCategory) do
                if equipSlotNames[name] then
                    claimedSlots[name] = true
                end
            end
        end

        local leftover = {}
        for name in pairs(itemsByCategory) do
            if not inOrder[name] and not claimedSlots[name] and #itemsByCategory[name] > 0 and IsCategoryVisible(name) then
                tinsert(leftover, name)
            end
        end
        Categories:SortCategories(leftover, db.global.categorySort or "priority")
        for _, name in ipairs(leftover) do
            tinsert(layout, { type = "category", name = name })
        end

        return layout
    end

    local inSection = {}
    for _, sec in pairs(sections) do
        for _, catName in ipairs(sec.categories or {}) do
            inSection[catName] = true
        end
    end

    local layout = {}

    -- Fallback: any visible category not placed in a section.
    -- SyncOnewowSectionCategories should normally prevent this, but we keep
    -- the safety net so orphaned items remain visible in the bag if the
    -- invariant is ever broken.
    local orphanedCats = {}
    for name in pairs(itemsByCategory) do
        if not inSection[name] and IsCategoryVisible(name) then
            tinsert(orphanedCats, name)
        end
    end

    if #catOrder > 0 then
        local orderMap = {}
        for i, name in ipairs(catOrder) do orderMap[name] = i end
        sort(orphanedCats, function(a, b)
            local aP = orderMap[a] or 999
            local bP = orderMap[b] or 999
            if aP ~= bP then return aP < bP end
            return a < b
        end)
    else
        Categories:SortCategories(orphanedCats, db.global.categorySort or "priority")
    end

    for _, sectionID in ipairs(sectOrder) do
        local sec = sections[sectionID]
        if sec and sec.categories then
            local hasItems = false
            for _, catName in ipairs(sec.categories) do
                if itemsByCategory[catName] and #itemsByCategory[catName] > 0 and IsCategoryVisible(catName) then
                    hasItems = true
                    break
                end
            end
            if hasItems then
                local showHeader = ResolveSectionShowHeader(sec, containerType)
                local effectiveCollapsed = showHeader and sec.collapsed

                tinsert(layout, { type = "section_header", name = sec.name, sectionID = sectionID, collapsed = effectiveCollapsed, showHeader = showHeader })
                if not effectiveCollapsed then
                    for _, catName in ipairs(sec.categories) do
                        if itemsByCategory[catName] and #itemsByCategory[catName] > 0 and IsCategoryVisible(catName) then
                            tinsert(layout, { type = "category", name = catName })
                        end
                    end
                end
            end
        end
    end

    for _, name in ipairs(orphanedCats) do
        tinsert(layout, { type = "category", name = name })
    end

    return layout
end

function H.GetSectionedLayout(itemsByCategory, containerType)
    local Profile = OneWoW_Bags.Profile
    Profile:Start("CategoryView.GetSectionedLayout")
    local result = GetSectionedLayoutImpl(itemsByCategory, containerType)
    Profile:Stop("CategoryView.GetSectionedLayout")
    return result
end

function H.RestoreItemButtonCounts(items)
    for _, btn in ipairs(items) do
        btn._owb_stackCount = nil
        btn._owb_virtualStackButtons = nil
        local info = btn.owb_itemInfo
        if info and info.hyperlink then
            SetItemButtonCount(btn, info.stackCount or 0)
        else
            SetItemButtonCount(btn, 0)
        end
    end
end

function H.StackItems(items, db, PE)
    H.RestoreItemButtonCounts(items)
    if not db.global.stackItems then return items end
    local stacks = {}
    local stackOrder = {}
    for _, btn in ipairs(items) do
        local info = btn.owb_itemInfo
        local itemID = info and info.itemID
        if not itemID then
            tinsert(stackOrder, { buttons = {btn}, count = 1 })
        else
            local key = PE:GetItemIdentityKey(itemID, info and info.hyperlink)
            if not stacks[key] then
                stacks[key] = { buttons = {}, count = 0, representative = btn }
                tinsert(stackOrder, stacks[key])
            end
            tinsert(stacks[key].buttons, btn)
            stacks[key].count = stacks[key].count + (info.stackCount or 1)
        end
    end
    local result = {}
    for _, stack in ipairs(stackOrder) do
        local rep = stack.representative or stack.buttons[1]
        if stack.count > 1 and rep then
            rep._owb_stackCount = stack.count
            rep._owb_virtualStackButtons = stack.buttons
            SetItemButtonCount(rep, stack.count)
        end
        tinsert(result, rep)
        for _, btn in ipairs(stack.buttons) do
            if btn ~= rep then
                btn:Hide()
            end
        end
    end
    return result
end

function H.FilterItems(categoryName, itemsByCategory, filterToken, catMods, sortButtons, db, PE)
    local Profile = OneWoW_Bags.Profile
    Profile:Start("CategoryView.FilterItems")
    local items = itemsByCategory[categoryName]
    if not items then
        Profile:Stop("CategoryView.FilterItems")
        return nil
    end
    if filterToken then
        local filtered = filterScratchByCategory[categoryName]
        if not filtered then
            filtered = {}
            filterScratchByCategory[categoryName] = filtered
        else
            wipe(filtered)
        end
        for _, btn in ipairs(items) do
            if btn._owb_filterToken == filterToken then
                tinsert(filtered, btn)
            end
        end
        items = filtered
    end
    if #items == 0 then
        Profile:Stop("CategoryView.FilterItems")
        return nil
    end
    local mod = catMods[categoryName]
    local catSort = mod and mod.sortMode or nil
    local catSubSort = mod and mod.subSortMode or nil
    sortButtons(items, catSort, catSubSort, mod and mod.sortDescending, mod and mod.subSortDescending)
    items = H.StackItems(items, db, PE)
    Profile:Stop("CategoryView.FilterItems")
    return items
end

function H.GroupItemsBy(items, groupBy)
    local groups = {}
    local groupOrder = {}

    if groupBy == "expansion" then
        for _, btn in ipairs(items) do
            local expID = btn._owb_expansionID
            if expID == nil and btn.owb_itemInfo then
                expID = OneWoW_Bags.WindowHelpers:GetButtonExpansionID(btn)
            end
            expID = expID or -1
            local expName = OneWoW_GUI:GetExpansionName(expID)
            if not groups[expName] then
                groups[expName] = {}
                tinsert(groupOrder, { name = expName, sortKey = expID })
            end
            tinsert(groups[expName], btn)
        end
        sort(groupOrder, function(a, b) return a.sortKey > b.sortKey end)
    elseif groupBy == "type" then
        local OTHER = L["CAT_OTHER"]
        for _, btn in ipairs(items) do
            local typeName = OTHER
            if btn.owb_itemInfo then
                local props = OneWoW_Bags:GetButtonProps(btn)
                typeName = props.itemType or OTHER
            end
            if not groups[typeName] then
                groups[typeName] = {}
                tinsert(groupOrder, { name = typeName, sortKey = typeName })
            end
            tinsert(groups[typeName], btn)
        end
        sort(groupOrder, function(a, b) return a.sortKey < b.sortKey end)
    elseif groupBy == "slot" then
        local OTHER = L["CAT_OTHER"]
        for _, btn in ipairs(items) do
            local slotName = OTHER
            if btn.owb_itemInfo then
                local props = OneWoW_Bags:GetButtonProps(btn)
                local equipLoc = props.equipLoc
                if equipLoc and equipLoc ~= "" then
                    slotName = _G[equipLoc] or equipLoc
                end
            end
            if not groups[slotName] then
                groups[slotName] = {}
                tinsert(groupOrder, { name = slotName, sortKey = slotName })
            end
            tinsert(groups[slotName], btn)
        end
        sort(groupOrder, function(a, b) return a.sortKey < b.sortKey end)
    elseif groupBy == "quality" then
        for _, btn in ipairs(items) do
            local q = (btn.owb_itemInfo and btn.owb_itemInfo.quality) or 0
            local qName = _G["ITEM_QUALITY" .. q .. "_DESC"] or (L["QUALITY_PREFIX"] .. q)
            if not groups[qName] then
                groups[qName] = {}
                tinsert(groupOrder, { name = qName, sortKey = q })
            end
            tinsert(groups[qName], btn)
        end
        sort(groupOrder, function(a, b) return a.sortKey > b.sortKey end)
    elseif groupBy == "equipmentset" then
        local MULTI = L["EQUIPMENT_SET_MULTIPLE"]
        local NONE  = L["EQUIPMENT_SET_NONE"]
        for _, btn in ipairs(items) do
            local key = NONE
            if btn.owb_itemInfo then
                local props = OneWoW_Bags:GetButtonProps(btn)
                local list = props.equipmentSetList
                if list and #list > 1 then
                    key = MULTI
                elseif list and #list == 1 then
                    key = list[1]
                end
            end
            if not groups[key] then
                groups[key] = {}
                tinsert(groupOrder, { name = key, sortKey = key })
            end
            tinsert(groups[key], btn)
        end
        sort(groupOrder, function(a, b)
            if a.name == MULTI then return false end
            if b.name == MULTI then return true end
            if a.name == NONE  then return false end
            if b.name == NONE  then return true end
            return a.name < b.name
        end)
    else
        return nil, nil
    end

    return groups, groupOrder
end

function H.LayoutCategoryContent(config)
    local contentFrame = config.contentFrame
    local viewContext = config.viewContext
    local itemsByCategory = config.itemsByCategory
    local layout = config.layout
    local compact = config.compact
    local showHeaders = config.showHeaders
    local verticalSpacing = config.verticalSpacing
    local compactGapSlots = config.compactGapSlots
    local cols = config.cols
    local leftPadding = config.leftPadding
    local cellSize = config.cellSize
    local iconSize = config.iconSize
    local filterToken = config.filterToken
    local db = config.db
    local PE = config.PE
    local containerType = config.containerType

    local sortButtons = viewContext.sortButtons
    local acquireSection = viewContext.acquireSection
    local acquireSectionHeader = viewContext.acquireSectionHeader
    local getCollapsed = viewContext.getCollapsed
    local setCollapsed = viewContext.setCollapsed

    local AcquireLabel = config.AcquireLabel
    local ReleaseAllLabels = config.ReleaseAllLabels
    ReleaseAllLabels()

    local moveRecentToTop = config.moveRecentToTop
    local moveOtherToBottom = config.moveOtherToBottom
    if moveRecentToTop or moveOtherToBottom then
        if type(layout[1]) == "table" then
            layout = H.PinSpecialCategories(layout, moveRecentToTop, moveOtherToBottom,
                function(entry) return entry.type == "category" and entry.name or nil end)
        else
            layout = H.PinSpecialCategories(layout, moveRecentToTop, moveOtherToBottom)
        end
    end

    local catMods = db.global.categoryModifications
    local yOffset = 0

    local function GetCategoryGrouping(categoryName)
        local mod = catMods[categoryName]
        if mod and mod.groupBy then return mod.groupBy end
        return nil
    end

    local function DoFilterItems(categoryName)
        return H.FilterItems(categoryName, itemsByCategory, filterToken, catMods, sortButtons, db, PE)
    end

    local function RenderCategoryStacked(categoryName)
        local items = DoFilterItems(categoryName)
        if not items then return end

        local groupBy = GetCategoryGrouping(categoryName)

        if showHeaders then
            local section = acquireSection(contentFrame)
            H.SetupCategorySection(section, contentFrame, yOffset, categoryName, #items, catMods)

            local collapsed = getCollapsed("category", categoryName)
            section.isCollapsed = collapsed or false

            section.collapseBtn.icon:SetAtlas(section.isCollapsed and "uitools-icon-chevron-right" or "uitools-icon-chevron-down")
            section.collapseBtn.icon:SetVertexColor(OneWoW_GUI:GetThemeColor("ACCENT_SECONDARY"))

            local sectionHeight = 26

            if not section.isCollapsed then
                section.content:SetHeight(1)

                if groupBy and groupBy ~= "none" then
                    local groups, groupOrder = H.GroupItemsBy(items, groupBy)

                    if groups and groupOrder then
                        local subY = H.RenderGroupedGrids(section.content, groups, groupOrder, {
                            x = leftPadding,
                            gridCols = cols,
                            labelWidth = cols * cellSize,
                            startY = 0,
                            cellSize = cellSize,
                            iconSize = iconSize,
                            AcquireLabel = AcquireLabel,
                        })
                        section.content:SetHeight(subY)
                        section.content:Show()
                        sectionHeight = sectionHeight + subY + 4
                    end
                else
                    local contentHeight = H.RenderItemGrid(section.content, items, 0, leftPadding, cellSize, iconSize, cols)
                    section.content:SetHeight(contentHeight)
                    section.content:Show()
                    sectionHeight = sectionHeight + contentHeight + 4
                end
            else
                section.content:Hide()
                for _, button in ipairs(items) do
                    button:Hide()
                end
            end

            section:SetHeight(sectionHeight)
            yOffset = yOffset + sectionHeight + H.VerticalGap(cellSize, verticalSpacing)

            local capturedName = categoryName
            section.header:SetScript("OnClick", nil)
            section.collapseBtn:SetScript("OnClick", function()
                section.isCollapsed = not section.isCollapsed
                setCollapsed("category", capturedName, section.isCollapsed)
            end)
        else
            local gridHeight = H.RenderItemGrid(contentFrame, items, yOffset, leftPadding, cellSize, iconSize, cols)
            yOffset = yOffset + gridHeight + H.VerticalGap(cellSize, verticalSpacing)
        end
    end

    local compactOpts = {
        yOffset = 0,
        cols = cols,
        gapSlots = compactGapSlots,
        showHeaders = showHeaders,
        leftPadding = leftPadding,
        cellSize = cellSize,
        iconSize = iconSize,
        catMods = catMods,
        AcquireLabel = AcquireLabel,
        verticalSpacing = verticalSpacing,
        containerType = containerType,
    }

    local function RenderSectionHeader(entry)
        local sectionID = entry.sectionID
        local sectionName = entry.name

        local section = acquireSectionHeader(contentFrame)
        section:ClearAllPoints()
        section:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, -yOffset)
        section:SetPoint("RIGHT", contentFrame, "RIGHT", 0, 0)
        section:SetBackdropColor(OneWoW_GUI:GetThemeColor("BG_PRIMARY"))
        section:SetBackdropBorderColor(OneWoW_GUI:GetThemeColor("BORDER_DEFAULT"))

        section.title:SetText(sectionName)
        section.title:SetTextColor(OneWoW_GUI:GetThemeColor("ACCENT_SECONDARY"))
        section.count:SetText("")

        section.collapseBtn.icon:SetAtlas(entry.collapsed and "uitools-icon-chevron-right" or "uitools-icon-chevron-down")
        section.collapseBtn.icon:SetVertexColor(OneWoW_GUI:GetThemeColor("ACCENT_SECONDARY"))

        section.content:Hide()
        section:SetHeight(24)
        yOffset = yOffset + 26

        local capturedSectionID = sectionID
        section.header:SetScript("OnClick", nil)
        section.collapseBtn:SetScript("OnClick", function()
            section.isCollapsed = not section.isCollapsed
            setCollapsed("section", capturedSectionID, section.isCollapsed)
        end)
    end

    local function BuildCatInfo(categoryName)
        local items = DoFilterItems(categoryName)
        if not items then return nil end
        return {
            name = categoryName,
            displayName = H.ResolveCategoryName(categoryName),
            items = items,
            groupBy = GetCategoryGrouping(categoryName),
        }
    end

    if type(layout) == "table" and layout[1] and type(layout[1]) == "table" then
        if compact then
            local currentGroup = {}
            for _, entry in ipairs(layout) do
                if entry.type == "category" then
                    local catInfo = BuildCatInfo(entry.name)
                    if catInfo then
                        tinsert(currentGroup, catInfo)
                    end
                elseif entry.type == "section_header" then
                    compactOpts.yOffset = yOffset
                    yOffset = H.LayoutCompactGroup(currentGroup, contentFrame, compactOpts)
                    currentGroup = {}
                    if entry.showHeader then
                        -- Skip the leading gap for the very first section (nothing
                        -- above it yet) so it sits flush to the top.
                        if yOffset > 0 then
                            yOffset = yOffset + H.VerticalGap(cellSize, verticalSpacing)
                        end
                        RenderSectionHeader(entry)
                    end
                end
            end
            compactOpts.yOffset = yOffset
            yOffset = H.LayoutCompactGroup(currentGroup, contentFrame, compactOpts)
        else
            for _, entry in ipairs(layout) do
                if entry.type == "category" then
                    RenderCategoryStacked(entry.name)
                elseif entry.type == "section_header" then
                    if entry.showHeader then
                        RenderSectionHeader(entry)
                    end
                end
            end
        end
    else
        if compact then
            local currentGroup = {}
            for _, categoryName in ipairs(layout) do
                local catInfo = BuildCatInfo(categoryName)
                if catInfo then
                    tinsert(currentGroup, catInfo)
                end
            end
            compactOpts.yOffset = yOffset
            yOffset = H.LayoutCompactGroup(currentGroup, contentFrame, compactOpts)
        else
            for _, categoryName in ipairs(layout) do
                RenderCategoryStacked(categoryName)
            end
        end
    end

    return max(yOffset, 100)
end
