--[[
    Warband Nexus - Characters Tab
    Display all tracked characters with gold, level, and last seen info

    WN_FACTORY: Gold/token text blocks and collapsible section hosts use `Factory:CreateContainer`; row chrome
    and delete icon buttons use `CreateButton`. Delete-confirm dialog prefers `UI_CreateThemedButton`, else
    Factory + ApplyVisuals; custom header dialogs fall back to plain `CreateFrame` only if Factory is absent.
    WN_PERF: Leaf rows virtualized (`VirtualListModule`); WoW Token request + lastSeen SavedVariables bumps throttled.
]]

local ADDON_NAME, ns = ...
local WarbandNexus = ns.WarbandNexus
local E = ns.Constants.EVENTS
local FontManager = ns.FontManager  -- Centralized font management

local issecretvalue = issecretvalue

local function SafeLower(s)
    if not s or s == "" then return "" end
    if issecretvalue and issecretvalue(s) then return "" end
    return s:lower()
end

local function CompareCharNameLower(a, b)
    return SafeLower(a.name) < SafeLower(b.name)
end

-- Unique AceEvent handler identity for CharactersUI
local CharactersUIEvents = {}

-- Throttle WoW Token market request (C_WowTokenPublic.UpdateMarketPrice) â€” avoid per-tab-draw Blizzard work.
local _wnCharsTabLastTokenReq = 0
local WN_CHARS_TAB_TOKEN_REQ_INTERVAL = 600

-- Throttle lastSeen SavedVariables writes when reopening the Characters tab in one session.
local _wnCharsTabLastSeenBump = {}
local WN_CHARS_TAB_LAST_SEEN_INTERVAL = 90

--- Narrow layout: stacked Total Gold / WoW Token subtitle+body column height (`MAIN_SHELL.HEADER_BAR_HEIGHT` rhythm).
local function CharactersTotalGoldTokenStackTextHeight()
    local ms = ns.UI_LAYOUT and ns.UI_LAYOUT.MAIN_SHELL or {}
    return ms.HEADER_BAR_HEIGHT or 40
end

local DebugPrint = ns.DebugPrint

-- Tooltip API
local ShowTooltip = ns.UI_ShowTooltip
local HideTooltip = ns.UI_HideTooltip

-- Import shared UI components (always get fresh reference)
local COLORS = ns.UI_COLORS
local CreateCard = ns.UI_CreateCard
local FormatGold = ns.UI_FormatGold
local FormatMoney = ns.UI_FormatMoney
local CreateCollapsibleHeader = ns.UI_CreateCollapsibleHeader
local BuildCollapsibleSectionOpts = ns.UI_BuildCollapsibleSectionOpts
local ApplyVisuals = ns.UI_ApplyVisuals
local FormatNumber = ns.UI_FormatNumber or function(n)
    return tostring(math.floor(tonumber(n) or 0))
end

local function ThemeTextHex(role)
    if ns.UI_GetTextRoleHex then
        return ns.UI_GetTextRoleHex(role)
    end
    if role == "Dim" then return "|cff888888" end
    if role == "Muted" then return "|cffaaaaaa" end
    return (ns.UI_GetBrightHex and ns.UI_GetBrightHex()) or (ns.UI_GetTextRoleHex and ns.UI_GetTextRoleHex("Bright")) or "|cffeeeeee"
end

local function SemanticGoldHex()
    if ns.UI_GetSemanticGoldHex then
        return ns.UI_GetSemanticGoldHex()
    end
    return "|cffffd700"
end

local function SemanticGoldRGB()
    if ns.UI_GetSemanticGoldColor then
        return ns.UI_GetSemanticGoldColor()
    end
    return 1, 0.82, 0
end

--- Set row/cell text then refresh light-mode outline when markup or colored ink is present.
local function PresentFS(fs, text)
    if not fs then return end
    if text ~= nil and fs.SetText then
        fs:SetText(text)
    end
    if ns.UI_ApplyFontStringPresentation then
        ns.UI_ApplyFontStringPresentation(fs, text or (fs.GetText and fs:GetText()))
    end
end

--- Section header count badge: role-colored count with standard FontManager outline.
local function SetSectionHeaderCount(fs, count, role)
    if not fs then return end
    ns.UI_SetTextColorRole(fs, role or "Muted")
    fs:SetText(FormatNumber(count or 0))
end
--- When `UI_CreateThemedButton` is missing, prefer Factory + ApplyVisuals; else legacy panel textures.
local function CreateCharacterDeleteDialogButton(parent, label, width, destructive)
    local themed = ns.UI_CreateThemedButton
    if themed then
        return themed(parent, label, width)
    end
    local Factory = ns.UI and ns.UI.Factory
    local ac = COLORS and COLORS.accent or { 0.4, 0.2, 0.58 }
    local ar, ag, ab = ac[1] or 0.4, ac[2] or 0.2, ac[3] or 0.58
    local btnH = 36
    if Factory and Factory.CreateButton then
        local btn = Factory:CreateButton(parent, width, btnH, false)
        if ApplyVisuals then
            if destructive then
                ApplyVisuals(btn, { 0.22, 0.06, 0.07, 1 }, { 0.75, 0.18, 0.2, 1 })
            else
                ApplyVisuals(btn, { ar * 0.5, ag * 0.5, ab * 0.5, 1 }, { ar, ag, ab, 1 })
            end
        end
        if Factory.ApplyHighlight then
            Factory:ApplyHighlight(btn)
        end
        local btnText = FontManager:CreateFontString(btn, "body", "OVERLAY")
        btnText:SetPoint("CENTER")
        btnText:SetText(label)
        btn.text = btnText
        if ApplyVisuals then
            if destructive then
                btn:SetScript("OnEnter", function(self)
                    ApplyVisuals(self, { 0.55, 0.1, 0.12, 1 }, { 0.95, 0.25, 0.28, 1 })
                end)
                btn:SetScript("OnLeave", function(self)
                    ApplyVisuals(self, { 0.22, 0.06, 0.07, 1 }, { 0.75, 0.18, 0.2, 1 })
                end)
            else
                btn:SetScript("OnEnter", function(self)
                    ApplyVisuals(self, { ar * 0.7, ag * 0.7, ab * 0.7, 1 }, { ar, ag, ab, 1 })
                end)
                btn:SetScript("OnLeave", function(self)
                    ApplyVisuals(self, { ar * 0.5, ag * 0.5, ab * 0.5, 1 }, { ar, ag, ab, 1 })
                end)
            end
        end
        return btn
    end
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(width, btnH)
    btn:SetNormalTexture("Interface\\Buttons\\UI-Panel-Button-Up")
    btn:SetHighlightTexture("Interface\\Buttons\\UI-Panel-Button-Highlight")
    btn:SetPushedTexture("Interface\\Buttons\\UI-Panel-Button-Down")
    local btnText = FontManager:CreateFontString(btn, "body", "OVERLAY")
    btnText:SetPoint("CENTER")
    btnText:SetText(label)
    return btn
end
local CreateFactionIcon = ns.UI_CreateFactionIcon
local CreateRaceIcon = ns.UI_CreateRaceIcon
local CreateDBVersionBadge = ns.UI_CreateDBVersionBadge
local CreateClassIcon = ns.UI_CreateClassIcon
local CreateFavoriteButton = ns.UI_CreateFavoriteButton
local CreateOnlineIndicator = ns.UI_CreateOnlineIndicator
local GetColumnOffset = ns.UI_GetColumnOffset
local CreateEmptyStateCard = ns.UI_CreateEmptyStateCard
local HideEmptyStateCard = ns.UI_HideEmptyStateCard
local CreateIcon = ns.UI_CreateIcon -- Factory for icons
-- Pooling constants
local AcquireCharacterRow = ns.UI_AcquireCharacterRow
local ReleaseCharacterRow = ns.UI_ReleaseCharacterRow

--- Virtual-scroll tabs: refresh row culling after collapsible section layout (ReputationUI parity).
local function CharactersVirtualScrollBump()
    local mf = WarbandNexus.UI and WarbandNexus.UI.mainFrame
    if not mf then return end
    -- Corner-drag: freeze list until resize commit (no interval column/gold/gradient churn).
    if ns.UI_IsMainFrameResizeSession and ns.UI_IsMainFrameResizeSession(mf) then
        return
    end
    if mf._virtualScrollUpdate then
        mf._virtualScrollUpdate()
    end
end

local CHAR_GOLD_ROW_MIN_WIDTH = 500
local GOLD_CARD_STD_HEIGHT = 90
local GOLD_CARD_TALL_HEIGHT = 108
-- Side-by-side Total Gold | Token needs room for two icons + two long money strings;
-- at 300 the value texts met in the middle (now clamped, but stack earlier anyway).
local GOLD_TOKEN_MIN_SPLIT_WIDTH = 340

local CHAR_GOLD_STACK_HYSTERESIS = 24

local function ComputeCharactersGoldCardMetrics(hostW, leftMargin, rightMargin, cardSpacing, layoutState)
    local innerGoldW = math.max(1, hostW - leftMargin - rightMargin)
    local totalSpacing = cardSpacing * 2
    local stackGoldCards
    if layoutState and layoutState.stackGoldCards ~= nil then
        if layoutState.stackGoldCards then
            stackGoldCards = innerGoldW < (CHAR_GOLD_ROW_MIN_WIDTH + CHAR_GOLD_STACK_HYSTERESIS)
        else
            stackGoldCards = innerGoldW < (CHAR_GOLD_ROW_MIN_WIDTH - CHAR_GOLD_STACK_HYSTERESIS)
        end
    else
        stackGoldCards = innerGoldW < CHAR_GOLD_ROW_MIN_WIDTH
    end
    local goldCardW1, goldCardW2, goldCardW3
    if stackGoldCards then
        goldCardW1 = innerGoldW
        goldCardW2 = innerGoldW
        goldCardW3 = innerGoldW
    else
        goldCardW1 = math.floor((innerGoldW - totalSpacing) / 3)
        goldCardW2 = goldCardW1
        goldCardW3 = innerGoldW - totalSpacing - goldCardW1 - goldCardW2
    end
    local stackGoldTokenInCard3
    if layoutState and layoutState.stackGoldTokenInCard3 ~= nil then
        if layoutState.stackGoldTokenInCard3 then
            stackGoldTokenInCard3 = goldCardW3 < (GOLD_TOKEN_MIN_SPLIT_WIDTH + CHAR_GOLD_STACK_HYSTERESIS)
        else
            stackGoldTokenInCard3 = goldCardW3 < (GOLD_TOKEN_MIN_SPLIT_WIDTH - CHAR_GOLD_STACK_HYSTERESIS)
        end
    else
        stackGoldTokenInCard3 = goldCardW3 < GOLD_TOKEN_MIN_SPLIT_WIDTH
    end
    local card12H = GOLD_CARD_STD_HEIGHT
    local card3H = stackGoldTokenInCard3 and GOLD_CARD_TALL_HEIGHT or GOLD_CARD_STD_HEIGHT
    return {
        innerGoldW = innerGoldW,
        stackGoldCards = stackGoldCards,
        stackGoldTokenInCard3 = stackGoldTokenInCard3,
        goldCardW1 = goldCardW1,
        goldCardW2 = goldCardW2,
        goldCardW3 = goldCardW3,
        card12H = card12H,
        card3H = card3H,
        cardSpacing = cardSpacing,
    }
end

local function ApplyTotalGoldCardInterior(bundle, stackToken)
    local totalGoldCard = bundle.total
    local divider = bundle.divider
    local tgIcon = bundle.tgIcon
    local tgTextContainer = bundle.tgTextContainer
    local tkIcon = bundle.tkIcon
    local tkTextContainer = bundle.tkTextContainer
    if not totalGoldCard or not divider or not tgIcon or not tgTextContainer or not tkIcon or not tkTextContainer then
        return
    end
    if stackToken then
        divider:Hide()
        tgIcon:ClearAllPoints()
        tgIcon:SetPoint("TOPLEFT", totalGoldCard, "TOPLEFT", 12, -12)
        tgTextContainer:ClearAllPoints()
        tgTextContainer:SetPoint("TOP", totalGoldCard, "TOP", 0, -10)
        tgTextContainer:SetPoint("LEFT", tgIcon, "RIGHT", 10, 0)
        tgTextContainer:SetPoint("RIGHT", totalGoldCard, "RIGHT", -10, 0)
        tgTextContainer:SetHeight(CharactersTotalGoldTokenStackTextHeight())
        local midRule = bundle.midRule
        if not midRule then
            midRule = totalGoldCard:CreateTexture(nil, "ARTWORK")
            midRule:SetHeight(1)
            midRule:SetColorTexture(COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.35)
            bundle.midRule = midRule
        end
        midRule:ClearAllPoints()
        midRule:SetPoint("LEFT", totalGoldCard, "LEFT", 10, 0)
        midRule:SetPoint("RIGHT", totalGoldCard, "RIGHT", -10, 0)
        midRule:SetPoint("TOP", totalGoldCard, "TOP", 0, -52)
        midRule:Show()
        tkIcon:ClearAllPoints()
        tkIcon:SetPoint("TOPLEFT", totalGoldCard, "TOPLEFT", 14, -58)
        tkTextContainer:ClearAllPoints()
        tkTextContainer:SetPoint("TOP", totalGoldCard, "TOP", 0, -56)
        tkTextContainer:SetPoint("LEFT", tkIcon, "RIGHT", 10, 0)
        tkTextContainer:SetPoint("RIGHT", totalGoldCard, "RIGHT", -10, 0)
        tkTextContainer:SetHeight(CharactersTotalGoldTokenStackTextHeight())
    else
        if bundle.midRule then
            bundle.midRule:Hide()
        end
        divider:Show()
        divider:ClearAllPoints()
        divider:SetPoint("CENTER", totalGoldCard, "CENTER", 0, 0)
        tgIcon:ClearAllPoints()
        tgIcon:SetPoint("CENTER", totalGoldCard, "LEFT", 15 + 18, 0)
        tgTextContainer:ClearAllPoints()
        tgTextContainer:SetPoint("LEFT", tgIcon, "RIGHT", 12, 0)
        tgTextContainer:SetPoint("RIGHT", divider, "LEFT", -8, 0)
        tgTextContainer:SetPoint("TOP", tgIcon, "TOP", 0, 0)
        tgTextContainer:SetPoint("BOTTOM", tgIcon, "BOTTOM", 0, 0)
        tkIcon:ClearAllPoints()
        tkIcon:SetPoint("LEFT", divider, "RIGHT", 14, 0)
        tkTextContainer:ClearAllPoints()
        tkTextContainer:SetPoint("LEFT", tkIcon, "RIGHT", 10, 0)
        tkTextContainer:SetPoint("RIGHT", totalGoldCard, "RIGHT", -12, 0)
        tkTextContainer:SetPoint("TOP", tkIcon, "TOP", 0, 0)
        tkTextContainer:SetPoint("BOTTOM", tkIcon, "BOTTOM", 0, 0)
    end
end

local function ApplyCharactersGoldCardsGeometry(bundle, hostW)
    if not bundle or not bundle.char or not bundle.wb or not bundle.total then
        return false
    end
    local leftMargin = bundle.leftMargin or 12
    local rightMargin = bundle.rightMargin or 12
    local cardSpacing = bundle.cardSpacing or 10
    local yAnchor = bundle.yOffset or 0
    local gm = ComputeCharactersGoldCardMetrics(hostW, leftMargin, rightMargin, cardSpacing, bundle._layoutState)
    local charGoldCard = bundle.char
    local wbGoldCard = bundle.wb
    local totalGoldCard = bundle.total

    charGoldCard:SetHeight(gm.card12H)
    charGoldCard:SetWidth(gm.goldCardW1)
    charGoldCard:ClearAllPoints()
    charGoldCard:SetPoint("TOPLEFT", bundle.parent, "TOPLEFT", leftMargin, -yAnchor)

    wbGoldCard:SetHeight(gm.card12H)
    wbGoldCard:SetWidth(gm.goldCardW2)
    wbGoldCard:ClearAllPoints()
    if gm.stackGoldCards then
        wbGoldCard:SetPoint("TOPLEFT", charGoldCard, "BOTTOMLEFT", 0, -cardSpacing)
    else
        wbGoldCard:SetPoint("TOPLEFT", charGoldCard, "TOPRIGHT", cardSpacing, 0)
    end

    totalGoldCard:SetHeight(gm.card3H)
    totalGoldCard:SetWidth(gm.goldCardW3)
    totalGoldCard:ClearAllPoints()
    if gm.stackGoldCards then
        totalGoldCard:SetPoint("TOPLEFT", wbGoldCard, "BOTTOMLEFT", 0, -cardSpacing)
    else
        totalGoldCard:SetPoint("TOPLEFT", wbGoldCard, "TOPRIGHT", cardSpacing, 0)
    end

    ApplyTotalGoldCardInterior(bundle, gm.stackGoldTokenInCard3)
    bundle._gm = gm
    bundle._layoutState = {
        stackGoldCards = gm.stackGoldCards,
        stackGoldTokenInCard3 = gm.stackGoldTokenInCard3,
    }
    return true
end

--- Corner-drag: card widths only; layout mode, anchors, and heights stay frozen until commit.
local function ApplyCharactersGoldCardsWidthOnly(bundle, hostW)
    if not bundle or not bundle.char or not bundle.wb or not bundle.total then
        return false
    end
    local gm = bundle._gm
    local layoutState = bundle._layoutState
    if not gm or not layoutState then
        return ApplyCharactersGoldCardsGeometry(bundle, hostW)
    end
    local leftMargin = bundle.leftMargin or 12
    local rightMargin = bundle.rightMargin or 12
    local cardSpacing = bundle.cardSpacing or gm.cardSpacing or 10
    local innerGoldW = math.max(1, hostW - leftMargin - rightMargin)
    if layoutState.stackGoldCards then
        bundle.char:SetWidth(innerGoldW)
        bundle.wb:SetWidth(innerGoldW)
        bundle.total:SetWidth(innerGoldW)
    else
        local totalSpacing = cardSpacing * 2
        local w1 = math.floor((innerGoldW - totalSpacing) / 3)
        local w2 = w1
        local w3 = innerGoldW - totalSpacing - w1 - w2
        bundle.char:SetWidth(w1)
        bundle.wb:SetWidth(w2)
        bundle.total:SetWidth(w3)
        if bundle._layoutState.stackGoldTokenInCard3 ~= nil then
            ApplyTotalGoldCardInterior(bundle, bundle._layoutState.stackGoldTokenInCard3)
        end
    end
    return true
end

--- True when commit would change gold stack mode or card heights (section yOffset chain must rebuild).
local function CharactersGoldLayoutNeedsFullRedraw(bundle, hostW)
    if not bundle or hostW < 1 then return false end
    local leftMargin = bundle.leftMargin or 12
    local rightMargin = bundle.rightMargin or 12
    local cardSpacing = bundle.cardSpacing or 10
    local gm = ComputeCharactersGoldCardMetrics(hostW, leftMargin, rightMargin, cardSpacing, bundle._layoutState)
    local prevState = bundle._layoutState
    local prevGm = bundle._gm
    if prevState then
        if gm.stackGoldCards ~= prevState.stackGoldCards then return true end
        if gm.stackGoldTokenInCard3 ~= prevState.stackGoldTokenInCard3 then return true end
    end
    if prevGm then
        if gm.card3H ~= prevGm.card3H or gm.card12H ~= prevGm.card12H then return true end
    end
    return false
end

--- Live resize: three gold cards track viewport width (scrollChild width stays frozen mid-drag).
local function RelayoutCharactersGoldCards(scrollChild, contentWidth, mf)
    if not scrollChild then return false end
    local bundle = scrollChild._wnCharsGoldBundle
    if not bundle then return false end
    local hostW = contentWidth or 0
    if hostW < 1 and mf and ns.UI_GetMainTabViewportWidth then
        hostW = ns.UI_GetMainTabViewportWidth(mf)
    elseif hostW < 1 and mf and ns.UI_GetMainScrollContentWidth then
        hostW = ns.UI_GetMainScrollContentWidth(mf)
    end
    if hostW < 1 then return false end
    return ApplyCharactersGoldCardsGeometry(bundle, hostW)
end

local CHAR_ROW_COLUMNS = ns.UI_CHAR_ROW_COLUMNS

--- Live resize: reposition width-dependent columns + class gradient (no full DrawCharacterRow).
local function ApplyCharacterRowInteriorLayout(row, rowW)
    if not row or not rowW or rowW < 1 then return end
    if row.GetWidth then
        local frameW = row:GetWidth()
        if frameW and frameW > 1 then
            rowW = frameW
        end
    end
    local addon = WarbandNexus
    local guildColW = addon._charsListStableGuildColW or addon._charListMaxGuildWidth
    if ns.UI_ComputeCharRowGuildColumnWidth and addon._charListMaxGuildWidth then
        local newGuildW = ns.UI_ComputeCharRowGuildColumnWidth(rowW, addon._charListMaxGuildWidth)
        if newGuildW and newGuildW > 0 then
            guildColW = newGuildW
            addon._charsListStableGuildColW = newGuildW
        end
    end

    local nameOffset = GetColumnOffset("name")
    local guildOffset = nameOffset + (CHAR_ROW_COLUMNS.name.total or 115)
    local guildSpacing = (CHAR_ROW_COLUMNS.guild and CHAR_ROW_COLUMNS.guild.spacing) or 15
    local rowH = row:GetHeight() or 46
    local centerY = -rowH / 2

    if row.guildText and guildColW then
        row.guildText:ClearAllPoints()
        row.guildText:SetPoint("CENTER", row, "TOPLEFT", guildOffset + (guildColW - 4) / 2, centerY)
        row.guildText:SetWidth(math.max(1, guildColW - 4))
    end

    local guildTotal = guildColW + guildSpacing
    local levelOffset = guildOffset + guildTotal
    local levelColW = CHAR_ROW_COLUMNS.level.width

    if row.levelText then
        row.levelText:ClearAllPoints()
        if row.levelRestedText and row.levelRestedText:IsShown() then
            row.levelText:SetPoint("TOP", row, "TOPLEFT", levelOffset + (levelColW / 2), -7)
        else
            row.levelText:SetPoint("LEFT", levelOffset, 0)
        end
    end

    local itemLevelOffset = levelOffset + (CHAR_ROW_COLUMNS.level.total or 97)
    if row.itemLevelText then
        row.itemLevelText:ClearAllPoints()
        row.itemLevelText:SetPoint("LEFT", itemLevelOffset, 0)
    end

    local goldOffset = itemLevelOffset + (CHAR_ROW_COLUMNS.itemLevel.total or 90)
    if row.goldText then
        row.goldText:ClearAllPoints()
        row.goldText:SetPoint("LEFT", goldOffset, 0)
    end

    local profOffset = goldOffset + (CHAR_ROW_COLUMNS.gold.total or 205)
    if row.profIcons then
        local shown = {}
        for i = 1, #row.profIcons do
            local pFrame = row.profIcons[i]
            if pFrame and pFrame.IsShown and pFrame:IsShown() then
                shown[#shown + 1] = pFrame
            end
        end
        local numProfs = #shown
        if numProfs > 0 then
            local iconSize = 39
            local iconSpacing = 5
            local profColumnWidth = CHAR_ROW_COLUMNS.professions.width
            local totalIconWidth = (numProfs * iconSize) + ((numProfs - 1) * iconSpacing)
            local leftPadding = (profColumnWidth - totalIconWidth) / 2
            local currentProfX = profOffset + leftPadding
            for i = 1, numProfs do
                local pFrame = shown[i]
                pFrame:ClearAllPoints()
                pFrame:SetPoint("LEFT", currentProfX, 0)
                currentProfX = currentProfX + iconSize + iconSpacing
            end
        end
    end

    local mythicKeyOffset = profOffset + (CHAR_ROW_COLUMNS.professions.total or 150)
    if row.keystoneIcon then
        row.keystoneIcon:ClearAllPoints()
        row.keystoneIcon:SetPoint("LEFT", mythicKeyOffset + 5, 0)
    end
    if row.keystoneText then
        row.keystoneText:ClearAllPoints()
        row.keystoneText:SetPoint("LEFT", mythicKeyOffset + 34, 0)
    end

    if row._wnRightRail then
        local railW = (ns.UI_GetCharRowRightRailWidth and ns.UI_GetCharRowRightRailWidth())
            or row._wnRightRail:GetWidth()
        row._wnRightRail:SetSize(railW, rowH)
        row._wnRightRail:ClearAllPoints()
        local R_MARGIN = ns.UI_CHAR_ROW_RIGHT_MARGIN or 6
        row._wnRightRail:SetPoint("TOPRIGHT", row, "TOPRIGHT", -R_MARGIN, 0)
    end

    row._wnRowPaintWidth = rowW
    if row._wnGradientRefresh then
        pcall(row._wnGradientRefresh)
    end
end


local function StyleCharacterDeleteIcon(icon, highlighted)
    if not icon then return end
    icon:SetDesaturated(false)
    if highlighted then
        icon:SetVertexColor(1, 0.5, 0.5)
        icon:SetAlpha(1)
    else
        icon:SetVertexColor(0.95, 0.35, 0.35)
        icon:SetAlpha(0.98)
    end
end

local GetCharKey = ns.UI_GetCharKey
local tinsert = table.insert
local tremove = table.remove

--- DB row key for the logged-in player (raw GetCharacterKey vs canonical SavedVariables key).
local function ResolveSessionCharactersTableKey()
    if ns.CharacterService and ns.CharacterService.ResolveCharactersTableKey then
        return ns.CharacterService:ResolveCharactersTableKey(WarbandNexus)
    end
    return nil
end

--- True if `char` list row is the current client session (fixes stale UI when row._key ~= GetCharacterKey()).
local function IsCharLoggedInSession(char)
    local rowKey = GetCharKey(char)
    if not rowKey then return false end
    local storage = ns.Utilities.GetCharacterStorageKey and ns.Utilities:GetCharacterStorageKey(WarbandNexus)
    if storage and rowKey == storage then return true end
    local resolved = ResolveSessionCharactersTableKey()
    if resolved and rowKey == resolved then return true end
    if ns.Utilities and ns.Utilities.GetCanonicalCharacterKey then
        local canonRow = ns.Utilities:GetCanonicalCharacterKey(rowKey)
        if storage and canonRow == storage then return true end
        if resolved and canonRow == resolved then return true end
    end
    return false
end

--- True if a stored order key string refers to the logged-in session (manual sort / pin-to-top).
local function OrderKeyIsSessionChar(orderKey)
    if not orderKey then return false end
    local storage = ns.Utilities.GetCharacterStorageKey and ns.Utilities:GetCharacterStorageKey(WarbandNexus)
    if storage and orderKey == storage then return true end
    local resolved = ResolveSessionCharactersTableKey()
    if resolved and orderKey == resolved then return true end
    if ns.Utilities and ns.Utilities.GetCanonicalCharacterKey then
        local canonOrder = ns.Utilities:GetCanonicalCharacterKey(orderKey)
        if storage and canonOrder == storage then return true end
        if resolved and canonOrder == resolved then return true end
    end
    return false
end

local function GetLayout() return ns.UI_LAYOUT or {} end
local ROW_HEIGHT = GetLayout().rowHeight or 26
local ROW_SPACING = GetLayout().rowSpacing or 28
local HEADER_HEIGHT = GetLayout().HEADER_HEIGHT or 32
local HEADER_SPACING = GetLayout().headerSpacing or 44
local SECTION_SPACING = GetLayout().betweenSections or 8
local BASE_INDENT = GetLayout().BASE_INDENT or 15
local SUBROW_EXTRA_INDENT = GetLayout().SUBROW_EXTRA_INDENT or 10
local SIDE_MARGIN = GetLayout().SIDE_MARGIN or 10
local TOP_MARGIN = GetLayout().TOP_MARGIN or 8
local SECONDARY_PROF_KEYS = { "cooking" }

local function BuildGuildText(char, isCurrentCharacter)
    local guildName = (char and char.guildName) or nil
    if isCurrentCharacter then
        local liveGuild = IsInGuild() and GetGuildInfo("player") or nil
        if liveGuild and not (issecretvalue and issecretvalue(liveGuild)) then
            guildName = liveGuild
        end
    end

    if guildName and not (issecretvalue and issecretvalue(guildName)) and guildName ~= "" then
        return ThemeTextHex("Bright") .. guildName .. "|r"
    end

    return ThemeTextHex("Muted") .. "—|r"
end

---Pending-mail badge next to character name. Inline |T paths are unreliable in modern clients; use a Texture.
local function ApplyPendingMailIconTexture(tex)
    tex:SetTexCoord(0, 1, 0, 1)
    if C_Texture and C_Texture.GetAtlasInfo then
        local atlasTry = { "Mail-Icon", "minimap-tracking-mailbox", "Mailbox-Tracking" }
        for i = 1, #atlasTry do
            local name = atlasTry[i]
            local ok, info = pcall(C_Texture.GetAtlasInfo, name)
            if ok and info then
                tex:SetAtlas(name)
                return
            end
        end
    end
    if C_Texture and C_Texture.GetFileIDFromPath then
        local paths = { "interface/minimap/tracking/mailbox", "Interface/Minimap/Tracking/Mailbox" }
        for i = 1, #paths do
            local p = paths[i]
            local ok, fid = pcall(C_Texture.GetFileIDFromPath, p)
            if ok and fid and type(fid) == "number" and fid > 0 then
                tex:SetTexture(fid)
                return
            end
        end
    end
    tex:SetTexture("Interface/Minimap/Tracking/Mailbox")
end

local function RegisterCharacterEvents(parent)
    -- Register only once per parent
    if parent.characterUpdateHandler then
        return
    end
    parent.characterUpdateHandler = true
    
    -- WN_CHARACTER_UPDATED: REMOVED â€” UI.lua's SchedulePopulateContent already handles
    -- chars tab refresh via PopulateContent â†’ DrawCharacterList. Having both caused double rebuild.
    
    -- WN_CHARACTER_TRACKING_CHANGED refresh is centralized in UI.lua.
end

--- Reposition cached Characters fixedHeader chrome (Collections/Items _fixedHeaderCache parity — WN-PERF tab revisit).
local function RepositionCharactersFixedHeader(mf, hdrCache, headerParent, chrome, headerYOffset, contentSide, scrollChild)
    local titleCard = hdrCache.titleCard
    titleCard:SetParent(headerParent)
    if chrome and ns.UI_AnchorTabTitleCard then
        ns.UI_AnchorTabTitleCard(titleCard, chrome)
    else
        titleCard:ClearAllPoints()
        titleCard:SetPoint("TOPLEFT", contentSide, -headerYOffset)
        titleCard:SetPoint("TOPRIGHT", -contentSide, -headerYOffset)
    end
    titleCard:Show()
    if hdrCache.sortBtn then hdrCache.sortBtn:Show() end
    if titleCard._wnCharCustomSectionBtn then titleCard._wnCharCustomSectionBtn:Show() end
    if ns.UI_HideTitleCardExpandCollapseControls then
        ns.UI_HideTitleCardExpandCollapseControls(scrollChild)
    end
    if ns.UI_AdvanceTabChromeYOffset then
        return ns.UI_AdvanceTabChromeYOffset(headerYOffset, titleCard:GetHeight())
    end
    return headerYOffset + (titleCard:GetHeight() or 64) + 8
end

function WarbandNexus:DrawCharacterList(parent)
    -- Request updated WoW Token market price (async) â€” heavily throttled; not tied to tab redraw frequency.
    if C_WowTokenPublic and C_WowTokenPublic.UpdateMarketPrice then
        local now = GetTime()
        if (now - _wnCharsTabLastTokenReq) >= WN_CHARS_TAB_TOKEN_REQ_INTERVAL then
            _wnCharsTabLastTokenReq = now
            C_WowTokenPublic.UpdateMarketPrice()
        end
    end

    local mf = WarbandNexus.UI and WarbandNexus.UI.mainFrame
    local metrics = ns.UI_GetMainTabLayoutMetrics and ns.UI_GetMainTabLayoutMetrics(mf)
    local width = (metrics and metrics.contentWidth)
        or (ns.UI_ResolveMainTabContentWidth and ns.UI_ResolveMainTabContentWidth(mf, parent))
        or (ns.UI_GetMainScrollContentWidth and mf and ns.UI_GetMainScrollContentWidth(mf))
        or (parent:GetWidth() or 600)

    local chrome = ns.UI_BeginTabChromeLayout and ns.UI_BeginTabChromeLayout(mf)
    local fixedHeader = mf and mf.fixedHeader
    local headerParent = (chrome and chrome.headerParent) or fixedHeader or parent
    local headerYOffset = (chrome and chrome.yOffset) or (metrics and metrics.topMargin) or TOP_MARGIN
    local contentSide = (chrome and chrome.side) or (metrics and metrics.sideMargin) or SIDE_MARGIN
    local sectionGap = (metrics and metrics.sectionGap) or SECTION_SPACING
    
    -- Add DB version badge (for debugging/monitoring)
    if not parent.dbVersionBadge then
        local dataSource = "db.global.characters"
        if self.db.global.characterCache and next(self.db.global.characterCache.characters or {}) then
            local cacheVersion = self.db.global.characterCache.version or "unknown"
            dataSource = "CharacterCache v" .. cacheVersion
        end
        parent.dbVersionBadge = CreateDBVersionBadge(parent, dataSource, "TOPRIGHT", -10, -5)
    end
    
    -- Register event listener (only once)
    RegisterCharacterEvents(parent)
    
    -- Hide empty state card (will be shown again if needed)
    HideEmptyStateCard(parent, "characters")

    -- Paths always reach PopulateContent today; still mirror Storage/Reputation so future callers stay safe.
    local mfForVirtual = WarbandNexus.UI and WarbandNexus.UI.mainFrame
    if mfForVirtual and ns.VirtualListModule and ns.VirtualListModule.ClearVirtualScroll then
        if not parent._preparedByPopulate then
            ns.VirtualListModule.ClearVirtualScroll(mfForVirtual)
        end
    end

    -- Nested character rows: PopulateContent walks subtrees via UI_ReleaseCharacterRowsFromSubtree before recycle.
    
    local currentPlayerKey = ns.Utilities and ns.Utilities.GetCharacterStorageKey and ns.Utilities:GetCharacterStorageKey(WarbandNexus)
    local sessionTableKey = ResolveSessionCharactersTableKey() or currentPlayerKey
    if self.db.global.characters and sessionTableKey and self.db.global.characters[sessionTableKey] then
        local now = GetTime()
        local last = _wnCharsTabLastSeenBump[sessionTableKey] or 0
        if (now - last) >= WN_CHARS_TAB_LAST_SEEN_INTERVAL then
            _wnCharsTabLastSeenBump[sessionTableKey] = now
            self.db.global.characters[sessionTableKey].lastSeen = time()
        end
    end
    
    local characters = self:GetAllCharacters()

    local CHAR_TITLE_RIGHT_RESERVE = (ns.UI_ComputeCharactersTitleToolbarReserve and ns.UI_ComputeCharactersTitleToolbarReserve())
        or 256
    local subtitleLine = (ns.L and ns.L["CHARACTERS_SUBTITLE"])
        or "A scrollable list of your characters with gold, level, gear, and key stats in one place."

    local hdrCache = mf and mf._charsFixedHeaderCache
    local headerDone = false
    local sortBtn

    if hdrCache and hdrCache.titleCard then
        headerYOffset = RepositionCharactersFixedHeader(mf, hdrCache, headerParent, chrome, headerYOffset, contentSide, parent)
        if ns.UI_CommitTabFixedHeader then
            ns.UI_CommitTabFixedHeader(mf, headerYOffset)
        elseif fixedHeader then
            fixedHeader:SetHeight(headerYOffset)
        end
        headerDone = true
    end

    if not headerDone then
    local titleCard, headerIcon, titleTextContainer, titleText, subtitleText = ns.UI_CreateStandardTabTitleCard(headerParent, {
        tabKey = "characters",
        titleText = (ns.L and ns.L["YOUR_CHARACTERS"]) or "Your Characters",
        subtitleText = subtitleLine,
        textRightInset = CHAR_TITLE_RIGHT_RESERVE,
    })
    if chrome and ns.UI_AnchorTabTitleCard then
        ns.UI_AnchorTabTitleCard(titleCard, chrome)
    else
        local contentSide = (metrics and metrics.sideMargin) or SIDE_MARGIN
        titleCard:SetPoint("TOPLEFT", contentSide, -headerYOffset)
        titleCard:SetPoint("TOPRIGHT", -contentSide, -headerYOffset)
    end

    local titleControlInset = (ns.UI_SPACING and ns.UI_SPACING.TITLE_CARD_TOOLBAR_EDGE_INSET) or 0

    -- Sort dropdown anchors at card top-right; expand/collapse sit to its left (no overlap).
    local sortAnchorFrame = titleCard
    local sortAnchorPoint = "RIGHT"
    local sortAnchorX = -titleControlInset

    if ns.UI_CreateCharacterTabAdvancedFilterButton then
        if ns.CharacterService and ns.CharacterService.EnsureCustomCharacterSectionsProfile then
            ns.CharacterService:EnsureCustomCharacterSectionsProfile(self.db.profile)
        end
        local sortOptions = {
            {key = "default", label = (ns.L and ns.L["SORT_MODE_DEFAULT"]) or "Default Order"},
            {key = "manual", label = (ns.L and ns.L["SORT_MODE_MANUAL"]) or "Manual (Custom Order)"},
            {key = "name", label = (ns.L and ns.L["SORT_MODE_NAME"]) or "Name (A-Z)"},
            {key = "level", label = (ns.L and ns.L["SORT_MODE_LEVEL"]) or "Level (Highest)"},
            {key = "ilvl", label = (ns.L and ns.L["SORT_MODE_ILVL"]) or "Item Level (Highest)"},
            {key = "gold", label = (ns.L and ns.L["SORT_MODE_GOLD"]) or "Gold (Highest)"},
            {key = "realm", label = (ns.L and ns.L["SORT_MODE_REALM"]) or "Realm (A-Z)"},
        }
        if not self.db.profile.characterSort then self.db.profile.characterSort = {} end
        if not self.db.profile.characterSectionFilter then self.db.profile.characterSectionFilter = { sectionKey = "all" } end
        sortBtn = ns.UI_CreateCharacterTabAdvancedFilterButton(titleCard, {
            sortOptions = sortOptions,
            dbSortTable = self.db.profile.characterSort,
            dbSectionFilter = self.db.profile.characterSectionFilter,
            getCustomSections = function()
                return self.db.profile.characterCustomGroups or {}
            end,
            onRefresh = function()
                WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
            end,
            onDeleteSection = function(groupId, groupName)
                WarbandNexus:ConfirmDeleteCustomCharacterHeader(groupId, groupName)
            end,
        })
        if sortBtn then
            if ns.UI_AnchorTitleCardToolbarControl then
                ns.UI_AnchorTitleCardToolbarControl(sortBtn, titleCard, titleCard, "RIGHT", -titleControlInset)
            else
                sortBtn:ClearAllPoints()
                sortBtn:SetPoint("RIGHT", titleCard, "RIGHT", -titleControlInset, 0)
            end
            sortBtn:SetFrameLevel(titleCard:GetFrameLevel() + 5)
            -- Match Filter row height (Factory advanced filter uses BUTTON_HEIGHT).
            local sectionToolbarBtnSize = (ns.UI_CONSTANTS and ns.UI_CONSTANTS.BUTTON_HEIGHT) or 32
            -- Visible entry for custom sections: same footprint as Filter, left of it.
            local secQuick = titleCard._wnCharCustomSectionBtn
            if not secQuick and ns.UI and ns.UI.Factory and ns.UI.Factory.CreateButton then
                secQuick = ns.UI.Factory:CreateButton(titleCard, sectionToolbarBtnSize, sectionToolbarBtnSize, false)
                titleCard._wnCharCustomSectionBtn = secQuick
                secQuick:SetFrameLevel(titleCard:GetFrameLevel() + 6)
                if secQuick.RegisterForClicks then
                    secQuick:RegisterForClicks("LeftButtonUp")
                end
                local okAtlas = false
                if secQuick.SetNormalAtlas then
                    okAtlas = pcall(function()
                        secQuick:SetNormalAtlas("GM-icon-assistActive-hover")
                    end)
                end
                if not okAtlas then
                    local tex = (secQuick.GetNormalTexture and secQuick:GetNormalTexture())
                    if tex then
                        tex:SetTexture("Interface\\Icons\\Achievement_Reputation_08")
                        tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
                    end
                end
                secQuick:SetScript("OnClick", function(btn)
                    if ns.UI_ShowCharacterSectionsToolbarMenu then
                        ns.UI_ShowCharacterSectionsToolbarMenu(btn, WarbandNexus.db.profile)
                    elseif WarbandNexus.OpenCustomCharacterHeaderDialog then
                        WarbandNexus:OpenCustomCharacterHeaderDialog()
                    end
                end)
                secQuick:SetScript("OnEnter", function(btn)
                    GameTooltip:SetOwner(btn, "ANCHOR_BOTTOMRIGHT")
                    GameTooltip:SetText((ns.L and ns.L["CUSTOM_HEADER_TITLEBAR_BTN_TOOLTIP"]) or "Custom sections", 1, 1, 1)
                    GameTooltip:AddLine((ns.L and ns.L["CUSTOM_HEADER_TITLEBAR_BTN_TOOLTIP_BODY"]) or "Open the menu: new or delete headers, gold-style section, or use [+] on a header to add characters.", 0.85, 0.85, 0.9, true)
                    GameTooltip:Show()
                end)
                secQuick:SetScript("OnLeave", GameTooltip_Hide)
            elseif secQuick then
                secQuick:SetSize(sectionToolbarBtnSize, sectionToolbarBtnSize)
            end
            if titleCard._wnCharCustomSectionBtn then
                if ns.UI_AnchorTitleCardToolbarControl then
                    ns.UI_AnchorTitleCardToolbarControl(titleCard._wnCharCustomSectionBtn, titleCard, sortBtn, "LEFT", -8)
                else
                    titleCard._wnCharCustomSectionBtn:ClearAllPoints()
                    titleCard._wnCharCustomSectionBtn:SetPoint("RIGHT", sortBtn, "LEFT", -8, 0)
                end
                titleCard._wnCharCustomSectionBtn:Show()
                sortAnchorFrame = titleCard._wnCharCustomSectionBtn
            else
                sortAnchorFrame = sortBtn
            end
            sortAnchorPoint = "LEFT"
            sortAnchorX = -10
        end
    elseif ns.UI_CreateCharacterSortDropdown then
        if titleCard._wnCharCustomSectionBtn then
            titleCard._wnCharCustomSectionBtn:Hide()
        end
        local sortOptions = {
            {key = "default", label = (ns.L and ns.L["SORT_MODE_DEFAULT"]) or "Default Order"},
            {key = "manual", label = (ns.L and ns.L["SORT_MODE_MANUAL"]) or "Manual (Custom Order)"},
            {key = "name", label = (ns.L and ns.L["SORT_MODE_NAME"]) or "Name (A-Z)"},
            {key = "level", label = (ns.L and ns.L["SORT_MODE_LEVEL"]) or "Level (Highest)"},
            {key = "ilvl", label = (ns.L and ns.L["SORT_MODE_ILVL"]) or "Item Level (Highest)"},
            {key = "gold", label = (ns.L and ns.L["SORT_MODE_GOLD"]) or "Gold (Highest)"},
            {key = "realm", label = (ns.L and ns.L["SORT_MODE_REALM"]) or "Realm (A-Z)"},
        }
        if not self.db.profile.characterSort then self.db.profile.characterSort = {} end
        sortBtn = ns.UI_CreateCharacterSortDropdown(titleCard, sortOptions, self.db.profile.characterSort, function()
            WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
        end)
        if ns.UI_AnchorTitleCardToolbarControl then
            ns.UI_AnchorTitleCardToolbarControl(sortBtn, titleCard, titleCard, "RIGHT", -titleControlInset)
        else
            sortBtn:ClearAllPoints()
            sortBtn:SetPoint("RIGHT", titleCard, "RIGHT", -titleControlInset, 0)
        end
        sortBtn:SetFrameLevel(titleCard:GetFrameLevel() + 5)
        sortAnchorFrame = sortBtn
        sortAnchorPoint = "LEFT"
        sortAnchorX = -10
    end

    if ns.UI_HideTitleCardExpandCollapseControls then
        ns.UI_HideTitleCardExpandCollapseControls(parent)
    end

    -- NO TRACKING: Static text, never overflows
    
    titleCard:Show()
    if ns.UI_AdvanceTabChromeYOffset then
        headerYOffset = ns.UI_AdvanceTabChromeYOffset(headerYOffset, titleCard:GetHeight())
    else
        headerYOffset = headerYOffset + (titleCard:GetHeight() or 64) + 8
    end
    if ns.UI_CommitTabFixedHeader then
        ns.UI_CommitTabFixedHeader(mf, headerYOffset)
    elseif fixedHeader then
        fixedHeader:SetHeight(headerYOffset)
    end

    if mf then
        mf._charsFixedHeaderCache = {
            titleCard = titleCard,
            sortBtn = sortBtn,
        }
    end
    end -- not headerDone

    local yOffset = (ns.UI_GetTabScrollContentStartY and ns.UI_GetTabScrollContentStartY()) or 8
    local titleBodyGap = (GetLayout().TAB_TITLE_TO_BODY_GAP) or (ns.UI_LAYOUT and ns.UI_LAYOUT.TAB_TITLE_TO_BODY_GAP) or 6
    yOffset = yOffset + titleBodyGap

    -- Get current character's gold (live bag total for session toon; DB for alts).
    local isTracked = ns.CharacterService and ns.CharacterService:IsCharacterTracked(self)
    local currentCharGold = ns.Utilities:GetLiveCharacterMoneyCopper(0)
    local isLoadingCharacterData = false
    
    -- Check if character data is being loaded (SaveCharacter in progress)
    if isTracked then
        local dbKey = sessionTableKey or currentPlayerKey
        if not dbKey or not self.db.global.characters[dbKey] then
            isLoadingCharacterData = true
        elseif not self.db.global.characters[dbKey].gold then
            isLoadingCharacterData = true
        end
    end
    
    local totalCharGold = 0
    
    for i = 1, #characters do
        local char = characters[i]
        local charGold = ns.Utilities:GetCharTotalCopper(char)
        
        if IsCharLoggedInSession(char) then
            -- Use real-time gold for current character (if tracked)
            totalCharGold = totalCharGold + currentCharGold
        else
            -- Use cached gold for other characters
            totalCharGold = totalCharGold + charGold
        end
    end
    
    local warbandBankGold = ns.Utilities:GetWarbandBankMoney() or 0
    local totalWithWarband = totalCharGold + warbandBankGold
    
    -- Three equal gold summary cards (full scroll width; stack vertically when narrow).
    local leftMargin = contentSide
    local rightMargin = contentSide
    local cardSpacing = 10
    local hostW = (metrics and metrics.contentWidth)
        or (mf and ns.UI_GetMainTabViewportWidth and ns.UI_GetMainTabViewportWidth(mf))
        or (parent.GetWidth and parent:GetWidth())
        or width
        or 600
    local gm = ComputeCharactersGoldCardMetrics(hostW, leftMargin, rightMargin, cardSpacing)
    local stackGoldCards = gm.stackGoldCards
    local stackGoldTokenInCard3 = gm.stackGoldTokenInCard3
    local goldCardW1 = gm.goldCardW1
    local goldCardW2 = gm.goldCardW2
    local goldCardW3 = gm.goldCardW3
    local card12H = gm.card12H
    local card3H = gm.card3H

    local CreateCardHeaderLayout = ns.UI_CreateCardHeaderLayout
    local GetCharacterSpecificIcon = ns.UI_GetCharacterSpecificIcon

    local goldDisplayText = isLoadingCharacterData
        and (ThemeTextHex("Dim") .. ((ns.L and ns.L["LOADING"]) or "Loading...") .. "|r")
        or FormatMoney(currentCharGold, 14)
    local charIconTex = GetCharacterSpecificIcon()

    -- PERSISTENT CARD TRIO: the gold summary repaints on every CHARACTER_UPDATED
    -- (gold ticks), and recreating three cards + children each time was the hottest
    -- chrome-churn path in the addon. The bundle (already maintained for the viewport
    -- adapter) now also carries the frames; teardown parks them in the recycle bin
    -- and we re-adopt here. A character-icon change (rare) rebuilds from scratch.
    local bundle = parent._wnCharsGoldBundle
    if bundle and bundle.charIconTex ~= charIconTex then
        local bin = ns.UI_RecycleBin
        for _, f in ipairs({ bundle.char, bundle.wb, bundle.total }) do
            if f then f:Hide(); f:ClearAllPoints(); if bin then f:SetParent(bin) end end
        end
        bundle = nil
        parent._wnCharsGoldBundle = nil
    end

    local charGoldCard, wbGoldCard, totalGoldCard

    if bundle then
        -- REUSE: re-adopt and update texts only.
        charGoldCard, wbGoldCard, totalGoldCard = bundle.char, bundle.wb, bundle.total
        charGoldCard:SetParent(parent)
        wbGoldCard:SetParent(parent)
        totalGoldCard:SetParent(parent)
        if bundle.cg1Value then bundle.cg1Value:SetText(goldDisplayText); PresentFS(bundle.cg1Value) end
        if bundle.wb1Value then bundle.wb1Value:SetText(FormatMoney(warbandBankGold, 14)); PresentFS(bundle.wb1Value) end
        if bundle.tgValue then bundle.tgValue:SetText(FormatMoney(totalWithWarband, 14)); PresentFS(bundle.tgValue) end
        -- Spinner lifecycle matters now that the card persists across paints.
        if isLoadingCharacterData and bundle.cg1Value then
            if charGoldCard.loadingSpinner then
                charGoldCard.loadingSpinner:Show()
            elseif ns.UI_CreateInlineLoadingSpinner then
                charGoldCard.loadingSpinner = ns.UI_CreateInlineLoadingSpinner(
                    charGoldCard, bundle.cg1Value, "LEFT", bundle.cg1Value:GetStringWidth() + 4, 0, 16)
            end
        elseif charGoldCard.loadingSpinner then
            charGoldCard.loadingSpinner:Hide()
        end
        bundle.yOffset = yOffset
        bundle.leftMargin = leftMargin
        bundle.rightMargin = rightMargin
        bundle.cardSpacing = cardSpacing
        bundle._gm = gm
    else

    -- Characters Gold Card (left)
    charGoldCard = CreateCard(parent, card12H)

    local cg1Layout = CreateCardHeaderLayout(
        charGoldCard,
        charIconTex,
        40,
        true,
        (ns.L and ns.L["HEADER_CURRENT_CHARACTER"]) or "CURRENT CHARACTER",
        goldDisplayText,
        "subtitle",
        "body"
    )

    -- Add inline loading spinner if data is being collected (Factory pattern)
    if isLoadingCharacterData and cg1Layout.value then
        local UI_CreateInlineLoadingSpinner = ns.UI_CreateInlineLoadingSpinner
        if UI_CreateInlineLoadingSpinner then
            charGoldCard.loadingSpinner = UI_CreateInlineLoadingSpinner(
                charGoldCard,
                cg1Layout.value,
                "LEFT",
                cg1Layout.value:GetStringWidth() + 4,
                0,
                16
            )
        end
    end

    -- NO TRACKING: Numbers rarely overflow (formatted gold)

    -- Warband Gold Card (middle)
    wbGoldCard = CreateCard(parent, card12H)
    
    -- Use factory for standardized card header layout
    local wb1Layout = CreateCardHeaderLayout(
        wbGoldCard,
        "warbands-icon",
        40,
        true,
        (ns.L and ns.L["HEADER_WARBAND_GOLD"]) or "WARBAND GOLD",
        FormatMoney(warbandBankGold, 14),
        "subtitle",
        "body"
    )
    
    -- NO TRACKING: Numbers rarely overflow (formatted gold)

    -- Total Gold + Token Card (right; same width as siblings in row mode)
    totalGoldCard = CreateCard(parent, card3H)

    -- Vertical divider (hidden when Total Gold + Token are stacked)
    local divider = totalGoldCard:CreateTexture(nil, "ARTWORK")
    divider:SetSize(1, 50)
    local divBorder = ns.UI_GetBorderStrokeColor and ns.UI_GetBorderStrokeColor() or COLORS.border
    divider:SetColorTexture(divBorder[1], divBorder[2], divBorder[3], 0.45)

    -- Left block: Total Gold (icon + label + value)
    local tgIcon = CreateIcon(totalGoldCard, "BonusLoot-Chest", 36, true, nil, true)
    tgIcon:Show()

    local tgTextContainer = (ns.UI.Factory and ns.UI.Factory:CreateContainer(totalGoldCard, 220, 64, false))
        or CreateFrame("Frame", nil, totalGoldCard)

    local tgLabel = FontManager:CreateFontString(tgTextContainer, "subtitle", "OVERLAY")
    tgLabel:SetText((ns.L and ns.L["HEADER_TOTAL_GOLD"]) or "TOTAL GOLD")
    ns.UI_SetTextColorRole(tgLabel, "Bright")
    tgLabel:SetJustifyH("LEFT")
    tgLabel:SetPoint("BOTTOM", tgTextContainer, "CENTER", 0, 0)
    tgLabel:SetPoint("LEFT", tgTextContainer, "LEFT", 0, 0)

    local tgValue = FontManager:CreateFontString(tgTextContainer, "body", "OVERLAY")
    tgValue:SetText(FormatMoney(totalWithWarband, 14))  -- updated in the reuse branch on later paints
    tgValue:SetJustifyH("LEFT")
    tgValue:SetPoint("TOP", tgTextContainer, "CENTER", 0, -4)
    tgValue:SetPoint("LEFT", tgTextContainer, "LEFT", 0, 0)
    -- RIGHT clamp: without it, large gold strings grew past the container and
    -- overlapped the divider/token block at intermediate card widths.
    tgValue:SetPoint("RIGHT", tgTextContainer, "RIGHT", 0, 0)
    tgValue:SetWordWrap(false)
    tgValue:SetNonSpaceWrap(false)

    -- Right block: WoW Token (icon + label + price with token count)
    local tkIcon = totalGoldCard:CreateTexture(nil, "ARTWORK")
    tkIcon:SetSize(28, 28)
    tkIcon:SetTexture("Interface\\Icons\\WoW_Token01")
    tkIcon:Show()

    local tkTextContainer = (ns.UI.Factory and ns.UI.Factory:CreateContainer(totalGoldCard, 220, 64, false))
        or CreateFrame("Frame", nil, totalGoldCard)

    local tkLabel = FontManager:CreateFontString(tkTextContainer, "subtitle", "OVERLAY")
    tkLabel:SetText((ns.L and ns.L["WOW_TOKEN_LABEL"]) or "WOW TOKEN")
    ns.UI_SetTextColorRole(tkLabel, "Bright")
    tkLabel:SetJustifyH("LEFT")
    tkLabel:SetPoint("BOTTOM", tkTextContainer, "CENTER", 0, 0)
    tkLabel:SetPoint("LEFT", tkTextContainer, "LEFT", 0, 0)

    local tkValue = FontManager:CreateFontString(tkTextContainer, "body", "OVERLAY")
    tkValue:SetJustifyH("LEFT")
    tkValue:SetPoint("TOP", tkTextContainer, "CENTER", 0, -4)
    tkValue:SetPoint("LEFT", tkTextContainer, "LEFT", 0, 0)
    tkValue:SetPoint("RIGHT", tkTextContainer, "RIGHT", 0, 0)
    tkValue:SetWordWrap(false)
    tkValue:SetNonSpaceWrap(false)

    parent._wnCharsGoldBundle = {
        parent = parent,
        char = charGoldCard,
        wb = wbGoldCard,
        total = totalGoldCard,
        yOffset = yOffset,
        leftMargin = leftMargin,
        rightMargin = rightMargin,
        cardSpacing = cardSpacing,
        divider = divider,
        tgIcon = tgIcon,
        tgTextContainer = tgTextContainer,
        tkIcon = tkIcon,
        tkTextContainer = tkTextContainer,
        midRule = nil,
        _gm = gm,
        -- Reuse-path references (see the persistent-trio branch above)
        charIconTex = charIconTex,
        cg1Value = cg1Layout.value,
        wb1Value = wb1Layout.value,
        tgValue = tgValue,
        tkValue = tkValue,
    }
    end -- persistent card trio: create/reuse split

    bundle = parent._wnCharsGoldBundle

    -- COMMON LAYOUT (runs for both fresh and reused cards): metrics can change
    -- between paints (resize, stack-mode flip), so size and anchor every time.
    charGoldCard:SetSize(goldCardW1, card12H)
    charGoldCard:ClearAllPoints()
    charGoldCard:SetPoint("TOPLEFT", parent, "TOPLEFT", leftMargin, -yOffset)
    wbGoldCard:SetSize(goldCardW2, card12H)
    wbGoldCard:ClearAllPoints()
    if stackGoldCards then
        wbGoldCard:SetPoint("TOPLEFT", charGoldCard, "BOTTOMLEFT", 0, -cardSpacing)
    else
        wbGoldCard:SetPoint("TOPLEFT", charGoldCard, "TOPRIGHT", cardSpacing, 0)
    end
    totalGoldCard:SetSize(goldCardW3, card3H)
    totalGoldCard:ClearAllPoints()
    if stackGoldCards then
        totalGoldCard:SetPoint("TOPLEFT", wbGoldCard, "BOTTOMLEFT", 0, -cardSpacing)
    else
        totalGoldCard:SetPoint("TOPLEFT", wbGoldCard, "TOPRIGHT", cardSpacing, 0)
    end

    ApplyTotalGoldCardInterior(bundle, stackGoldTokenInCard3)

    -- GetCurrentMarketPrice updates asynchronously after UpdateMarketPrice();
    -- TOKEN_MARKET_PRICE_UPDATED refreshes the tab (Core.lua).
    local tokenPrice = C_WowTokenPublic and C_WowTokenPublic.GetCurrentMarketPrice and select(1, C_WowTokenPublic.GetCurrentMarketPrice())
    local tkValueRef = bundle.tkValue
    if tokenPrice and tokenPrice > 0 then
        local affordableCount = math.floor(totalWithWarband / tokenPrice)
        tkValueRef:SetText(
            FormatMoney(tokenPrice, 12)
                .. "  |cff66c0ff("
                .. affordableCount
                .. " "
                .. (((ns.L and ns.L["WOW_TOKEN_COUNT_LABEL"]) or "Tokens"))
                .. ")|r"
        )
        PresentFS(tkValueRef)
    else
        tkValueRef:SetText(ThemeTextHex("Dim") .. ((ns.L and ns.L["NOT_AVAILABLE_SHORT"]) or "N/A") .. "|r")
        PresentFS(tkValueRef)
        -- One-shot retry if price not ready yet (event-driven path is primary).
        if C_WowTokenPublic and C_WowTokenPublic.UpdateMarketPrice then
            C_Timer.After(1.25, function()
                local addon = _G.WarbandNexus
                if not addon or not addon.UI or not addon.UI.mainFrame then return end
                local mf = addon.UI.mainFrame
                if not mf:IsShown() or mf.currentTab ~= "chars" then return end
                local p = select(1, C_WowTokenPublic.GetCurrentMarketPrice and C_WowTokenPublic.GetCurrentMarketPrice() or 0)
                if p and p > 0 and addon.SendMessage then
                    addon:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { tab = "chars", skipCooldown = true })
                end
            end)
        end
    end

    charGoldCard:Show()
    wbGoldCard:Show()
    totalGoldCard:Show()

    if stackGoldCards then
        yOffset = yOffset + card12H + cardSpacing + card12H + cardSpacing + card3H + 8
    else
        yOffset = yOffset + math.max(card12H, card3H) + 10
    end

    if not self.db.profile.ui then self.db.profile.ui = {} end
    local staleCopies = (ns.CharacterService and ns.CharacterService.FindProbableStaleCharacterCopies)
        and ns.CharacterService:FindProbableStaleCharacterCopies(self) or {}
    if #staleCopies > 0 and not self.db.profile.ui.dismissStaleCharacterCopiesBanner then
        local bannerH = 52
        -- CreateCard(parent, height): the old 4-arg call passed contentSide (~12px)
        -- as the height, squashing the stale-copies banner to a sliver.
        local banner = CreateCard(parent, bannerH)
        banner:ClearAllPoints()
        banner:SetPoint("TOPLEFT", parent, "TOPLEFT", contentSide, -yOffset)
        banner:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -contentSide, -yOffset)
        local goldC = COLORS.gold or { 1, 0.82, 0, 1 }
        local bannerBg = (ns.UI_ResolveSurfaceTierColor and ns.UI_ResolveSurfaceTierColor("card"))
            or COLORS.bgCard or COLORS.bgLight or COLORS.bg
        if ApplyVisuals then
            ApplyVisuals(banner, bannerBg, { goldC[1], goldC[2], goldC[3], 0.82 })
        end
        local bannerTitle = FontManager:CreateFontString(banner, "header", "OVERLAY")
        bannerTitle:SetPoint("TOPLEFT", 12, -8)
        bannerTitle:SetPoint("RIGHT", banner, "RIGHT", -90, 0)
        bannerTitle:SetJustifyH("LEFT")
        bannerTitle:SetText(SemanticGoldHex() .. ((ns.L and ns.L["STALE_CHARACTER_COPIES_TITLE"]) or "Duplicate character entries") .. "|r")
        local bannerBody = FontManager:CreateFontString(banner, "body", "OVERLAY")
        bannerBody:SetPoint("TOPLEFT", bannerTitle, "BOTTOMLEFT", 0, -2)
        bannerBody:SetPoint("RIGHT", banner, "RIGHT", -90, 0)
        bannerBody:SetJustifyH("LEFT")
        bannerBody:SetWordWrap(true)
        bannerBody:SetMaxLines(2)
        ns.UI_SetTextColorRole(bannerBody, "Normal")
        bannerBody:SetText((ns.L and ns.L["STALE_CHARACTER_COPIES_DESC"]) or "Delete old copies or use the tracking icon.")
        local dismissBtn = ns.UI.Factory:CreateButton(banner, 72, 24, false)
        dismissBtn:SetPoint("RIGHT", banner, "RIGHT", -8, 0)
        local dismissBg = (ns.UI_GetControlChromeBackdrop and ns.UI_GetControlChromeBackdrop())
            or COLORS.bgCard or COLORS.bgLight or COLORS.bg
        local dismissBr = COLORS.accent or { 0.4, 0.2, 0.58 }
        if ApplyVisuals then
            ApplyVisuals(dismissBtn, dismissBg, { dismissBr[1], dismissBr[2], dismissBr[3], 0.65 })
        end
        local dismissLabel = FontManager:CreateFontString(dismissBtn, "body", "OVERLAY")
        dismissLabel:SetPoint("CENTER")
        dismissLabel:SetText((ns.L and ns.L["STALE_CHARACTER_COPIES_DISMISS"]) or "Dismiss")
        dismissBtn:SetScript("OnClick", function()
            self.db.profile.ui.dismissStaleCharacterCopiesBanner = true
            WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { tab = "chars", skipCooldown = true })
        end)
        yOffset = yOffset + bannerH + 8
    end
    
    local sortOptions = {
        {key = "default", label = (ns.L and ns.L["SORT_MODE_DEFAULT"]) or "Default Order"},
        {key = "manual", label = (ns.L and ns.L["SORT_MODE_MANUAL"]) or "Manual (Custom Order)"},
        {key = "name", label = (ns.L and ns.L["SORT_MODE_NAME"]) or "Name (A-Z)"},
        {key = "level", label = (ns.L and ns.L["SORT_MODE_LEVEL"]) or "Level (Highest)"},
        {key = "ilvl", label = (ns.L and ns.L["SORT_MODE_ILVL"]) or "Item Level (Highest)"},
        {key = "gold", label = (ns.L and ns.L["SORT_MODE_GOLD"]) or "Gold (Highest)"},
        {key = "realm", label = (ns.L and ns.L["SORT_MODE_REALM"]) or "Realm (A-Z)"},
    }
    
    if not self.db.profile.characterSort then self.db.profile.characterSort = {} end
    local sk = self.db.profile.characterSort.key
    local currentSortKey = (type(sk) == "string" and sk ~= "" and
        (sk == "default" or sk == "manual" or sk == "name" or sk == "level" or sk == "ilvl" or sk == "gold" or sk == "realm"))
        and sk or "default"

    local sectionFilter = "all"
    if self.db.profile.characterSectionFilter and type(self.db.profile.characterSectionFilter.sectionKey) == "string" then
        sectionFilter = self.db.profile.characterSectionFilter.sectionKey
    end
    if ns.CharacterService and ns.CharacterService.EnsureCustomCharacterSectionsProfile then
        ns.CharacterService:EnsureCustomCharacterSectionsProfile(self.db.profile)
    end

    local favoriteKeySet = (ns.CharacterService and ns.CharacterService.BuildFavoriteKeySet)
        and ns.CharacterService:BuildFavoriteKeySet(self) or {}
    
    local trackedFavorites = {}
    local trackedRegular = {}
    local untracked = {}
    
    for i = 1, #characters do
        local char = characters[i]
        local charKey = GetCharKey(char)
        local isTracked = char.isTracked ~= false  -- Default to true if not set
        
        -- Separate by tracking status first, then favorites
        if not isTracked then
            tinsert(untracked, char)
        elseif ns.CharacterService then
            local isFav = (ns.CharacterService.IsFavoriteFromKeySet and ns.CharacterService:IsFavoriteFromKeySet(favoriteKeySet, charKey))
                or ns.CharacterService:IsFavoriteCharacter(self, charKey)
            if isFav then
                tinsert(trackedFavorites, char)
            else
                tinsert(trackedRegular, char)
            end
        end
    end

    -- Split non-favorites into custom header buckets + ungrouped (must run after trackedRegular is built).
    local customGroupsOrdered = (ns.CharacterService and ns.CharacterService.BuildOrderedCustomCharacterGroups)
        and ns.CharacterService:BuildOrderedCustomCharacterGroups(self.db.profile, currentSortKey)
        or (self.db.profile.characterCustomGroups or {})
    local assignments = self.db.profile.characterGroupAssignments or {}
    local groupedById = {}
    for gi = 1, #customGroupsOrdered do
        groupedById[customGroupsOrdered[gi].id] = {}
    end
    local trackedRegularUngrouped = {}
    for ri = 1, #trackedRegular do
        local char = trackedRegular[ri]
        local ck = GetCharKey(char)
        local gid = nil
        if ck and ns.CharacterService and ns.CharacterService.GetCharacterCustomSectionId then
            gid = ns.CharacterService:GetCharacterCustomSectionId(self, ck)
        elseif ck then
            gid = assignments[ck]
        end
        if gid and groupedById[gid] then
            tinsert(groupedById[gid], char)
        else
            tinsert(trackedRegularUngrouped, char)
        end
    end
    trackedRegular = trackedRegularUngrouped
    
    -- Load custom order from profile
    if not self.db.profile.characterOrder then
        self.db.profile.characterOrder = {
            favorites = {},
            regular = {},
            untracked = {}
        }
    end
    
    -- Sort function (with custom order support)
    local function sortCharacters(list, orderKey)
        local sortMode = currentSortKey

        if sortMode ~= "manual" then
            if sortMode == "default" then
                -- Standard: logged-in character first, then level (high to low), then name (A-Z, case-insensitive).
                table.sort(list, function(a, b)
                    local aOn = IsCharLoggedInSession(a)
                    local bOn = IsCharLoggedInSession(b)
                    if aOn ~= bOn then
                        return aOn
                    end
                    if (a.level or 0) ~= (b.level or 0) then
                        return (a.level or 0) > (b.level or 0)
                    end
                    return CompareCharNameLower(a, b)
                end)
                return list
            end
            table.sort(list, function(a, b)
                if sortMode == "name" then
                    return CompareCharNameLower(a, b)
                elseif sortMode == "level" then
                    if (a.level or 0) ~= (b.level or 0) then
                        return (a.level or 0) > (b.level or 0)
                    else
                        return CompareCharNameLower(a, b)
                    end
                elseif sortMode == "ilvl" then
                    if (a.itemLevel or 0) ~= (b.itemLevel or 0) then
                        return (a.itemLevel or 0) > (b.itemLevel or 0)
                    else
                        return CompareCharNameLower(a, b)
                    end
                elseif sortMode == "gold" then
                    local goldA = ns.Utilities:GetCharTotalCopper(a)
                    local goldB = ns.Utilities:GetCharTotalCopper(b)
                    if goldA ~= goldB then
                        return goldA > goldB
                    else
                        return CompareCharNameLower(a, b)
                    end
                elseif sortMode == "realm" then
                    local ra = SafeLower(a.realm or "")
                    local rb = SafeLower(b.realm or "")
                    if ra ~= rb then
                        return ra < rb
                    else
                        return CompareCharNameLower(a, b)
                    end
                end
                -- Fallback
                if (a.level or 0) ~= (b.level or 0) then
                    return (a.level or 0) > (b.level or 0)
                else
                    return CompareCharNameLower(a, b)
                end
            end)
            return list
        end
        
        local customOrder = self.db.profile.characterOrder[orderKey] or {}
        
        -- If custom order exists and has items, use it
        if #customOrder > 0 then
            local ordered = {}
            local charMap = {}
            
            -- Create a map for quick lookup
            for i = 1, #list do
                local char = list[i]
                local key = GetCharKey(char)
                charMap[key] = char
            end
            
            -- Add characters in custom order
            for i = 1, #customOrder do
                local charKey = customOrder[i]
                if charMap[charKey] then
                    tinsert(ordered, charMap[charKey])
                    charMap[charKey] = nil  -- Remove to track remaining
                end
            end
            
            -- Add any new characters not in custom order (at the end, sorted)
            local remaining = {}
            for _, char in pairs(charMap) do
                tinsert(remaining, char)
            end
            table.sort(remaining, function(a, b)
                if (a.level or 0) ~= (b.level or 0) then
                    return (a.level or 0) > (b.level or 0)
                else
                    return CompareCharNameLower(a, b)
                end
            end)
            for i = 1, #remaining do
                local char = remaining[i]
                tinsert(ordered, char)
            end
            
            return ordered
        else
            -- Default sort: level desc Ã¢â€ â€™ name asc (ignore table header sorting for now)
            table.sort(list, function(a, b)
                if (a.level or 0) ~= (b.level or 0) then
                    return (a.level or 0) > (b.level or 0)
                else
                    return CompareCharNameLower(a, b)
                end
            end)
            return list
        end
    end
    
    -- Sort favorites, each custom header bucket, ungrouped regular, then untracked
    trackedFavorites = sortCharacters(trackedFavorites, "favorites")
    for gi = 1, #customGroupsOrdered do
        local gid = customGroupsOrdered[gi].id
        local list = groupedById[gid]
        if list then
            local lk = (ns.CharacterService and ns.CharacterService.GetCustomGroupListKey and ns.CharacterService:GetCustomGroupListKey(gid)) or ("group_" .. tostring(gid))
            sortCharacters(list, lk)
        end
    end
    trackedRegular = sortCharacters(trackedRegular, "regular")
    untracked = sortCharacters(untracked, "untracked")

    -- Logged-in character always first within each section (any sort mode / manual custom order).
    local function pinOnlineCharacterFirst(list)
        if not list or #list == 0 then return end
        for i = 1, #list do
            if IsCharLoggedInSession(list[i]) then
                if i > 1 then
                    local c = tremove(list, i)
                    tinsert(list, 1, c)
                end
                break
            end
        end
    end
    pinOnlineCharacterFirst(trackedFavorites)
    for gi = 1, #customGroupsOrdered do
        local gid = customGroupsOrdered[gi].id
        if groupedById[gid] then pinOnlineCharacterFirst(groupedById[gid]) end
    end
    pinOnlineCharacterFirst(trackedRegular)
    pinOnlineCharacterFirst(untracked)
    
    -- Guild column width: max guild name width across all visible characters (centered text)
    do
        if not parent.guildMeasureFs then
            parent.guildMeasureFs = FontManager:CreateFontString(parent, "body", "OVERLAY")
            parent.guildMeasureFs:Hide()
        end
        local fs = parent.guildMeasureFs
        local maxW = 0
        local GUILD_PADDING = 20
        local GUILD_MIN = 60
        local GUILD_MAX = 280
        local rawCap = GUILD_MAX - GUILD_PADDING
        local listsToMeasure = { trackedFavorites, trackedRegular, untracked }
        for gmi = 1, #customGroupsOrdered do
            local gmid = customGroupsOrdered[gmi].id
            if groupedById[gmid] then tinsert(listsToMeasure, groupedById[gmid]) end
        end
        for li = 1, #listsToMeasure do
            local list = listsToMeasure[li]
            for i = 1, #list do
                local char = list[i]
                local isCurrent = IsCharLoggedInSession(char)
                fs:SetText(BuildGuildText(char, isCurrent))
                local w = fs:GetStringWidth()
                if w > maxW then maxW = w end
                if maxW >= rawCap then
                    break
                end
            end
            if maxW >= rawCap then
                break
            end
        end
        self._charListMaxGuildWidth = math.min(math.max(maxW + GUILD_PADDING, GUILD_MIN), GUILD_MAX)
    end

    local rowDrawWidth = width
    if ns.UI_ResolveCharactersTabRowWidth then
        rowDrawWidth = ns.UI_ResolveCharactersTabRowWidth(mf, parent, metrics, self._charListMaxGuildWidth)
    end
    local sectionStackW = math.max(1, rowDrawWidth or width or 1)
    parent._charsSectionStackW = sectionStackW
    if mf and ns.UI_ComputeCharactersMinScrollWidth then
        mf._charsMinScrollWidth = ns.UI_ComputeCharactersMinScrollWidth(self, self._charListMaxGuildWidth)
    end
    if ns.UI_ComputeCharRowGuildColumnWidth then
        self._charsListStableGuildColW = ns.UI_ComputeCharRowGuildColumnWidth(rowDrawWidth, self._charListMaxGuildWidth)
    else
        self._charsListStableGuildColW = self._charListMaxGuildWidth
    end
    
    if #characters == 0 then
        local _, height = CreateEmptyStateCard(parent, "characters", yOffset)
        return yOffset + height
    end
    
    -- Initialize collapse state (persistent)
    if not self.db.profile.ui then
        self.db.profile.ui = {}
    end
    if self.db.profile.ui.favoritesExpanded == nil then
        self.db.profile.ui.favoritesExpanded = true
    end
    if self.db.profile.ui.charactersExpanded == nil then
        self.db.profile.ui.charactersExpanded = true
    end
    
    local SECTION_H = (GetLayout().SECTION_COLLAPSE_HEADER_HEIGHT) or 36
    local SECTION_HEADER_GAP = GetLayout().SECTION_STACK_GAP_UNDER_HEADER or sectionGap or 12
    local SECTION_HEADER_TEXT_INSET = 12
    local previousSectionContent = nil
    local isFirstSection = true

    local function AcquireSectionContentFrame(anchorHeader)
        local contentFrame = nil
        local hostW = math.max(1, sectionStackW or width or (parent.GetWidth and parent:GetWidth()) or 1)
        if ns.UI and ns.UI.Factory and ns.UI.Factory.CreateContainer then
            -- Parent = section header so virtual-scroll offset walks header -> scrollChild correctly.
            contentFrame = ns.UI.Factory:CreateContainer(anchorHeader, hostW, 1, false)
        else
            contentFrame = CreateFrame("Frame", nil, anchorHeader)
            contentFrame:SetSize(hostW, 1)
        end

        contentFrame:ClearAllPoints()
        contentFrame:SetPoint("TOPLEFT", anchorHeader, "BOTTOMLEFT", 0, 0)
        contentFrame:SetPoint("TOPRIGHT", anchorHeader, "BOTTOMRIGHT", 0, 0)
        contentFrame:SetHeight(0.1)
        contentFrame._wnSectionFullH = 0
        contentFrame._wnVirtualContentHeight = nil
        return contentFrame
    end

    -- Leaf rows only are virtualized; headers/cards stay real.
    -- Row stripes (ApplyRowBackground index): still **per-section** 1..n â€” not one merged index across Favorites + Characters + Untracked.
    local function DrawCharactersIntoSection(contentFrame, list, isFavorite, listKey, emptyMessage)
        contentFrame._wnVirtualContentHeight = nil
        local sectionYOffset = 0
        if #list > 0 then
            local mf = WarbandNexus.UI and WarbandNexus.UI.mainFrame
            local VLM = ns.VirtualListModule
            local betweenRows = GetLayout().betweenRows or 0
            local stride = 46 + betweenRows
            local showReorder = currentSortKey == "manual"

            if mf and mf.scroll and contentFrame and VLM and VLM.SetupVirtualList then
                local n = #list
                local flatList = {}
                local rowY = 0
                for i = 1, n do
                    local char = list[i]
                    local charKey = GetCharKey(char)
                    -- VirtualList incremental reuse keys only on charKey; bust sig when keystone/gold/ilvl change.
                    local mk = char and char.mythicKey
                    local mkSig = (mk and tonumber(mk.level) and tonumber(mk.level) > 0)
                        and (tostring(mk.level) .. ":" .. tostring(mk.mapID or mk.dungeonID or ""))
                        or "0"
                    local rowSig = table.concat({
                        listKey or "section",
                        charKey or tostring(i),
                        "mk" .. mkSig,
                        "g" .. tostring(char and char.gold or 0),
                        "il" .. tostring(char and char.itemLevel or 0),
                    }, ":")
                    flatList[i] = {
                        type = "row",
                        yOffset = rowY,
                        height = stride,
                        xOffset = 0,
                        rowReuseSig = rowSig,
                        populateEntry = {
                            char = char,
                            index = i,
                            rowWidth = rowDrawWidth,
                            isFavorite = isFavorite,
                            showReorder = showReorder,
                            charList = list,
                            listKey = listKey,
                            positionInList = i,
                            totalInList = n,
                            currentPlayerKey = currentPlayerKey,
                        },
                    }
                    rowY = rowY + stride
                end

                local scrollChildForOff = (mf.scroll.GetScrollChild and mf.scroll:GetScrollChild()) or mf.scrollChild

                -- Offset remeasured every scroll paint (anchor walk); avoid stale GetTop snap at setup.
                local totalHeight = VLM.SetupVirtualList(mf, contentFrame, nil, flatList, {
                    createRowFn = function(container, _it, _idx)
                        return AcquireCharacterRow(container)
                    end,
                    populateRowFn = function(row, it, _idx)
                        local pe = it.populateEntry
                        local container = row:GetParent()
                        pcall(function()
                            WarbandNexus:DrawCharacterRow(container, pe.char, pe.index, pe.rowWidth, 0,
                                pe.isFavorite, pe.showReorder, pe.charList, pe.listKey,
                                pe.positionInList, pe.totalInList, pe.currentPlayerKey, row)
                        end)
                    end,
                    layoutRowFn = function(row, it, _idx)
                        local pe = it.populateEntry
                        if pe and pe.rowWidth then
                            ApplyCharacterRowInteriorLayout(row, pe.rowWidth)
                        end
                    end,
                    resizeLayoutOnly = true,
                    -- Required for resizeLayoutOnly to actually engage: without sig-based
                    -- reuse, frameReused is never true and every paint re-runs the full
                    -- DrawCharacterRow per visible row (rowReuseSig is set on each entry).
                    incrementalRowReuse = true,
                    fixedRowWidthFromEntry = true,
                    rowSpanContainerWidth = true,
                    rowPaintRightPad = 0,
                    releaseRowFn = ReleaseCharacterRow,
                })
                contentFrame._wnVirtualContentHeight = totalHeight
                sectionYOffset = totalHeight
                contentFrame:SetHeight(math.max(0.1, totalHeight))
                if scrollChildForOff and VLM.RefreshContainerTopOffsetCache then
                    VLM.RefreshContainerTopOffsetCache(contentFrame, scrollChildForOff, contentTopOff)
                end
            else
                local yAcc = 0
                for i = 1, #list do
                    local char = list[i]
                    local ok, nextYOffset = pcall(
                        self.DrawCharacterRow,
                        self,
                        contentFrame,
                        char,
                        i,
                        rowDrawWidth,
                        yAcc,
                        isFavorite,
                        showReorder,
                        list,
                        listKey,
                        i,
                        #list,
                        currentPlayerKey
                    )
                    if ok and nextYOffset then
                        yAcc = nextYOffset
                    else
                        yAcc = yAcc + (ROW_HEIGHT or 36)
                    end
                end
                sectionYOffset = yAcc
                contentFrame._wnSectionFullH = sectionYOffset
                if sectionYOffset > 0 then
                    contentFrame:SetHeight(sectionYOffset)
                else
                    contentFrame:SetHeight(0.1)
                end
            end
        elseif emptyMessage and emptyMessage ~= "" then
            local emptyText = FontManager:CreateFontString(contentFrame, "body", "OVERLAY")
            emptyText:SetPoint("TOP", contentFrame, "TOP", 0, -20)
            emptyText:SetText(ThemeTextHex("Muted") .. emptyMessage .. "|r")
            emptyText:SetWidth(math.max(200, (sectionStackW or width or 400) - 40))
            emptyText:SetJustifyH("CENTER")
            sectionYOffset = sectionYOffset + 50
            contentFrame:SetHeight(math.max(0.1, sectionYOffset))
        end

        contentFrame._wnSectionFullH = math.max(0.1, sectionYOffset)
        return sectionYOffset
    end

    local function AnchorSectionHeader(headerFrame)
        headerFrame:SetHeight(SECTION_H)
        if headerFrame.SetWidth then
            headerFrame:SetWidth(math.max(1, sectionStackW))
        end
        if isFirstSection then
            headerFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", contentSide, -yOffset)
            isFirstSection = false
        elseif previousSectionContent then
            headerFrame:SetPoint("TOPLEFT", previousSectionContent, "BOTTOMLEFT", 0, -SECTION_HEADER_GAP)
        else
            headerFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", contentSide, -yOffset)
        end
    end

    local drawFavorites = (sectionFilter == "all") or (sectionFilter == "favorites")
    local drawRegular = (sectionFilter == "all") or (sectionFilter == "regular")
    local drawUntracked = (sectionFilter == "untracked") or (sectionFilter == "all" and #untracked > 0)

    -- Section stack (when filter is "all"): Favorites -> user-defined custom headers (gold favorites first, then name/sort) -> Characters -> inactive last.

    -- Favorites
    if drawFavorites then
        local favoritesExpanded = self.db.profile.ui.favoritesExpanded
        local favoritesContent
        local favoritesVisualOpts = BuildCollapsibleSectionOpts({
            bodyGetter = function() return favoritesContent end,
            updateVisibleFn = CharactersVirtualScrollBump,
        }) or {
            animatedContent = function() return favoritesContent end,
        }
        favoritesVisualOpts.sectionPreset = "gold"
        favoritesVisualOpts.useFullParentWidth = true
        favoritesVisualOpts.sectionStackWidth = sectionStackW
        local favHeader, _, favIcon = CreateCollapsibleHeader(
            parent,
            ((ns.L and ns.L["HEADER_FAVORITES"]) or "Favorites"),
            "favorites",
            favoritesExpanded,
            function(isExpanded)
                self.db.profile.ui.favoritesExpanded = isExpanded
                if isExpanded then
                    if favoritesContent then
                        favoritesContent:Show()
                        favoritesContent:SetHeight(math.max(0.1, favoritesContent._wnSectionFullH or 0.1))
                    end
                elseif favoritesContent then
                    favoritesContent:Hide()
                    favoritesContent:SetHeight(0.1)
                end
            end,
            "GM-icon-assistActive-hover",
            true,
            nil,
            nil,
            favoritesVisualOpts
        )
        AnchorSectionHeader(favHeader)
        if favIcon then favIcon:SetSize(24, 24) end

        local favCount = FontManager:CreateFontString(favHeader, FontManager:GetFontRole("mainNavTabCount"), "OVERLAY")
        favCount:SetPoint("RIGHT", favHeader, "RIGHT", -SECTION_HEADER_TEXT_INSET, 0)
        favCount:SetJustifyH("RIGHT")
        SetSectionHeaderCount(favCount, #trackedFavorites, "Normal")

        favoritesContent = AcquireSectionContentFrame(favHeader)
        local favoritesHeight = DrawCharactersIntoSection(
            favoritesContent,
            trackedFavorites,
            true,
            "favorites",
            (ns.L and ns.L["NO_FAVORITES"]) or "No favorite characters yet. Click the star icon to favorite a character."
        )
        if favoritesExpanded then
            favoritesContent:Show()
            favoritesContent:SetHeight(math.max(0.1, favoritesContent._wnSectionFullH or 0.1))
        else
            favoritesContent:Hide()
            favoritesContent:SetHeight(0.1)
        end
        previousSectionContent = favoritesContent
        yOffset = yOffset + SECTION_H + (favoritesExpanded and favoritesHeight or 0) + SECTION_HEADER_GAP
    end

    -- User-defined custom headers (tracked, non-favorite characters)
    for cgi = 1, #customGroupsOrdered do
        local gMeta = customGroupsOrdered[cgi]
        local gid = gMeta.id
        local gList = groupedById[gid] or {}
        local gListKey = (ns.CharacterService and ns.CharacterService.GetCustomGroupListKey and ns.CharacterService:GetCustomGroupListKey(gid)) or ("group_" .. tostring(gid))
        -- "all": show every defined header (even empty) so users see where to assign; specific key: that section only.
        local showThisGroup = (sectionFilter == "all") or (sectionFilter == gListKey)
        if showThisGroup then
            if self.db.profile.characterGroupExpanded[gid] == nil then
                self.db.profile.characterGroupExpanded[gid] = true
            end
            local grpExpanded = self.db.profile.characterGroupExpanded[gid]
            local grpContent
            local isFavHeader = ns.CharacterService and ns.CharacterService.IsProfileCustomSectionHighlighted
                and ns.CharacterService:IsProfileCustomSectionHighlighted(self.db.profile, gid)
            local grpVisualOpts = BuildCollapsibleSectionOpts({
                bodyGetter = function() return grpContent end,
                updateVisibleFn = CharactersVirtualScrollBump,
            }) or { animatedContent = function() return grpContent end }
            grpVisualOpts.sectionPreset = isFavHeader and "gold" or "accent"
            grpVisualOpts.useFullParentWidth = true
            grpVisualOpts.sectionStackWidth = sectionStackW
            local grpTitle = gMeta.name or gid
            local grpHeaderAtlas = isFavHeader and "GM-icon-assistActive-hover" or "GM-icon-headCount"
            local grpHeader, grpExpandIcon, grpIcon, grpHeaderText = CreateCollapsibleHeader(
                parent,
                grpTitle,
                "cgrp_" .. tostring(gid),
                grpExpanded,
                function(isExpanded)
                    self.db.profile.characterGroupExpanded[gid] = isExpanded
                    if isExpanded then
                        if grpContent then
                            grpContent:Show()
                            grpContent:SetHeight(math.max(0.1, grpContent._wnSectionFullH or 0.1))
                        end
                    elseif grpContent then
                        grpContent:Hide()
                        grpContent:SetHeight(0.1)
                    end
                end,
                grpHeaderAtlas,
                true,
                nil,
                nil,
                grpVisualOpts
            )
            AnchorSectionHeader(grpHeader)
            if grpIcon then grpIcon:SetSize(24, 24) end

            -- Unified Custom Header chrome (count + [+] add button + gold star).
            -- Layout: [chevron] [icon] [gold-star] [title] ........ [add-btn] [count]
            if ns.UI_DecorateCustomHeader then
                ns.UI_DecorateCustomHeader(grpHeader, {
                    groupId = gid,
                    memberCount = #gList,
                    addon = WarbandNexus,
                    profile = self.db.profile,
                    expandIcon = grpExpandIcon,
                    iconFrame = grpIcon,
                    headerText = grpHeaderText,
                    includeAddButton = true,
                    addButtonRoster = characters,
                    refreshTab = nil,
                })
            end

            grpContent = AcquireSectionContentFrame(grpHeader)
            local grpHeight = DrawCharactersIntoSection(
                grpContent,
                gList,
                false,
                gListKey,
                (ns.L and ns.L["CUSTOM_HEADER_EMPTY"]) or "No characters in this header. Use the + button (next to Filter) or row note icon on non-favorites to assign."
            )
            if grpExpanded then
                grpContent:Show()
                grpContent:SetHeight(math.max(0.1, grpContent._wnSectionFullH or 0.1))
            else
                grpContent:Hide()
                grpContent:SetHeight(0.1)
            end
            previousSectionContent = grpContent
            yOffset = yOffset + SECTION_H + (grpExpanded and grpHeight or 0) + SECTION_HEADER_GAP
        end
    end

    -- Regular characters (ungrouped tracked, non-favorites)
    if drawRegular then
        local charactersExpanded = self.db.profile.ui.charactersExpanded
        local charactersContent
        local charVisualOpts = BuildCollapsibleSectionOpts({
            bodyGetter = function() return charactersContent end,
            updateVisibleFn = CharactersVirtualScrollBump,
        }) or {}
        charVisualOpts.sectionPreset = "accent"
        charVisualOpts.useFullParentWidth = true
        charVisualOpts.sectionStackWidth = sectionStackW
        local charHeader, _, charIcon = CreateCollapsibleHeader(
            parent,
            ((ns.L and ns.L["HEADER_CHARACTERS"]) or "Characters"),
            "characters",
            charactersExpanded,
            function(isExpanded)
                self.db.profile.ui.charactersExpanded = isExpanded
                if isExpanded then
                    if charactersContent then
                        charactersContent:Show()
                        charactersContent:SetHeight(math.max(0.1, charactersContent._wnSectionFullH or 0.1))
                    end
                elseif charactersContent then
                    charactersContent:Hide()
                    charactersContent:SetHeight(0.1)
                end
            end,
            "GM-icon-headCount",
            true,
            nil,
            nil,
            charVisualOpts
        )
        AnchorSectionHeader(charHeader)
        if charIcon then charIcon:SetSize(24, 24) end

        local charCount = FontManager:CreateFontString(charHeader, FontManager:GetFontRole("mainNavTabCount"), "OVERLAY")
        charCount:SetPoint("RIGHT", charHeader, "RIGHT", -SECTION_HEADER_TEXT_INSET, 0)
        charCount:SetJustifyH("RIGHT")
        SetSectionHeaderCount(charCount, #trackedRegular, "Normal")

        charactersContent = AcquireSectionContentFrame(charHeader)
        local charactersHeight = DrawCharactersIntoSection(
            charactersContent,
            trackedRegular,
            false,
            "regular",
            (ns.L and ns.L["ALL_FAVORITED"]) or "All characters are favorited!"
        )
        if charactersExpanded then
            charactersContent:Show()
            charactersContent:SetHeight(math.max(0.1, charactersContent._wnSectionFullH or 0.1))
        else
            charactersContent:Hide()
            charactersContent:SetHeight(0.1)
        end
        previousSectionContent = charactersContent
        yOffset = yOffset + SECTION_H + (charactersExpanded and charactersHeight or 0)
    end

    -- Untracked characters
    if drawUntracked then
        if not isFirstSection then
            yOffset = yOffset + SECTION_HEADER_GAP
        end
        if self.db.profile.ui.untrackedExpanded == nil then
            self.db.profile.ui.untrackedExpanded = false
        end

        local untrackedExpanded = self.db.profile.ui.untrackedExpanded
        local untrackedContent
        local untrackedVisualOpts = BuildCollapsibleSectionOpts({
            bodyGetter = function() return untrackedContent end,
            updateVisibleFn = CharactersVirtualScrollBump,
        }) or {
            animatedContent = function() return untrackedContent end,
        }
        untrackedVisualOpts.sectionPreset = "danger"
        untrackedVisualOpts.useFullParentWidth = true
        untrackedVisualOpts.sectionStackWidth = sectionStackW
        local untrackedHeader, _, untrackedIcon = CreateCollapsibleHeader(
            parent,
            ((ns.L and ns.L["UNTRACKED_CHARACTERS"]) or "Untracked Characters"),
            "untracked",
            untrackedExpanded,
            function(isExpanded)
                self.db.profile.ui.untrackedExpanded = isExpanded
                if isExpanded then
                    if untrackedContent then
                        untrackedContent:Show()
                        untrackedContent:SetHeight(math.max(0.1, untrackedContent._wnSectionFullH or 0.1))
                    end
                elseif untrackedContent then
                    untrackedContent:Hide()
                    untrackedContent:SetHeight(0.1)
                end
            end,
            "DungeonStoneCheckpointDeactivated",
            true,
            nil,
            nil,
            untrackedVisualOpts
        )
        AnchorSectionHeader(untrackedHeader)
        if untrackedIcon then untrackedIcon:SetSize(24, 24) end

        local untrackedCount = FontManager:CreateFontString(untrackedHeader, FontManager:GetFontRole("mainNavTabCount"), "OVERLAY")
        untrackedCount:SetPoint("RIGHT", untrackedHeader, "RIGHT", -SECTION_HEADER_TEXT_INSET, 0)
        untrackedCount:SetJustifyH("RIGHT")
        SetSectionHeaderCount(untrackedCount, #untracked, "Muted")

        untrackedContent = AcquireSectionContentFrame(untrackedHeader)
        local untrackedHeight = DrawCharactersIntoSection(
            untrackedContent,
            untracked,
            false,
            "untracked",
            nil
        )
        if untrackedExpanded then
            untrackedContent:Show()
            untrackedContent:SetHeight(math.max(0.1, untrackedContent._wnSectionFullH or 0.1))
        else
            untrackedContent:Hide()
            untrackedContent:SetHeight(0.1)
        end
        previousSectionContent = untrackedContent
        yOffset = yOffset + SECTION_H + (untrackedExpanded and untrackedHeight or 0)
    end

    if ns.UI_SyncMainTabScrollChrome then
        ns.UI_SyncMainTabScrollChrome(mf, parent, yOffset)
    end

    if mf and C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if mf.currentTab == "chars" then
                CharactersVirtualScrollBump()
            end
        end)
    end

    return yOffset
end

---@param existingRow Frame|nil When set (virtual list), draw into this pooled row instead of acquiring a second frame.
function WarbandNexus:DrawCharacterRow(parent, char, index, width, yOffset, isFavorite, showReorder, charList, listKey, positionInList, totalInList, currentPlayerKey, existingRow)
    local row = existingRow
    if not row then
        row = AcquireCharacterRow(parent)
    else
        row:SetParent(parent)
        row:Show()
        row:SetAlpha(1)
        if row.anim then row.anim:Stop() end
    end
    row:ClearAllPoints()
    row:SetHeight(46)
    if not existingRow then
        row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
        row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOffset)
    end
    if row.SetClipsChildren then
        row:SetClipsChildren(not existingRow)
    end
    row:EnableMouse(true)
    row:SetAlpha(1)
    if row.anim then row.anim:Stop() end

    -- Define charKey for use in buttons (canonical key for DB/service consistency)
    local charKey = GetCharKey(char)
    local isCurrent = IsCharLoggedInSession(char)
    
    -- Set alternating background colors (Factory pattern)
    ns.UI.Factory:ApplyRowBackground(row, index)

    -- Online character highlight (theme-aware)
    ns.UI.Factory:ApplyOnlineCharacterHighlight(row, isCurrent)

    -- Class color
    local classColor = RAID_CLASS_COLORS[char.classFile] or {r = 1, g = 1, b = 1}
    
    -- COLUMN 1: Favorite button
    local favOffset = GetColumnOffset("favorite")
    
    if not row.favButton then
        -- Helper creates button, attach to row
        row.favButton = CreateFavoriteButton(row, charKey, isFavorite, CHAR_ROW_COLUMNS.favorite.width, "LEFT", favOffset + (CHAR_ROW_COLUMNS.favorite.spacing / 2), 0, nil)
        row.favButton.isPersistentRowElement = true  -- Mark as persistent to prevent cleanup
        -- Note: callback set below
    end
    -- Update state
    row.favButton.charKey = charKey
    row.favButton:SetChecked(isFavorite)
    
    -- Safely set OnClick (favButton should be a Button type)
    if row.favButton.HasScript and row.favButton:HasScript("OnClick") then
        row.favButton:SetScript("OnClick", function(btn)
            local newStatus = false
            if ns.CharacterService then
                newStatus = ns.CharacterService:ToggleFavoriteCharacter(WarbandNexus, charKey)
            end
            return newStatus
        end)
    end
    
    -- Tooltip for favorite button
    if ShowTooltip and row.favButton.SetScript then
        row.favButton:SetScript("OnEnter", function(self)
            local isFav = ns.CharacterService and ns.CharacterService:IsFavoriteCharacter(WarbandNexus, charKey)
            ShowTooltip(self, {
                type = "custom",
                icon = "Interface\\Icons\\Achievement_GuildPerk_HappyHour",
                title = isFav and ((ns.L and ns.L["REMOVE_FROM_FAVORITES"]) or "Remove from Favorites") or ((ns.L and ns.L["ADD_TO_FAVORITES"]) or "Add to Favorites"),
                description = (ns.L and ns.L["FAVORITES_TOOLTIP"]) or "Favorite characters appear at the top of the list",
                lines = {
                    {text = "|cff00ff00" .. ((ns.L and ns.L["CLICK_TO_TOGGLE"]) or "Click to toggle") .. "|r", color = {1, 1, 1}}
                },
            })
        end)
        
        row.favButton:SetScript("OnLeave", function(self)
            if HideTooltip then
                HideTooltip()
            end
        end)
    end
    
    -- Icon border color (accent) â€” used for profession icons
    local iconBorderColor = {COLORS.accent[1], COLORS.accent[2], COLORS.accent[3], 0.6}
    
    -- COLUMN 2: Faction icon
    local factionOffset = GetColumnOffset("faction")
    if char.faction then
        if not row.factionIcon then
            row.factionIcon = CreateFactionIcon(row, char.faction, CHAR_ROW_COLUMNS.faction.width, "LEFT", factionOffset + (CHAR_ROW_COLUMNS.faction.spacing / 2), 0)
        end
        if char.faction == "Alliance" then
            row.factionIcon:SetAtlas("AllianceEmblem")
        elseif char.faction == "Horde" then
            row.factionIcon:SetAtlas("HordeEmblem")
        else
            row.factionIcon:SetAtlas("bfa-landingbutton-alliance-up")
        end
        row.factionIcon:Show()
    elseif row.factionIcon then
        row.factionIcon:Hide()
    end
    
    -- COLUMN 3: Race icon
    local raceOffset = GetColumnOffset("race")
    if char.raceFile then
        if not row.raceIcon then
            row.raceIcon = CreateRaceIcon(row, char.raceFile, char.gender, CHAR_ROW_COLUMNS.race.width, "LEFT", raceOffset + (CHAR_ROW_COLUMNS.race.spacing / 2), 0)
        end
        local raceAtlas = ns.UI_GetRaceGenderAtlas(char.raceFile, char.gender)
        row.raceIcon:SetAtlas(raceAtlas)
        row.raceIcon:Show()
    elseif row.raceIcon then
        row.raceIcon:Hide()
    end
    
    -- COLUMN 4: Class icon
    local classOffset = GetColumnOffset("class")
    if char.classFile then
        if not row.classIcon then
            row.classIcon = CreateClassIcon(row, char.classFile, CHAR_ROW_COLUMNS.class.width, "LEFT", classOffset + (CHAR_ROW_COLUMNS.class.spacing / 2), 0)
        end
        if ns.UI_ApplyClassIconTexture then
            ns.UI_ApplyClassIconTexture(row.classIcon, char.classFile)
        else
            row.classIcon:SetAtlas("classicon-" .. char.classFile)
        end
        row.classIcon:Show()
    elseif row.classIcon then
        row.classIcon:Hide()
    end
    
    -- COLUMN 5: Name
    local nameOffset = GetColumnOffset("name")
    local nameLeftPadding = 4
    
    if not row.nameText then
        row.nameText = FontManager:CreateFontString(row, "subtitle", "OVERLAY")
        row.nameText:SetPoint("TOPLEFT", nameOffset + nameLeftPadding, -8)
        row.nameText:SetWidth(CHAR_ROW_COLUMNS.name.width)
        row.nameText:SetJustifyH("LEFT")
        row.nameText:SetWordWrap(false)
        row.nameText:SetNonSpaceWrap(false)
        row.nameText:SetMaxLines(1)
    end
    row.nameText:SetText(string.format("|cff%02x%02x%02x%s|r",
        classColor.r * 255, classColor.g * 255, classColor.b * 255,
        char.name or ((ns.L and ns.L["UNKNOWN"]) or "Unknown")))
    PresentFS(row.nameText)
    if char.hasMail then
        if not row.mailIcon then
            row.mailIcon = row:CreateTexture(nil, "OVERLAY")
            row.mailIcon:SetSize(14, 14)
        end
        ApplyPendingMailIconTexture(row.mailIcon)
        row.mailIcon:ClearAllPoints()
        row.mailIcon:SetPoint("LEFT", row.nameText, "LEFT", row.nameText:GetStringWidth() + 3, -1)
        row.mailIcon:Show()
    elseif row.mailIcon then
        row.mailIcon:Hide()
    end
    if not row.realmText then
        row.realmText = FontManager:CreateFontString(row, "small", "OVERLAY")
        row.realmText:SetPoint("TOPLEFT", nameOffset + nameLeftPadding, -23)
        row.realmText:SetWidth(CHAR_ROW_COLUMNS.name.width)
        row.realmText:SetJustifyH("LEFT")
        row.realmText:SetWordWrap(false)
        row.realmText:SetNonSpaceWrap(false)  -- Prevent long word overflow
        row.realmText:SetMaxLines(1)  -- Single line only
        ns.UI_SetTextColorRole(row.realmText, "Normal")
    end
    local displayRealm = ns.Utilities and ns.Utilities:FormatRealmName(char.realm) or char.realm or ((ns.L and ns.L["UNKNOWN"]) or "Unknown")
    ns.UI_SetTextColorRole(row.realmText, "Normal")
    row.realmText:SetText(displayRealm)
    PresentFS(row.realmText)
    
    -- COLUMN: Guild â€” width from max guild name; text centered; strictly between Name and Level
    local guildOffset = nameOffset + (CHAR_ROW_COLUMNS.name.total or 115)
    local guildColW = self._charsListStableGuildColW
        or self._charListMaxGuildWidth
        or (CHAR_ROW_COLUMNS.guild and CHAR_ROW_COLUMNS.guild.width)
        or 130
    local guildSpacing = (CHAR_ROW_COLUMNS.guild and CHAR_ROW_COLUMNS.guild.spacing) or 15
    if not row.guildText then
        row.guildText = FontManager:CreateFontString(row, "body", "OVERLAY")
        row.guildText:SetJustifyH("CENTER")
        if row.guildText.SetJustifyV then row.guildText:SetJustifyV("MIDDLE") end
        row.guildText:SetWordWrap(false)
        row.guildText:SetNonSpaceWrap(false)
        row.guildText:SetMaxLines(1)
    end
    row.guildText:ClearAllPoints()
    local rowH = row:GetHeight() or 46
    local centerY = -rowH / 2
    row.guildText:SetPoint("CENTER", row, "TOPLEFT", guildOffset + (guildColW - 4) / 2, centerY)
    row.guildText:SetWidth(guildColW - 4)
    row.guildText:SetText(BuildGuildText(char, isCurrent))
    PresentFS(row.guildText)
    row.guildText:Show()

    -- Level column: level + rested line (DB-driven).
    local guildTotal = guildColW + guildSpacing
    local levelOffset = guildOffset + guildTotal
    local levelColW = CHAR_ROW_COLUMNS.level.width
    if not row.levelText then
        row.levelText = FontManager:CreateFontString(row, "body", "OVERLAY")
        row.levelText:SetWidth(levelColW)
        row.levelText:SetJustifyH("CENTER")
        row.levelText:SetWordWrap(false)
        row.levelText:SetMaxLines(1)
    end

    if not row.levelRestedText then
        row.levelRestedText = FontManager:CreateFontString(row, "small", "OVERLAY")
        row.levelRestedText:SetWidth(levelColW)
        row.levelRestedText:SetJustifyH("CENTER")
        row.levelRestedText:SetWordWrap(false)
        row.levelRestedText:SetMaxLines(1)
    end

    local restedState = self.GetCharacterRestedState and self:GetCharacterRestedState(char)
    local maxPlayerLevel = GetMaxPlayerLevel and GetMaxPlayerLevel() or 80
    local showRestedLine = restedState ~= nil and (char.level or 1) < maxPlayerLevel
    local showZzz = restedState and restedState.isRestingArea

    row.levelText:ClearAllPoints()
    if showRestedLine then
        row.levelText:SetPoint("TOP", row, "TOPLEFT", levelOffset + (levelColW / 2), -7)
    else
        row.levelText:SetPoint("LEFT", levelOffset, 0)
    end
    row.levelText:SetText(string.format("|cff%02x%02x%02x%d|r",
        classColor.r * 255, classColor.g * 255, classColor.b * 255,
        char.level or 1))
    PresentFS(row.levelText)
    row.levelText:Show()

    if showRestedLine then
        local restedPct = restedState.restedPercentOfLevel or 0
        row.levelRestedText:ClearAllPoints()
        row.levelRestedText:SetPoint("TOP", row.levelText, "BOTTOM", 0, -2)
        if showZzz then
            row.levelRestedText:SetText(string.format("|cff66c0ffZzz %.2f%%|r", restedPct))
        else
            row.levelRestedText:SetText(string.format("|cff66c0ff%.2f%%|r", restedPct))
        end
        PresentFS(row.levelRestedText)
        row.levelRestedText:Show()
    else
        row.levelRestedText:Hide()
    end

    if row.levelRestedIcon then row.levelRestedIcon:Hide() end
    if row.levelRestedHitFrame then row.levelRestedHitFrame:Hide() end

    -- COLUMN: Item Level (dynamic offset, chained from level column)
    local itemLevelOffset = levelOffset + (CHAR_ROW_COLUMNS.level.total or 97)
    if not row.itemLevelText then
        row.itemLevelText = FontManager:CreateFontString(row, "body", "OVERLAY")
        row.itemLevelText:SetWidth(CHAR_ROW_COLUMNS.itemLevel.width)
        row.itemLevelText:SetJustifyH("CENTER")
        row.itemLevelText:SetMaxLines(1)  -- Single line only
    end
    row.itemLevelText:ClearAllPoints()
    row.itemLevelText:SetPoint("LEFT", itemLevelOffset, 0)
    local itemLevel = char.itemLevel or 0
    if itemLevel > 0 then
        row.itemLevelText:SetText(string.format("%s%s %d|r", SemanticGoldHex(), (ns.L and ns.L["ILVL_SHORT"]) or "iLvl", itemLevel))
    else
        row.itemLevelText:SetText(ThemeTextHex("Muted") .. "--|r")
    end
    PresentFS(row.itemLevelText)
    
    -- COLUMN 8: Gold (dynamic offset, chained from itemLevel column)
    local goldOffset = itemLevelOffset + (CHAR_ROW_COLUMNS.itemLevel.total or 90)
    if not row.goldText then
        row.goldText = FontManager:CreateFontString(row, "body", "OVERLAY")
        row.goldText:SetWidth(CHAR_ROW_COLUMNS.gold.width)
        row.goldText:SetJustifyH("RIGHT")
        row.goldText:SetMaxLines(1)  -- Single line only
    end
    row.goldText:ClearAllPoints()
    row.goldText:SetPoint("LEFT", goldOffset, 0)
    local totalCopper = isCurrent and ns.Utilities.GetLiveCharacterMoneyCopper
        and ns.Utilities:GetLiveCharacterMoneyCopper(ns.Utilities:GetCharTotalCopper(char))
        or ns.Utilities:GetCharTotalCopper(char)
    row.goldText:SetText(FormatMoney(totalCopper, 12))
    PresentFS(row.goldText)
    
    -- COLUMN 9: Professions (dynamic offset, chained from gold column)
    local profOffset = goldOffset + (CHAR_ROW_COLUMNS.gold.total or 205)
    if not row.profIcons then row.profIcons = {} end
    
    -- Hide all existing profession icons first
    for i = 1, #row.profIcons do
        row.profIcons[i]:Hide()
    end
    
    if char.professions then
        local iconSize = 39  -- Increased 15% (34 â†’ 39)
        local iconSpacing = 5  -- Icon spacing
        
        -- Count total professions first
        local numProfs = 0
        if char.professions[1] then numProfs = numProfs + 1 end
        if char.professions[2] then numProfs = numProfs + 1 end
        for i = 1, #SECONDARY_PROF_KEYS do
            local sec = SECONDARY_PROF_KEYS[i]
            if char.professions[sec] then numProfs = numProfs + 1 end
        end
        
        -- Calculate centered starting position (within column content area, excluding right spacing)
        local profColumnWidth = CHAR_ROW_COLUMNS.professions.width  -- Content width only
        local totalIconWidth = (numProfs * iconSize) + ((numProfs - 1) * iconSpacing)
        -- Center the icons within the column width
        local leftPadding = (profColumnWidth - totalIconWidth) / 2
        local currentProfX = profOffset + leftPadding
        
        local charKey = GetCharKey(char)
        local function SetupProfIcon(prof, idx, profSlotKey)
            if not prof or not prof.icon then return end
            
            -- Reuse or create (with accent border)
            local pFrame = row.profIcons[idx]
            if not pFrame then
                pFrame = CreateIcon(row, nil, iconSize, false, iconBorderColor, false)
                pFrame:EnableMouse(true)
                pFrame.icon = pFrame.texture
                
                row.profIcons[idx] = pFrame
            end
            
            -- Clear previous position and set new centered position
            pFrame:ClearAllPoints()
            pFrame:SetPoint("LEFT", currentProfX, 0)
            pFrame.icon:SetTexture(prof.icon)
            pFrame.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)  -- Crop default WoW icon border
            pFrame:Show()
            
            -- Unspent knowledge badge (yellow dot)
            local profName = prof.name
            local kd = nil
            if profName and char.knowledgeData then
                -- Prefer highest skillLineID (newest expansion) for current data
                local bestKey = -1
                for key, entry in pairs(char.knowledgeData) do
                    if type(key) == "number" and type(entry) == "table" and entry.professionName == profName then
                        if key > bestKey then
                            bestKey = key
                            kd = entry
                        end
                    end
                end
                -- Fallback: legacy profName-keyed data
                if not kd then
                    kd = char.knowledgeData[profName]
                end
            end
            if kd and kd.unspentPoints and kd.unspentPoints > 0 then
                if not pFrame.knowledgeBadge then
                    local badge = pFrame:CreateTexture(nil, "OVERLAY")
                    badge:SetSize(16, 16)
                    badge:SetPoint("TOPRIGHT", pFrame, "TOPRIGHT", 5, 0)
                    badge:SetAtlas("icons_64x64_important")
                    pFrame.knowledgeBadge = badge
                end
                pFrame.knowledgeBadge:Show()
            else
                if pFrame.knowledgeBadge then
                    pFrame.knowledgeBadge:Hide()
                end
            end
            
            -- Profession detail is now shown automatically when WoW's profession UI opens.
            -- No click action needed on these icons.
            pFrame:SetScript("OnMouseUp", nil)
            
            -- Setup tooltip with detailed information (expansion skills + knowledge + concentration)
            pFrame:SetScript("OnEnter", function(self)
                local profName = prof.name or ((ns.L and ns.L["UNKNOWN_PROFESSION"]) or "Unknown Profession")
                local lines = {}

                local expansions = char.professionExpansions and char.professionExpansions[profName]
                if expansions and #expansions > 0 then
                    for i = 1, #expansions do
                        local exp = expansions[i]
                        local skillColor
                        local maxSkill = exp.maxSkillLevel or 0
                        local curSkill = exp.skillLevel or 0

                        if maxSkill > 0 and curSkill >= maxSkill then
                            skillColor = {0.3, 0.9, 0.3}
                        elseif curSkill > 0 then
                            local gr, gg, gb = SemanticGoldRGB()
                            skillColor = { gr, gg, gb }
                        else
                            skillColor = {0.4, 0.4, 0.4}
                        end

                        local skillText
                        if maxSkill > 0 then
                            skillText = string.format("%d / %d", curSkill, maxSkill)
                        else
                            skillText = "â€”"
                        end

                        lines[#lines + 1] = {
                            left = exp.name or "?",
                            right = skillText,
                            leftColor = {1, 1, 1},
                            rightColor = skillColor
                        }
                    end
                else
                    -- Fallback: no expansion data yet, show basic skill
                    if prof.skill and prof.maxSkill then
                        lines[#lines + 1] = {
                            left = profName,
                            right = string.format("%d / %d", prof.skill, prof.maxSkill),
                            leftColor = {1, 1, 1},
                            rightColor = {0.3, 0.9, 0.3}
                        }
                    end
                end

                local kd = nil
                if char.knowledgeData then
                    -- Prefer highest skillLineID (newest expansion) for current data
                    local bestKey = -1
                    for kKey, kEntry in pairs(char.knowledgeData) do
                        if type(kKey) == "number" and type(kEntry) == "table" and kEntry.professionName == profName then
                            if kKey > bestKey then
                                bestKey = kKey
                                kd = kEntry
                            end
                        end
                    end
                    -- Fallback: legacy profName-keyed data
                    if not kd then
                        kd = char.knowledgeData[profName]
                    end
                end
                if kd then
                    local unspent = kd.unspentPoints or 0
                    local spent = kd.spentPoints or 0
                    local maxPts = kd.maxPoints or 0
                    local collectible = (maxPts > 0) and (maxPts - unspent - spent) or 0
                    local hasKnowledgeLine = (collectible > 0) or (unspent > 0)
                    if hasKnowledgeLine then
                        lines[#lines + 1] = { left = " ", leftColor = {1, 1, 1} }
                        local cName = kd.currencyName or ((ns.L and ns.L["KNOWLEDGE"]) or "Knowledge")
                        if collectible > 0 then
                            lines[#lines + 1] = {
                                left = (ns.L and ns.L["COLLECTIBLE"]) or "Collectible",
                                right = tostring(collectible),
                                leftColor = {1, 1, 1},
                                rightColor = {0.3, 0.9, 0.3}
                            }
                        end
                        if unspent > 0 then
                            lines[#lines + 1] = {
                                left = "|TInterface\\GossipFrame\\AvailableQuestIcon:0|t " .. unspent .. " " .. ((ns.L and ns.L["UNSPENT_POINTS"]) or "Unspent Points"),
                                leftColor = {1, 0.82, 0}
                            }
                        end
                    end
                end

                local concData = char.concentration and char.concentration[profName]
                if concData and concData.max and concData.max > 0 then
                    -- Spacer
                    lines[#lines + 1] = { left = " ", leftColor = {1, 1, 1} }

                    -- Estimate current concentration (passive regen since snapshot)
                    local estimated = concData.current or 0
                    if WarbandNexus and WarbandNexus.GetEstimatedConcentration then
                        estimated = WarbandNexus:GetEstimatedConcentration(concData)
                    end

                    local concColor = {1, 1, 1}
                    if estimated >= concData.max then
                        concColor = {0.3, 0.9, 0.3}
                    elseif estimated > 0 then
                        concColor = {1, 0.82, 0}
                    end

                    local concText = string.format("%d / %d", estimated, concData.max)

                    lines[#lines + 1] = {
                        left = (ns.L and ns.L["CONCENTRATION"]) or "Concentration",
                        right = concText,
                        leftColor = {1, 1, 1},
                        rightColor = concColor
                    }

                    -- Recharge timer (white label, yellow duration)
                    if estimated < concData.max and WarbandNexus and WarbandNexus.GetConcentrationTimeToFull then
                        local timeStr = WarbandNexus:GetConcentrationTimeToFull(concData)
                        if timeStr and timeStr ~= "" and timeStr ~= ((ns.L and ns.L["FULL"]) or "Full") then
                            lines[#lines + 1] = {
                                left = (ns.L and ns.L["RECHARGE"]) or "Recharge",
                                right = timeStr,
                                leftColor = {1, 1, 1},
                                rightColor = {1, 0.82, 0}
                            }
                        end
                    end
                end

                local eqByProf = char.professionEquipment
                local eqData = eqByProf and eqByProf[profName] or nil
                if eqData and (eqData.tool or eqData.accessory1 or eqData.accessory2) then
                    lines[#lines + 1] = { left = " ", leftColor = {1, 1, 1} }
                    local slotKeys = { "tool", "accessory1", "accessory2" }
                    local tooltipSvc = ns.TooltipService
                    for i = 1, #slotKeys do
                        local slotKey = slotKeys[i]
                        local item = eqData[slotKey]
                        if item then
                            local iconStr = item.icon and string.format("|T%s:0|t ", tostring(item.icon)) or ""
                            -- Get stat lines first to extract actual slot type (Head, Chest, Tool, etc.)
                            local statLines = tooltipSvc and tooltipSvc.GetItemTooltipSummaryLines and tooltipSvc:GetItemTooltipSummaryLines(item.itemLink, item.itemID, slotKey) or {}
                            -- First line contains the slot type (e.g., "Head", "Chest", "Tool")
                            local slotLabel = (statLines[1] and statLines[1].left)
                                or (slotKey == "tool" and ((ns.L and ns.L["TOOL"]) or "Tool") or ((ns.L and ns.L["ACCESSORY"]) or "Accessory"))
                            lines[#lines + 1] = {
                                left = iconStr .. (item.name or ((ns.L and ns.L["UNKNOWN"]) or "Unknown")),
                                right = slotLabel,
                                leftColor = {1, 1, 1},
                                rightColor = {0.5, 0.5, 0.5},
                            }
                            -- Add remaining stat lines (skip first since it's now used as slot label)
                            for si = 2, #statLines do
                                local sLine = statLines[si]
                                lines[#lines + 1] = {
                                    left = "    " .. sLine.left,
                                    right = sLine.right or "",
                                    leftColor = sLine.leftColor or {0.7, 0.7, 0.7},
                                    rightColor = sLine.rightColor or {0.7, 0.7, 0.7},
                                }
                            end
                        end
                    end
                end

                if not ShowTooltip then
                    -- Fallback: Use TooltipService
                    local tooltipLines = {}
                    for i = 1, #lines do
                        local line = lines[i]
                        if line.right then
                            tooltipLines[#tooltipLines + 1] = {
                                text = (line.left or "") .. "  " .. (line.right or ""),
                                color = line.rightColor or line.leftColor or {1, 1, 1}
                            }
                        elseif line.left and line.left ~= " " then
                            tooltipLines[#tooltipLines + 1] = {
                                text = line.left,
                                color = line.leftColor or {1, 1, 1}
                            }
                        end
                    end
                    ns.TooltipService:Show(self, {
                        type = "custom",
                        icon = prof.icon,
                        title = profName,
                        lines = tooltipLines,
                    })
                    return
                end

                ShowTooltip(self, {
                    type = "custom",
                    icon = prof.icon,
                    title = profName,
                    lines = lines,
                })
            end)
            pFrame:SetScript("OnLeave", function(self)
                if HideTooltip then
                    HideTooltip()
                else
                    ns.TooltipService:Hide()
                end
            end)
            
            return true
        end
        
        local pIdx = 1
        -- Primary
        if char.professions[1] then
            SetupProfIcon(char.professions[1], pIdx, 1)
            pIdx = pIdx + 1
            currentProfX = currentProfX + iconSize + iconSpacing
        end
        if char.professions[2] then
            SetupProfIcon(char.professions[2], pIdx, 2)
            pIdx = pIdx + 1
            currentProfX = currentProfX + iconSize + iconSpacing
        end
        -- Secondary
        for i = 1, #SECONDARY_PROF_KEYS do
            local sec = SECONDARY_PROF_KEYS[i]
            if char.professions[sec] then
                SetupProfIcon(char.professions[sec], pIdx, sec)
                pIdx = pIdx + 1
                currentProfX = currentProfX + iconSize + iconSpacing
            end
        end
    end
    
    -- COLUMN 10: Mythic Keystone (dynamic offset, chained from professions column)
    local mythicKeyOffset = profOffset + (CHAR_ROW_COLUMNS.professions.total or 150)
    
    -- Create keystone icon (shared for has-key and no-key)
    if not row.keystoneIcon then
        row.keystoneIcon = row:CreateTexture(nil, "ARTWORK")
        row.keystoneIcon:SetSize(24, 24)  -- Increased 20% (20 â†’ 24)

        -- Use atlas with fallback
        local atlasInfo = C_Texture.GetAtlasInfo("ChromieTime-32x32")
        if atlasInfo then
            row.keystoneIcon:SetAtlas("ChromieTime-32x32", true)
        else
            row.keystoneIcon:SetTexture(525134)  -- Fallback to item texture
            row.keystoneIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        end
    end
    row.keystoneIcon:ClearAllPoints()
    row.keystoneIcon:SetPoint("LEFT", mythicKeyOffset + 5, 0)  -- 5px padding from column edge

    -- Create keystone text (shared for has-key and no-key)
    if not row.keystoneText then
        row.keystoneText = FontManager:CreateFontString(row, "body", "OVERLAY")
        row.keystoneText:SetWidth(CHAR_ROW_COLUMNS.mythicKey.width - 30)
        row.keystoneText:SetJustifyH("LEFT")
        row.keystoneText:SetWordWrap(false)
        row.keystoneText:SetNonSpaceWrap(false)  -- Prevent long word overflow
        row.keystoneText:SetMaxLines(1)  -- Single line only
    end
    row.keystoneText:ClearAllPoints()
    row.keystoneText:SetPoint("LEFT", mythicKeyOffset + 34, 0)  -- Icon(24) + gap(5) + padding(5)
    
    -- Format content based on keystone status
    if char.mythicKey and char.mythicKey.level and char.mythicKey.level > 0 then
        -- HAS KEY: +Level â€¢ Dungeon (colored, bright icon)
        local dungeonName = (ns.Utilities and ns.Utilities.ResolveKeystoneDungeonName)
            and ns.Utilities:ResolveKeystoneDungeonName(char.mythicKey)
            or (char.mythicKey.dungeonName or "????")
        local dungeonAbbrev = dungeonName:sub(1, 4):upper()
        local keystoneText = string.format("|cffff8000+%d|r %s•|r %s%s|r",
            char.mythicKey.level, ThemeTextHex("Dim"), SemanticGoldHex(), dungeonAbbrev)
        
        row.keystoneText:SetText(keystoneText)
        
        -- Bright, colored icon
        row.keystoneIcon:SetDesaturated(false)
        row.keystoneIcon:SetVertexColor(1, 1, 1)
        row.keystoneIcon:SetAlpha(1.0)
    else
        -- NO KEY: +0 â€¢ None (dimmed, desaturated icon)
        local keystoneText = ThemeTextHex("Muted") .. "+0|r " .. ThemeTextHex("Muted") .. "•|r "
            .. ThemeTextHex("Normal") .. ((ns.L and ns.L["NONE_LABEL"]) or "None") .. "|r"
        
        row.keystoneText:SetText(keystoneText)
        
        -- Dimmed, desaturated icon
        row.keystoneIcon:SetDesaturated(true)
        row.keystoneIcon:SetVertexColor(0.6, 0.6, 0.6)
        row.keystoneIcon:SetAlpha(0.4)
    end
    
    row.keystoneIcon:Show()
    row.keystoneText:Show()
    
    -- RIGHT RAIL: fixed slot positions (no per-row width reflow flicker on resize)
    local R_MARGIN = ns.UI_CHAR_ROW_RIGHT_MARGIN or 6
    local R_GAP = ns.UI_CHAR_ROW_RIGHT_GAP or 6
    local delW = CHAR_ROW_COLUMNS.delete and CHAR_ROW_COLUMNS.delete.width or 24
    local trackW = CHAR_ROW_COLUMNS.tracking and CHAR_ROW_COLUMNS.tracking.width or 22
    local haTot = CHAR_ROW_COLUMNS.headerAssign and CHAR_ROW_COLUMNS.headerAssign.total or 26
    local lsW = CHAR_ROW_COLUMNS.lastSeen and CHAR_ROW_COLUMNS.lastSeen.width or 60
    local reoW = CHAR_ROW_COLUMNS.reorder and CHAR_ROW_COLUMNS.reorder.width or 44
    local railW = (ns.UI_GetCharRowRightRailWidth and ns.UI_GetCharRowRightRailWidth()) or (R_MARGIN * 2 + delW + R_GAP + trackW + R_GAP + haTot + R_GAP + lsW + R_GAP + reoW)
    local insetDelete = 0
    local insetTrack = delW + R_GAP
    local insetHeader = insetTrack + trackW + R_GAP
    local insetLastSeen = insetHeader + haTot + R_GAP
    local insetReorder = insetLastSeen + lsW + R_GAP

    if not row._wnRightRail then
        row._wnRightRail = CreateFrame("Frame", nil, row)
        row._wnRightRail.isPersistentRowElement = true
    end
    local rail = row._wnRightRail
    rail:SetSize(railW, 46)
    rail:ClearAllPoints()
    rail:SetPoint("TOPRIGHT", row, "TOPRIGHT", -R_MARGIN, 0)
    rail:Show()

    -- COLUMN: Tracking toggle (all rows; login not required for other characters)
    local isCharTracked = char.isTracked ~= false
    if not row.trackingBtn then
        local tbtn = ns.UI.Factory:CreateButton(rail, 22, 22, true)
        if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(tbtn) end
        tbtn.isPersistentRowElement = true
        tbtn.icon = tbtn:CreateTexture(nil, "ARTWORK")
        tbtn.icon:SetAllPoints()
        row.trackingBtn = tbtn
    else
        row.trackingBtn:SetParent(rail)
    end
    row.trackingBtn:ClearAllPoints()
    row.trackingBtn:SetPoint("RIGHT", rail, "RIGHT", -(insetTrack + 11), 0)
    if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(row.trackingBtn) end
    row.trackingBtn.charKey = charKey
    row.trackingBtn.charName = char.name or ((ns.L and ns.L["UNKNOWN"]) or "Unknown")
    if row.trackingBtn.icon then
        if isCharTracked then
            local ok = pcall(row.trackingBtn.icon.SetAtlas, row.trackingBtn.icon, "common-icon-checkmark", false)
            if not ok then
                row.trackingBtn.icon:SetTexture("Interface\\Icons\\Ability_Hunter_BeastTaming")
            end
            row.trackingBtn.icon:SetVertexColor(0.35, 1, 0.45)
        else
            local ok = pcall(row.trackingBtn.icon.SetAtlas, row.trackingBtn.icon, "common-icon-redx", false)
            if not ok then
                row.trackingBtn.icon:SetTexture("Interface\\Icons\\Spell_Shadow_Teleport")
            end
            row.trackingBtn.icon:SetVertexColor(1, 0.55, 0.42)
        end
    end
    if row.trackingBtn.HasScript and row.trackingBtn:HasScript("OnClick") then
        row.trackingBtn:SetScript("OnClick", function(selfBtn)
            local CS = ns.CharacterService
            if not CS or not CS.ShowTrackingChangeConfirmation then return end
            local addon = WarbandNexus
            local ck = selfBtn.charKey
            local rowKey = (ns.Utilities and ns.Utilities.GetCanonicalCharacterKey and ck)
                and ns.Utilities:GetCanonicalCharacterKey(ck) or ck
            local rowData = addon and addon.db and addon.db.global and addon.db.global.characters
                and rowKey and (addon.db.global.characters[rowKey] or (ck and addon.db.global.characters[ck]))
            local tracked = rowData and rowData.isTracked ~= false
            CS:ShowTrackingChangeConfirmation(addon, rowKey or ck, selfBtn.charName, not tracked)
        end)
    end
    if ShowTooltip and row.trackingBtn.SetScript then
        row.trackingBtn:SetScript("OnEnter", function(selfBtn)
            local tracked = char.isTracked ~= false
            ShowTooltip(selfBtn, {
                type = "custom",
                title = (ns.L and ns.L["TRACKING_TOGGLE_TOOLTIP_TITLE"]) or "Character tracking",
                description = tracked
                    and ((ns.L and ns.L["TRACKING_TOGGLE_DISABLE_HINT"]) or "Click to disable tracking.")
                    or ((ns.L and ns.L["TRACKING_TOGGLE_ENABLE_HINT"]) or "Click to enable tracking."),
            })
        end)
        row.trackingBtn:SetScript("OnLeave", HideTooltip)
    end
    row.trackingBtn:Show()

    -- Reorder Buttons (leftmost slot in the right rail)
    if not row.reorderButtons then
        local rb = ns.UI.Factory:CreateContainer(rail, CHAR_ROW_COLUMNS.reorder.width, 46)
        rb.isPersistentRowElement = true

        rb.up = ns.UI.Factory:CreateButton(rb, 18, 18, true)
        if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(rb.up) end
        rb.up:SetPoint("CENTER", -11, 0)
        rb.up:SetNormalAtlas("housing-floor-arrow-up-default")
        rb.up:EnableMouse(true)
        rb.down = ns.UI.Factory:CreateButton(rb, 18, 18, true)
        if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(rb.down) end
        rb.down:SetPoint("CENTER", 11, 0)
        rb.down:SetNormalAtlas("housing-floor-arrow-down-default")
        rb.down:EnableMouse(true)
        row.reorderButtons = rb
    else
        row.reorderButtons:SetParent(rail)
    end
    row.reorderButtons:ClearAllPoints()
    row.reorderButtons:SetPoint("RIGHT", rail, "RIGHT", -insetReorder, 0)
    
    if showReorder and charList then
        row.reorderButtons:Show()
        -- Always set OnClick (WoW Button supports SetScript even without prior script; HasScript can be false before first set)
        if row.reorderButtons.up then
            row.reorderButtons.up:EnableMouse(true)
            row.reorderButtons.up:SetScript("OnClick", function() WarbandNexus:ReorderCharacter(char, charList, listKey, -1) end)
        end
        if row.reorderButtons.down then
            row.reorderButtons.down:EnableMouse(true)
            row.reorderButtons.down:SetScript("OnClick", function() WarbandNexus:ReorderCharacter(char, charList, listKey, 1) end)
        end
    else
        row.reorderButtons:Hide()
        if row.reorderButtons.up then row.reorderButtons.up:SetScript("OnClick", nil) end
        if row.reorderButtons.down then row.reorderButtons.down:SetScript("OnClick", nil) end
    end
    
    -- COLUMN: Last Seen (right rail — "Online" only for current character; relative time for alts)
    if not row._wnLastSeenHost then
        row._wnLastSeenHost = CreateFrame("Frame", nil, rail)
        row._wnLastSeenHost.isPersistentRowElement = true
    end
    local lsHost = row._wnLastSeenHost
    lsHost:SetParent(rail)
    lsHost:SetSize(lsW, 46)
    lsHost:ClearAllPoints()
    lsHost:SetPoint("RIGHT", rail, "RIGHT", -insetLastSeen, 0)
    lsHost:Show()

    if isCurrent then
        if not row.onlineText then
            row.onlineText = FontManager:CreateFontString(lsHost, "body", "OVERLAY")
            row.onlineText:SetWidth(lsW)
            row.onlineText:SetJustifyH("CENTER")
            row.onlineText:SetText((ns.L and ns.L["ONLINE"]) or "Online")
            if ns.UI_SetInkColor then
                ns.UI_SetInkColor(row.onlineText, 0, 1, 0, 1)
            else
                row.onlineText:SetTextColor(0, 1, 0)
            end
        else
            row.onlineText:SetParent(lsHost)
        end
        row.onlineText:ClearAllPoints()
        row.onlineText:SetPoint("CENTER", lsHost, "CENTER", 0, 0)
        row.onlineText:Show()
        if row.lastSeenText then row.lastSeenText:Hide() end
    else
        if row.onlineText then row.onlineText:Hide() end

        local timeDiff = char.lastSeen and (time() - char.lastSeen) or math.huge
        local lastSeenStr
        if timeDiff < 60 then
            lastSeenStr = (ns.L and ns.L["TIME_LESS_THAN_MINUTE"]) or "< 1m ago"
        elseif timeDiff < 3600 then
            local minutesFormat = (ns.L and ns.L["TIME_MINUTES_FORMAT"]) or "%dm ago"
            lastSeenStr = string.format(minutesFormat, math.floor(timeDiff / 60))
        elseif timeDiff < 86400 then
            local hoursFormat = (ns.L and ns.L["TIME_HOURS_FORMAT"]) or "%dh ago"
            lastSeenStr = string.format(hoursFormat, math.floor(timeDiff / 3600))
        else
            local daysFormat = (ns.L and ns.L["TIME_DAYS_FORMAT"]) or "%dd ago"
            lastSeenStr = string.format(daysFormat, math.floor(timeDiff / 86400))
        end

        if not row.lastSeenText then
            row.lastSeenText = FontManager:CreateFontString(lsHost, "body", "OVERLAY")
            row.lastSeenText:SetWidth(lsW)
            row.lastSeenText:SetJustifyH("CENTER")
        else
            row.lastSeenText:SetParent(lsHost)
        end
        row.lastSeenText:ClearAllPoints()
        row.lastSeenText:SetPoint("CENTER", lsHost, "CENTER", 0, 0)
        row.lastSeenText:SetText(lastSeenStr)
        ns.UI_SetTextColorRole(row.lastSeenText, "Normal")
        PresentFS(row.lastSeenText)
        row.lastSeenText:Show()
    end
    
    -- COLUMN: Custom section assign (folder) â€” tracked non-favorites in regular or custom group lists
    local showHeaderAssign = (char.isTracked ~= false) and (not isFavorite)
        and (listKey == "regular" or (ns.CharacterService and ns.CharacterService.ParseCustomGroupIdFromListKey(listKey)))
    if showHeaderAssign then
        if not row.headerAssignBtn then
            local hb = ns.UI.Factory:CreateButton(rail, 22, 22, true)
            if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(hb) end
            hb.isPersistentRowElement = true
            local htex = hb.GetNormalTexture and hb:GetNormalTexture()
            if htex then
                htex:SetTexture("Interface\\Icons\\INV_Misc_Note_01")
                htex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            end
            row.headerAssignBtn = hb
        else
            row.headerAssignBtn:SetParent(rail)
        end
        row.headerAssignBtn:ClearAllPoints()
        row.headerAssignBtn:SetPoint("RIGHT", rail, "RIGHT", -(insetHeader + 11), 0)
        if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(row.headerAssignBtn) end
        row.headerAssignBtn:Show()
        row.headerAssignBtn:SetScript("OnClick", function(selfBtn)
            if ns.UI_ShowCharacterSectionAssignMenu then
                ns.UI_ShowCharacterSectionAssignMenu(selfBtn, charKey, WarbandNexus.db.profile, function()
                    WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
                end)
            end
        end)
        if ShowTooltip then
            row.headerAssignBtn:SetScript("OnEnter", function(selfBtn)
                ShowTooltip(selfBtn, {
                    type = "custom",
                    title = (ns.L and ns.L["CUSTOM_HEADER_ASSIGN_TOOLTIP_TITLE"]) or "Custom header",
                    description = (ns.L and ns.L["CUSTOM_HEADER_ASSIGN_TOOLTIP_DESC"]) or "Move this character into a header. Use the + button (left of Filter) to create headers, or Filter â†’ New custom header.",
                })
            end)
            row.headerAssignBtn:SetScript("OnLeave", HideTooltip)
        end
    elseif row.headerAssignBtn then
        row.headerAssignBtn:Hide()
    end

    -- COLUMN: Delete button (RIGHT-anchored, compact)
    if not isCurrent then
        if not row.deleteBtn then
            local deleteBtn = ns.UI.Factory:CreateButton(rail, 24, 24, true)
            if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(deleteBtn) end
            deleteBtn.isPersistentRowElement = true
            
            -- Add icon texture
            local icon = deleteBtn:CreateTexture(nil, "ARTWORK")
            icon:SetAllPoints()
            icon:SetTexture("Interface\\Buttons\\UI-GroupLoot-Pass-Up")
            StyleCharacterDeleteIcon(icon, false)
            deleteBtn.texture = icon
            deleteBtn.icon = icon
            
            row.deleteBtn = deleteBtn
        else
            row.deleteBtn:SetParent(rail)
        end
        row.deleteBtn:ClearAllPoints()
        row.deleteBtn:SetPoint("RIGHT", rail, "RIGHT", -(insetDelete + 12), 0)
        if ns.UI.Factory.ApplyIconOnlyButtonChrome then ns.UI.Factory:ApplyIconOnlyButtonChrome(row.deleteBtn) end
        
        row.deleteBtn.charKey = charKey
        row.deleteBtn.charName = char.name or ((ns.L and ns.L["UNKNOWN"]) or "Unknown")
        
        -- Safely set OnClick
        if row.deleteBtn.HasScript and row.deleteBtn:HasScript("OnClick") then
            row.deleteBtn:SetScript("OnClick", function(self)
                -- Use custom themed delete dialog
                local charKey = self.charKey
                local charName = self.charName
                
                -- Get UI functions from namespace
                local CreateExternalWindow = ns.UI_CreateExternalWindow
                local FontManager = ns.FontManager
                
                if not CreateExternalWindow then
                    DebugPrint("[WarbandNexus] ERROR: CreateExternalWindow not found!")
                    return
                end
                
                -- Create custom delete confirmation dialog
                local dialog, contentFrame = CreateExternalWindow({
                    name = "DeleteCharacterDialog",
                    title = (ns.L and ns.L["DELETE_CHARACTER_TITLE"]) or "Delete Character?",
                    icon = "Interface\\DialogFrame\\UI-Dialog-Icon-AlertNew",
                    width = 420,
                    height = 180,
                })
                
                if not dialog then
                    DebugPrint("[WarbandNexus] ERROR: Failed to create delete dialog!")
                    return
                end
                
                -- Warning text
                local warning = FontManager:CreateFontString(contentFrame, "body", "OVERLAY")
                warning:SetPoint("TOP", contentFrame, "TOP", 0, -20)
                warning:SetWidth(380)
                warning:SetJustifyH("CENTER")
                warning:SetTextColor(1, 0.85, 0.1)  -- Gold/Orange
                local confirmDeleteFmt = (ns.L and ns.L["CONFIRM_DELETE"]) or "Are you sure you want to delete |cff00ccff%s|r?"
                warning:SetText(string.format(confirmDeleteFmt, charName or ((ns.L and ns.L["THIS_CHARACTER"]) or "this character")))
                
                local subtext = FontManager:CreateFontString(contentFrame, "body", "OVERLAY")
                subtext:SetPoint("TOP", warning, "BOTTOM", 0, -10)
                subtext:SetWidth(380)
                subtext:SetJustifyH("CENTER")
                subtext:SetTextColor(1, 0.3, 0.3)  -- Red
                local cannotUndoText = (ns.L and ns.L["CANNOT_UNDO"]) or "This action cannot be undone!"
                subtext:SetText("|cffff0000" .. cannotUndoText .. "|r")
                
                -- Buttons container (using Factory pattern)
                local btnContainer = ns.UI.Factory:CreateContainer(contentFrame, 320, 40)
                btnContainer:SetPoint("BOTTOM", contentFrame, "BOTTOM", 0, 20)
                
                -- Delete button (LEFT)
                local deleteBtnLabel = (ns.L and ns.L["DELETE"]) or "Delete"
                local deleteBtn = CreateCharacterDeleteDialogButton(btnContainer, deleteBtnLabel, 150, true)
                deleteBtn:SetPoint("LEFT", btnContainer, "LEFT", 0, 0)
                deleteBtn:SetScript("OnClick", function()
                    local delKey = (ns.UI_GetCharKey and char) and ns.UI_GetCharKey(char)
                        or (ns.Utilities and ns.Utilities.GetCanonicalCharacterKey and ns.Utilities:GetCanonicalCharacterKey(charKey))
                        or charKey
                    local success = WarbandNexus:DeleteCharacter(delKey)
                    if dialog.Close then
                        dialog:Close()
                    else
                        dialog:Hide()
                    end
                end)
                
                -- Cancel button (RIGHT)
                local cancelBtnLabel = (ns.L and ns.L["CANCEL"]) or "Cancel"
                local cancelBtn = CreateCharacterDeleteDialogButton(btnContainer, cancelBtnLabel, 150, false)
                cancelBtn:SetPoint("RIGHT", btnContainer, "RIGHT", 0, 0)
                cancelBtn:SetScript("OnClick", function()
                    if dialog.Close then
                        dialog:Close()
                    else
                        dialog:Hide()
                    end
                end)
                
                -- NOTE: Do NOT override dialog:SetScript("OnHide") here!
                -- WindowFactory's OnHide handles hiding the clickOutsideFrame overlay.
                -- Overriding it leaves an invisible full-screen mouse blocker.
                -- dialog.Close() already handles SetParent(nil) and _G cleanup.
                
                -- Show dialog
                dialog:Show()
            end)
        end
        
        if row.deleteBtn.icon then
            StyleCharacterDeleteIcon(row.deleteBtn.icon, false)
        end

        -- Tooltip for delete button
        if ShowTooltip and row.deleteBtn.SetScript then
            row.deleteBtn:SetScript("OnEnter", function(self)
                if self.icon then
                    StyleCharacterDeleteIcon(self.icon, true)
                end
                local removeFromTrackingFmt = (ns.L and ns.L["REMOVE_FROM_TRACKING_FORMAT"]) or "Remove %s from tracking"
                ShowTooltip(self, {
                    type = "custom",
                    icon = "Interface\\Icons\\Spell_Shadow_SacrificialShield",
                    title = (ns.L and ns.L["DELETE_CHARACTER"]) or "Delete Character",
                    titleColor = {1, 0.4, 0},
                    description = string.format(removeFromTrackingFmt, self.charName or ((ns.L and ns.L["THIS_CHARACTER"]) or "this character")),
                    lines = {
                        {text = "|cffff0000" .. ((ns.L and ns.L["CANNOT_UNDO"]) or "This action cannot be undone!") .. "|r", color = {1, 0.2, 0.2}},
                        {type = "spacer"},
                        {text = "|cff00ff00" .. ((ns.L and ns.L["CLICK_TO_DELETE"]) or "Click to delete") .. "|r", color = {1, 1, 1}}
                    },
                })
            end)
            
            row.deleteBtn:SetScript("OnLeave", function(self)
                if self.icon then
                    StyleCharacterDeleteIcon(self.icon, false)
                end
                if HideTooltip then
                    HideTooltip()
                end
            end)
        end
        
        row.deleteBtn:Show()
    else
        if row.deleteBtn then
            row.deleteBtn:Hide()
            -- Clear OnClick when hiding
            if row.deleteBtn.HasScript and row.deleteBtn:HasScript("OnClick") then
                row.deleteBtn:SetScript("OnClick", nil)
            end
        end
    end
    
    row._wnGradientRefresh = function()
        if not row.nameText or not row.realmText or not ns.UI_ApplyCharacterRowClassGradientAccent then
            return
        end
        local nameLeft = nameOffset + nameLeftPadding
        local nameColW = CHAR_ROW_COLUMNS.name.width
        local swName = row.nameText:GetStringWidth() or 0
        local swRealm = row.realmText:GetStringWidth() or 0
        local mailExtra = 0
        if row.mailIcon and row.mailIcon:IsShown() then
            mailExtra = (row.mailIcon:GetWidth() or 14) + 4
        end
        local nameBlockRight = nameLeft + math.min(nameColW, math.max(swName, swRealm) + mailExtra + 4)
        local gradientEnd = nameBlockRight
        local rowW = row._wnRowPaintWidth
        if not rowW or rowW < 2 then
            rowW = row:GetWidth() or 0
        end
        if rowW < 2 and parent and parent.GetWidth then
            rowW = parent:GetWidth() or rowW
        end
        if rowW < 2 then
            rowW = width or 800
        end
        if gradientEnd > rowW - 2 then
            gradientEnd = rowW - 2
        end
        ns.UI_ApplyCharacterRowClassGradientAccent(row, char.classFile, gradientEnd)
    end
    row._wnGradientRefresh()

    local betweenRows = GetLayout().betweenRows or 0
    return yOffset + 46 + betweenRows, row  -- Row 46px + spacing + row ref for pool tracking
end

function WarbandNexus:ReorderCharacter(char, charList, listKey, direction)
    if not char or not listKey then
        return
    end
    
    local charKey = GetCharKey(char)
    local favoriteKeySet = (ns.CharacterService and ns.CharacterService.BuildFavoriteKeySet)
        and ns.CharacterService:BuildFavoriteKeySet(self) or {}
    
    -- Don't update lastSeen when reordering (keep current timestamps)
    local currentPlayerKey = ns.Utilities:GetCharacterStorageKey(WarbandNexus)
    
    -- Get or initialize custom order
    if not self.db.profile.characterOrder then
        self.db.profile.characterOrder = {
            favorites = {},
            regular = {},
            untracked = {},
        }
    end
    
    if not self.db.profile.characterOrder[listKey] then
        self.db.profile.characterOrder[listKey] = {}
    end
    
    local customOrder = self.db.profile.characterOrder[listKey]
    
    -- If no custom order exists, seed from GetAllCharacters order for this category (online char first).
    if #customOrder == 0 then
        local allChars = self:GetAllCharacters()
        local keysInCategory = {}
        for i = 1, #allChars do
            local c = allChars[i]
            local key = GetCharKey(c)
            if key then
                local isTracked = c.isTracked ~= false
                local isFav = false
                if ns.CharacterService then
                    isFav = (ns.CharacterService.IsFavoriteFromKeySet and ns.CharacterService:IsFavoriteFromKeySet(favoriteKeySet, key))
                        or ns.CharacterService:IsFavoriteCharacter(self, key)
                end
                local inCategory = false
                if listKey == "favorites" then
                    inCategory = isTracked and isFav
                elseif listKey == "regular" then
                    local gid = (ns.CharacterService and ns.CharacterService.GetCharacterCustomSectionId)
                        and ns.CharacterService:GetCharacterCustomSectionId(self, key)
                        or (self.db.profile.characterGroupAssignments or {})[key]
                    inCategory = isTracked and not isFav and (gid == nil or gid == "")
                elseif listKey == "untracked" then
                    inCategory = not isTracked
                elseif ns.CharacterService and ns.CharacterService.ParseCustomGroupIdFromListKey then
                    local grpId = ns.CharacterService:ParseCustomGroupIdFromListKey(listKey)
                    if grpId then
                        local gid = (ns.CharacterService and ns.CharacterService.GetCharacterCustomSectionId)
                            and ns.CharacterService:GetCharacterCustomSectionId(self, key)
                            or (self.db.profile.characterGroupAssignments or {})[key]
                        inCategory = isTracked and not isFav and gid == grpId
                    end
                end
                if inCategory then
                    keysInCategory[#keysInCategory + 1] = key
                end
            end
        end
        local seen = {}
        if currentPlayerKey then
            for i = 1, #keysInCategory do
                if OrderKeyIsSessionChar(keysInCategory[i]) then
                    customOrder[1] = keysInCategory[i]
                    seen[keysInCategory[i]] = true
                    break
                end
            end
        end
        for i = 1, #keysInCategory do
            local k = keysInCategory[i]
            if not seen[k] then
                customOrder[#customOrder + 1] = k
                seen[k] = true
            end
        end
    end
    
    -- Find current index in custom order
    local currentIndex = nil
    for i = 1, #customOrder do
        local key = customOrder[i]
        if key == charKey then
            currentIndex = i
            break
        end
    end
    
    if not currentIndex then 
        -- Character not in custom order, add it
        tinsert(customOrder, charKey)
        currentIndex = #customOrder
    end
    
    -- Calculate new index
    local newIndex = currentIndex + direction
    
    if newIndex < 1 or newIndex > #customOrder then
        return
    end
    
    -- Swap in custom order
    customOrder[currentIndex], customOrder[newIndex] = customOrder[newIndex], customOrder[currentIndex]

    -- Logged-in character stays at top of this section after any reorder (matches DrawCharacterList).
    if currentPlayerKey then
        for i = 1, #customOrder do
            if OrderKeyIsSessionChar(customOrder[i]) then
                if i > 1 then
                    local c = tremove(customOrder, i)
                    tinsert(customOrder, 1, c)
                end
                break
            end
        end
    end
    
    -- Save and refresh
    self.db.profile.characterOrder[listKey] = customOrder
    
    local tk = ResolveSessionCharactersTableKey() or currentPlayerKey
    if self.db.global.characters and tk and self.db.global.characters[tk] then
        self.db.global.characters[tk].lastSeen = time()
    end
    
    WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
end

function WarbandNexus:OpenCustomCharacterHeaderDialog()
    local CreateExternalWindow = ns.UI_CreateExternalWindow
    local CreateThemedButton = ns.UI_CreateThemedButton
    local FontMgr = ns.FontManager
    if not CreateExternalWindow or not ns.UI or not ns.UI.Factory or not ns.UI.Factory.CreateEditBox or not ns.UI_CreateCustomHeaderRosterPicker then
        return
    end
    local L = ns.L
    local profile = self.db and self.db.profile
    local characters = (self.GetAllCharacters and self:GetAllCharacters()) or {}
    local dialogW = 820
    local innerW = dialogW - 36
    local dialog, contentFrame = CreateExternalWindow({
        name = "WnNewCustomHeaderDialog",
        title = (L and L["CUSTOM_HEADER_NEW_DIALOG_TITLE"]) or "New custom section",
        icon = "socialqueuing-icon-group",
        iconIsAtlas = true,
        width = dialogW,
        height = 620,
    })
    if not dialog or not contentFrame or not profile then return end

    local hint = FontMgr:CreateFontString(contentFrame, "body", "OVERLAY")
    hint:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 16, -10)
    hint:SetWidth(innerW)
    hint:SetJustifyH("LEFT")
    hint:SetJustifyV("TOP")
    if hint.SetWordWrap then hint:SetWordWrap(true) end
    ns.UI_SetTextColorRole(hint, "Normal")
    hint:SetText((L and L["CUSTOM_HEADER_NEW_DIALOG_HINT"]) or "Name (max 32). Search and tick optional. Enter in name saves.")

    local nameLabel = FontMgr:CreateFontString(contentFrame, "tabSubtitle", "OVERLAY")
    nameLabel:SetPoint("TOPLEFT", hint, "BOTTOMLEFT", 0, -12)
    nameLabel:SetWidth(innerW)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetTextColor(1, 0.92, 0.55)
    nameLabel:SetText((L and L["CUSTOM_HEADER_NEW_DIALOG_LABEL"]) or "Section name")

    local editBg = ns.UI.Factory:CreateContainer(contentFrame, innerW, 44, true)
    editBg:SetPoint("TOPLEFT", nameLabel, "BOTTOMLEFT", 0, -8)
    editBg:SetPoint("TOPRIGHT", nameLabel, "BOTTOMRIGHT", 0, -8)
    local eb = ns.UI.Factory:CreateEditBox(editBg)
    if eb.SetPoint then
        eb:SetPoint("TOPLEFT", editBg, "TOPLEFT", 10, -8)
        eb:SetPoint("BOTTOMRIGHT", editBg, "BOTTOMRIGHT", -10, 6)
    end
    eb:SetMaxLetters(32)
    eb:SetAutoFocus(true)

    local btnContainer = ns.UI.Factory:CreateContainer(contentFrame, innerW, 40)
    btnContainer:SetPoint("BOTTOM", contentFrame, "BOTTOM", 0, 14)
    local okLbl = (L and L["CUSTOM_HEADER_NEW_DIALOG_CREATE"]) or "Create section"
    local cancelLbl = CANCEL or "Cancel"

    local HF = ns.UI and ns.UI.Factory
    local host = HF and HF:CreateContainer(contentFrame, math.max(1, innerW), 420, false)
    if not host then
        host = CreateFrame("Frame", nil, contentFrame)
    end
    host:SetPoint("LEFT", contentFrame, "LEFT", 16, 0)
    host:SetPoint("RIGHT", contentFrame, "RIGHT", -16, 0)
    host:SetPoint("TOP", editBg, "BOTTOM", 0, -10)
    host:SetPoint("BOTTOM", btnContainer, "TOP", 0, 12)

    local picker = ns.UI_CreateCustomHeaderRosterPicker(host, innerW, WarbandNexus, profile, characters, nil)
    if picker and picker.frame then
        picker.frame:SetAllPoints(host)
    end

    local function trySubmit()
        local t = eb:GetText()
        if issecretvalue and issecretvalue(t) then return end
        if type(t) == "string" then
            t = t:match("^%s*(.-)%s*$") or ""
        else
            t = ""
        end
        if t == "" then
            return
        end
        local addedId
        if ns.CharacterService and ns.CharacterService.AddCustomCharacterSection then
            addedId = ns.CharacterService:AddCustomCharacterSection(WarbandNexus, t)
        end
        if not addedId then
            return
        end
        if picker and picker.GetSelectedKeys and ns.CharacterService and ns.CharacterService.SetCharacterCustomSection then
            local keys = picker.GetSelectedKeys()
            for i = 1, #keys do
                local raw = keys[i]
                if raw then
                    local k = (ns.Utilities and ns.Utilities.GetCanonicalCharacterKey and ns.Utilities:GetCanonicalCharacterKey(raw)) or raw
                    ns.CharacterService:SetCharacterCustomSection(WarbandNexus, k, addedId)
                end
            end
        end
        if dialog.Close then dialog:Close() else dialog:Hide() end
        WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
    end

    eb:SetScript("OnEnterPressed", function()
        trySubmit()
    end)

    local okBtn = CreateThemedButton and CreateThemedButton(btnContainer, okLbl, 168, 34)
    if okBtn then
        okBtn:SetPoint("LEFT", btnContainer, "LEFT", 0, 0)
        okBtn:SetScript("OnClick", trySubmit)
    end
    local cancelBtn = CreateThemedButton and CreateThemedButton(btnContainer, cancelLbl, 150, 34)
    if cancelBtn then
        cancelBtn:SetPoint("RIGHT", btnContainer, "RIGHT", 0, 0)
        cancelBtn:SetScript("OnClick", function()
            if dialog.Close then dialog:Close() else dialog:Hide() end
        end)
    end
    dialog:Show()
    if picker and picker.Rebuild and C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if picker.Rebuild then picker.Rebuild() end
        end)
    end
end

--- Modal roster editor for an existing custom header ([+] same UX as new section dialog).
function WarbandNexus:OpenCustomHeaderRosterWindow(groupId)
    local CreateExternalWindow = ns.UI_CreateExternalWindow
    local CreateThemedButton = ns.UI_CreateThemedButton
    local FontMgr = ns.FontManager
    if not groupId or not CreateExternalWindow or not ns.UI_CreateCustomHeaderRosterPicker then return end
    local profile = self.db and self.db.profile
    local characters = (self.GetAllCharacters and self:GetAllCharacters()) or {}
    if not profile then return end
    local L = ns.L
    local gtitle = tostring(groupId)
    local groups = profile.characterCustomGroups or {}
    for gi = 1, #groups do
        if groups[gi].id == groupId then
            gtitle = groups[gi].name or gtitle
            break
        end
    end
    local dialogW = 820
    local innerW = dialogW - 36
    local safeName = "WnHdrRoster_" .. tostring(groupId):gsub("[^%a%d_]", "_")
    local dialog, contentFrame = CreateExternalWindow({
        name = safeName,
        title = gtitle,
        icon = "socialqueuing-icon-group",
        iconIsAtlas = true,
        width = dialogW,
        height = 620,
    })
    if not dialog or not contentFrame then return end

    local hint = FontMgr:CreateFontString(contentFrame, "body", "OVERLAY")
    hint:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 16, -10)
    hint:SetWidth(innerW)
    hint:SetJustifyH("LEFT")
    if hint.SetWordWrap then hint:SetWordWrap(true) end
    ns.UI_SetTextColorRole(hint, "Normal")
    hint:SetText((L and L["CUSTOM_HEADER_ROSTER_WINDOW_HINT"]) or "First list: boxes start checked (still in this section). Uncheck to queue a removal. Second list: tick to queue adds. Nothing is saved until you click Add selected.")

    local btnContainer = ns.UI.Factory:CreateContainer(contentFrame, innerW, 40)
    btnContainer:SetPoint("BOTTOM", contentFrame, "BOTTOM", 0, 14)
    local addLbl = (L and L["CUSTOM_HEADER_ADD_SELECTED"]) or "Add selected"
    local closeLbl = (L and L["CUSTOM_HEADER_ROSTER_CLOSE"]) or "Close"

    local HF = ns.UI and ns.UI.Factory
    local host = HF and HF:CreateContainer(contentFrame, math.max(1, innerW), 480, false)
    if not host then
        host = CreateFrame("Frame", nil, contentFrame)
    end
    host:SetPoint("LEFT", contentFrame, "LEFT", 16, 0)
    host:SetPoint("RIGHT", contentFrame, "RIGHT", -16, 0)
    host:SetPoint("TOP", hint, "BOTTOM", 0, -10)
    host:SetPoint("BOTTOM", btnContainer, "TOP", 0, 12)

    local picker = ns.UI_CreateCustomHeaderRosterPicker(host, innerW, WarbandNexus, profile, characters, groupId)
    if picker and picker.frame then
        picker.frame:SetAllPoints(host)
    end

    local addBtn = CreateThemedButton and CreateThemedButton(btnContainer, addLbl, 200, 34)
    if addBtn and picker and picker.ApplyPendingAdds then
        addBtn:SetPoint("LEFT", btnContainer, "LEFT", 0, 0)
        addBtn:SetScript("OnClick", function()
            picker.ApplyPendingAdds()
            WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
        end)
    end
    local closeBtn = CreateThemedButton and CreateThemedButton(btnContainer, closeLbl, 160, 34)
    if closeBtn then
        closeBtn:SetPoint("RIGHT", btnContainer, "RIGHT", 0, 0)
        closeBtn:SetScript("OnClick", function()
            if dialog.Close then dialog:Close() else dialog:Hide() end
            WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
        end)
    end
    dialog:Show()
    if picker and picker.Rebuild and C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if picker.Rebuild then picker.Rebuild() end
        end)
    end
end

function WarbandNexus:ConfirmDeleteCustomCharacterHeader(groupId, groupName)
    local CreateExternalWindow = ns.UI_CreateExternalWindow
    local CreateThemedButton = ns.UI_CreateThemedButton
    local FontMgr = ns.FontManager
    if not groupId or not CreateExternalWindow then return end
    local dialog, contentFrame = CreateExternalWindow({
        name = "WnDeleteCustomHeaderDialog",
        title = (ns.L and ns.L["CUSTOM_HEADER_DELETE_DIALOG_TITLE"]) or "Delete custom header?",
        icon = "Interface\\DialogFrame\\UI-Dialog-Icon-AlertNew",
        width = 420,
        height = 190,
    })
    if not dialog or not contentFrame then return end
    local nm = groupName or groupId
    local warn = FontMgr:CreateFontString(contentFrame, "body", "OVERLAY")
    warn:SetPoint("TOP", contentFrame, "TOP", 0, -20)
    warn:SetWidth(380)
    warn:SetJustifyH("CENTER")
    local fmt = (ns.L and ns.L["CUSTOM_HEADER_DELETE_DIALOG_BODY"]) or "Remove header |cffffcc00%s|r and ungroup its characters?"
    warn:SetText(string.format(fmt, nm))

    local btnContainer = ns.UI.Factory:CreateContainer(contentFrame, 360, 40)
    btnContainer:SetPoint("BOTTOM", contentFrame, "BOTTOM", 0, 20)
    local delBtn = CreateThemedButton and CreateThemedButton(btnContainer, (ns.L and ns.L["DELETE"]) or "Delete", 150, 36)
    if delBtn then
        delBtn:SetPoint("LEFT", btnContainer, "LEFT", 0, 0)
        delBtn:SetScript("OnClick", function()
            if ns.CharacterService and ns.CharacterService.RemoveCustomCharacterSection then
                ns.CharacterService:RemoveCustomCharacterSection(WarbandNexus, groupId)
            end
            if dialog.Close then dialog:Close() else dialog:Hide() end
            WarbandNexus:SendMessage(E.UI_MAIN_REFRESH_REQUESTED, { skipCooldown = true })
        end)
    end
    local cancelBtn = CreateThemedButton and CreateThemedButton(btnContainer, (ns.L and ns.L["CANCEL"]) or "Cancel", 150, 36)
    if cancelBtn then
        cancelBtn:SetPoint("RIGHT", btnContainer, "RIGHT", 0, 0)
        cancelBtn:SetScript("OnClick", function()
            if dialog.Close then dialog:Close() else dialog:Hide() end
        end)
    end
    dialog:Show()
end

--- Section headers created by `CreateCollapsibleHeader` (stripe texture marker).
local function ForEachCharactersSectionHeader(scrollChild, fn)
    if not scrollChild or not fn or not scrollChild.GetNumChildren then return end
    local n = scrollChild:GetNumChildren() or 0
    for i = 1, n do
        local ch = select(i, scrollChild:GetChildren())
        if ch and ch._wnSectionStripe then
            fn(ch)
        end
    end
end

--- Walk scroll subtree for section virtual-list containers (`contentFrame._vlm_flatList`).
local function ForEachCharactersVirtualContainer(root, fn)
    if not root or type(fn) ~= "function" then return end
    if root._vlm_flatList and root._virtualUpdater then
        fn(root)
    end
    if not root.GetNumChildren then return end
    local n = root:GetNumChildren() or 0
    for i = 1, n do
        ForEachCharactersVirtualContainer(select(i, root:GetChildren()), fn)
    end
end

--- Resize: refresh section stack + virtual row widths without full tab rebuild when possible.
local function RelayoutCharactersSectionBodyWidths(scrollChild, viewportW, sideMargin)
    if not scrollChild or not viewportW or viewportW < 1 then return end
    local mf = WarbandNexus.UI and WarbandNexus.UI.mainFrame
    local metrics = ns.UI_GetMainTabLayoutMetrics and ns.UI_GetMainTabLayoutMetrics(mf)
    local guildW = WarbandNexus and WarbandNexus._charListMaxGuildWidth
    local stackW
    if ns.UI_ResolveCharactersTabRowWidth then
        stackW = ns.UI_ResolveCharactersTabRowWidth(mf, scrollChild, metrics, guildW)
    else
        stackW = math.max(1, viewportW - (sideMargin or 12) * 2)
    end
    if (not stackW or stackW < 1) and viewportW then
        stackW = math.max(1, viewportW - (sideMargin or 12) * 2)
    end
    stackW = math.max(1, stackW or 1)
    scrollChild._charsSectionStackW = stackW
    ForEachCharactersSectionHeader(scrollChild, function(header)
        if header.SetWidth then
            header:SetWidth(stackW)
        end
    end)
    ForEachCharactersVirtualContainer(scrollChild, function(container)
        if container.SetWidth then
            container:SetWidth(stackW)
        end
    end)
end

local function ResolveCharactersTabRowPaintWidth(mf, scrollChild, contentWidth, metrics, forLiveResize)
    if forLiveResize and ns.UI_ResolveCharactersTabRowWidthForLive then
        local liveW = ns.UI_ResolveCharactersTabRowWidthForLive(mf, scrollChild, metrics, WarbandNexus._charListMaxGuildWidth)
        if liveW and liveW >= 1 then
            return liveW
        end
    end
    local addon = WarbandNexus
    local guildW = addon and addon._charListMaxGuildWidth
    local rowW
    if ns.UI_ResolveCharactersTabRowWidth then
        rowW = ns.UI_ResolveCharactersTabRowWidth(mf, scrollChild, metrics, guildW)
    else
        rowW = (ns.UI_ComputeCharactersMinScrollWidth and ns.UI_ComputeCharactersMinScrollWidth(addon, guildW))
            or contentWidth
            or (scrollChild and scrollChild:GetWidth())
            or 0
    end
    if rowW < 1 then
        rowW = 1100
    end
    return rowW
end

local function RefreshCharactersVirtualLayout(scrollChild, contentWidth)
    if not scrollChild then return false end
    local mf = WarbandNexus.UI and WarbandNexus.UI.mainFrame
    local VLM = ns.VirtualListModule
    if not mf or not VLM then return false end
    local metrics = ns.UI_GetMainTabLayoutMetrics and ns.UI_GetMainTabLayoutMetrics(mf)
    local side = (metrics and metrics.sideMargin) or 12
    local viewportW = contentWidth
    if (not viewportW or viewportW < 1) and mf and ns.UI_GetMainTabViewportWidth then
        viewportW = ns.UI_GetMainTabViewportWidth(mf)
    end
    RelayoutCharactersSectionBodyWidths(scrollChild, viewportW, side)
    local rowW = ResolveCharactersTabRowPaintWidth(mf, scrollChild, contentWidth, metrics)
    if ns.UI_CloseCharacterTabFlyoutMenus then
        ns.UI_CloseCharacterTabFlyoutMenus()
    end
    if ns.UI_EnsureMainScrollLayout then
        ns.UI_EnsureMainScrollLayout()
    end
    local any = false
    ForEachCharactersVirtualContainer(scrollChild, function(container)
        local fl = container._vlm_flatList
        if fl then
            container._vlm_layoutTopOffset = nil
            for j = 1, #fl do
                local it = fl[j]
                if it and it.type == "row" and it.populateEntry then
                    it.populateEntry.rowWidth = rowW
                end
            end
            if VLM.RefreshVirtualListFlatList then
                VLM.RefreshVirtualListFlatList(mf, container, fl)
            elseif container._virtualUpdater then
                container._virtualUpdater()
            end
            any = true
        end
    end)
    CharactersVirtualScrollBump()
    if ns.UI_RefreshFixedHeaderChrome and mf then
        ns.UI_RefreshFixedHeaderChrome(mf)
    end
    if mf and mf.scrollChild and ns.UI_SyncMainTabScrollChrome then
        local bodyH = 0
        if scrollChild.GetHeight then
            bodyH = math.max(0, (scrollChild:GetHeight() or 0) - ((ns.UI_GetTabScrollContentStartY and ns.UI_GetTabScrollContentStartY()) or 8))
        end
        ns.UI_SyncMainTabScrollChrome(mf, scrollChild, bodyH)
    end
    if mf and mf.scroll and ns.UI and ns.UI.Factory and ns.UI.Factory.UpdateHorizontalScrollBarVisibility then
        ns.UI.Factory:UpdateHorizontalScrollBarVisibility(mf.scroll)
    end
    return any
end


local function CharactersTabViewportRelayout(scrollChild, contentWidth, mf)
    if not mf or mf.currentTab ~= "chars" then return false end
    if ns.UI_IsMainFrameResizeSession and ns.UI_IsMainFrameResizeSession(mf) then
        return true
    end
    RelayoutCharactersGoldCards(scrollChild, contentWidth, mf)
    if RefreshCharactersVirtualLayout(scrollChild, contentWidth) then
        return true
    end
    return false
end

if ns.UI_LayoutCoordinator then
    ns.UI_LayoutCoordinator:RegisterTabAdapter("chars", {
        OnViewportWidthChanged = CharactersTabViewportRelayout,
        OnViewportLayoutCommit = function(scrollChild, contentWidth, mf)
            if not mf or mf.currentTab ~= "chars" then return false end
            mf._wnCharsLiveRelayoutW = nil
            -- Single full redraw after corner-drag (gold mode, section offsets, columns, gradient).
            return false
        end,
    })
end
