GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.NewUIRoster = addon.NewUIRoster or {}

local page = addon.NewUIRoster
page.State = page.State or {}
local TEXT_VIEWER_AUTOSELECT_MAX_CHARS = 12000

local theme = addon.NewUITheme and addon.NewUITheme.Default or {
    Card = {
        Background = { 0.12, 0.12, 0.14, 0.98 },
        Border = { 0.34, 0.34, 0.40, 1 },
        Title = { 0.96, 0.93, 0.84, 1 },
        Value = { 0.72, 0.68, 0.58, 1 },
    },
    Panel = {
        Background = { 0.12, 0.12, 0.14, 0.98 },
        Border = { 0.34, 0.34, 0.40, 1 },
        Title = { 0.96, 0.93, 0.84, 1 },
    },
}

local PANEL_ART = { backgroundTextureKey = "MyToonsPanelBackground", borderTextureKey = "MyToonsPanelBorder" }
local CARD_ART = { backgroundTextureKey = "MyToonsCardBackground", borderTextureKey = "MyToonsCardBorder" }
local BUTTON_ART = { backgroundTextureKey = "MyToonsButtonBackground", selectedBackgroundTextureKey = "MyToonsButtonSelected", borderTextureKey = "MyToonsButtonBorder" }
local ROW_TEXTURE_KEY = "MyToonsRowBackground"
local CLASS_ICON_TEXTURE = CLASS_ICON_TEXTURE or "Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES"
local FIXED_COLUMN_DEFINITIONS = {
    { key = "Toon", text = "Toon", width = 218, align = "LEFT" },
    { key = "Actions", text = "Actions", width = 72, align = "CENTER" },
}
local LEGACY_COLUMN_DEFINITIONS = {
    { key = "Main Toon", valueKey = "mainCharacter", text = "Main Toon", width = 170, align = "LEFT" },
    { key = "Role", valueKey = "role", text = "Role", width = 92, align = "LEFT" },
    { key = "Spec", valueKey = "spec", text = "Spec", width = 170, align = "LEFT" },
    { key = "Item Level", valueKey = "itemLevel", text = "iLvl", width = 62, align = "CENTER" },
    { key = "Rank", valueKey = "rank", text = "Rank", width = 138, align = "LEFT" },
    { key = "Readiness", valueKey = "readiness", text = "Readiness", width = 232, align = "LEFT" },
    { key = "Guild Name", valueKey = "guildName", text = "Guild Name", width = 166, align = "LEFT" },
    { key = "Realm", valueKey = "realm", text = "Realm", width = 118, align = "LEFT" },
    { key = "Faction", valueKey = "faction", text = "Faction", width = 108, align = "LEFT" },
    { key = "Joined Guild", valueKey = "guildJoinDate", text = "Joined Guild", width = 128, align = "LEFT" },
    { key = "Class", valueKey = "className", text = "Class", width = 116, align = "LEFT" },
    { key = "Birthdate", valueKey = "birthdate", text = "Birthdate", width = 118, align = "LEFT" },
    { key = "Roles", valueKey = "roles", text = "Roles", width = 132, align = "LEFT" },
    { key = "Interests", valueKey = "interests", text = "Interests", width = 178, align = "LEFT" },
    { key = "Professions", valueKey = "professions", text = "Professions", width = 178, align = "LEFT" },
    { key = "BattleTag", valueKey = "battleTag", text = "BattleTag", width = 162, align = "LEFT" },
}
local DEFAULT_SELECTED_COLUMNS = {
    ["Main Toon"] = true,
    ["Role"] = true,
    ["Spec"] = true,
    ["Item Level"] = true,
    ["Rank"] = true,
    ["Readiness"] = true,
}
local MAX_SELECTED_COLUMNS = 6

local refreshPage
local trimString
local normalizeClassToken
local getClassColor
local createCardBand
local getReadableProgressBarColor

local function copyMap(source)
    local result = {}
    for key, value in pairs(type(source) == "table" and source or {}) do
        result[key] = value
    end
    return result
end

local function joinList(values)
    if type(values) ~= "table" then
        local text = trimString(values)
        return text ~= "" and text or "-"
    end

    local parts = {}
    for _, value in ipairs(type(values) == "table" and values or {}) do
        local text = trimString(value)
        if text ~= "" then
            parts[#parts + 1] = text
        end
    end
    return #parts > 0 and table.concat(parts, ", ") or "-"
end

local function findLegacyColumnDefinition(columnKey)
    for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
        if definition.key == columnKey then
            return definition
        end
    end
    return nil
end

local function buildVisibleColumnDefinitions(selectedColumns)
    local definitions = { FIXED_COLUMN_DEFINITIONS[1] }
    for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
        if selectedColumns[definition.key] then
            definitions[#definitions + 1] = definition
        end
    end
    definitions[#definitions + 1] = FIXED_COLUMN_DEFINITIONS[2]
    return definitions
end

local function normalizeSelectedColumns(selectedColumns)
    local normalized = copyMap(DEFAULT_SELECTED_COLUMNS)
    local source = type(selectedColumns) == "table" and selectedColumns or {}
    local migrationMap = {
        MainToon = "Main Toon",
        ItemLevel = "Item Level",
        Rank = "Rank",
        Spec = "Spec",
        Role = "Role",
        Readiness = "Readiness",
    }

    for key, value in pairs(source) do
        local resolvedKey = migrationMap[key] or key
        if findLegacyColumnDefinition(resolvedKey) then
            normalized[resolvedKey] = value and true or false
        end
    end

    local capped = {}
    local selectedCount = 0
    for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
        if normalized[definition.key] and selectedCount < MAX_SELECTED_COLUMNS then
            capped[definition.key] = true
            selectedCount = selectedCount + 1
        else
            capped[definition.key] = false
        end
    end

    return capped
end

local function countSelectedColumns(selectedColumns)
    local count = 0
    for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
        if selectedColumns[definition.key] then
            count = count + 1
        end
    end
    return count
end

local function resolveVisibleColumns(baseColumns, selectedColumns)
    local resolved = {}
    local definitions = buildVisibleColumnDefinitions(selectedColumns)
    local startX = (baseColumns.Toon and baseColumns.Toon.x) or 12
    local totalWidth = math.max(1, (((baseColumns.Actions and baseColumns.Actions.x) or 1152) + ((baseColumns.Actions and baseColumns.Actions.w) or 72)) - startX)
    local fixedWidth = (FIXED_COLUMN_DEFINITIONS[1].width or 0) + (FIXED_COLUMN_DEFINITIONS[2].width or 0)
    local preferredFlexWidth = 0
    for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
        if selectedColumns[definition.key] then
            preferredFlexWidth = preferredFlexWidth + (definition.width or 100)
        end
    end
    local availableFlexWidth = math.max(0, totalWidth - fixedWidth)
    local scale = preferredFlexWidth > availableFlexWidth and (availableFlexWidth / preferredFlexWidth) or 1
    local cursorX = startX

    for index, definition in ipairs(definitions) do
        local width = definition.width or 100
        if definition.key ~= "Toon" and definition.key ~= "Actions" then
            width = math.max(60, math.floor(width * scale + 0.5))
        end
        if definition.key == "Actions" then
            width = FIXED_COLUMN_DEFINITIONS[2].width or width
            cursorX = startX + totalWidth - width
        end
        resolved[definition.key] = { x = cursorX, w = width, text = definition.key, align = definition.align or "LEFT" }
        if definition.key ~= "Actions" then
            cursorX = cursorX + width
        end
    end

    return resolved, definitions
end

trimString = function(value)
    return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end

local function roundNumber(value)
    return math.floor((tonumber(value) or 0) + 0.5)
end

local function splitFullName(fullName)
    if addon.Store and addon.Store.SplitFullName then
        return addon.Store:SplitFullName(fullName, GetRealmName() or "Unknown")
    end

    local name, realm = tostring(fullName or ""):match("^([^-]+)%-(.+)$")
    return name or trimString(fullName) or "Unknown", realm or (GetRealmName() or "Unknown")
end

local function normalizeRoleLabel(value)
    local normalized = string.lower(trimString(value))

    if string.find(normalized, "tank", 1, true) then
        return "Tank"
    end

    if string.find(normalized, "heal", 1, true) then
        return "Healer"
    end

    if normalized == "" then
        return "DPS"
    end

    return "DPS"
end

local function buildRolesValue(member)
    local roles = {}
    local seen = {}

    local function addRole(value)
        local raw = trimString(value)
        if raw == "" then
            return
        end

        local label = normalizeRoleLabel(raw)
        if label ~= "" and not seen[label] then
            seen[label] = true
            roles[#roles + 1] = label
        end
    end

    addRole(member and (member.primaryRole or member.role))

    if type(member and member.roles) == "table" then
        for _, value in ipairs(member.roles) do
            addRole(value)
        end
    elseif trimString(member and member.roles) ~= "" then
        addRole(member and member.roles)
    end

    if type(member and member.secondaryRoles) == "table" then
        for _, value in ipairs(member.secondaryRoles) do
            addRole(value)
        end
    elseif trimString(member and member.secondaryRoles) ~= "" then
        addRole(member and member.secondaryRoles)
    end

    return #roles > 0 and table.concat(roles, ", ") or "-"
end

local function formatItemLevel(value)
    local numeric = tonumber(value)

    if numeric and numeric > 0 then
        return tostring(roundNumber(numeric))
    end

    return "-"
end

local function formatTimestamp(value)
    local timestamp = tonumber(value) or 0

    if timestamp <= 0 then
        return "Unknown"
    end

    return date("%b %d, %I:%M %p", timestamp)
end

local function resolveTexturePath(textureKeyOrPath)
    if type(textureKeyOrPath) ~= "string" or textureKeyOrPath == "" then
        return nil
    end

    local textures = addon.NewUIAssets and addon.NewUIAssets.Textures or nil
    if textures and textures[textureKeyOrPath] ~= nil then
        return textures[textureKeyOrPath] ~= "" and textures[textureKeyOrPath] or nil
    end

    return textureKeyOrPath
end

local function resolveArtMetrics(textureKey)
    local metrics = addon.NewUIAssets and addon.NewUIAssets.Metrics or nil
    return metrics and metrics[textureKey] or nil
end

local function resolveAsset(textureKey, fallbackInsets, fallbackWidth, fallbackHeight)
    local metrics = resolveArtMetrics(textureKey) or {}
    return {
        texture = resolveTexturePath(textureKey),
        sourceWidth = tonumber(metrics.sourceWidth) or tonumber(fallbackWidth) or 1,
        sourceHeight = tonumber(metrics.sourceHeight) or tonumber(fallbackHeight) or 1,
        insets = metrics.insets or fallbackInsets or {},
        texCoord = metrics.texCoord,
    }
end

local function applyRect(frame, parent, rect)
    if not frame or not parent or not rect then
        return
    end

    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", parent, "TOPLEFT", rect.x or 0, rect.y or 0)
    frame:SetSize(rect.w or 1, rect.h or 1)
end

local function applyArtTexture(texture, textureKey, fallbackColor)
    local texturePath = resolveTexturePath(textureKey)
    local metrics = resolveArtMetrics(textureKey)

    if texturePath then
        texture:SetTexture(texturePath)
        texture:SetVertexColor(1, 1, 1, 0.98)
        if metrics and type(metrics.texCoord) == "table" and #metrics.texCoord == 4 then
            texture:SetTexCoord(metrics.texCoord[1], metrics.texCoord[3], metrics.texCoord[2], metrics.texCoord[4])
        else
            texture:SetTexCoord(0, 1, 0, 1)
        end
        return
    end

    texture:SetColorTexture(unpack(fallbackColor or { 0.10, 0.10, 0.12, 0.95 }))
end

local function createArtContentFrame(parent, textureKey, width, height, fallbackInsets)
    local metrics = resolveArtMetrics(textureKey)
    local sourceWidth = metrics and tonumber(metrics.sourceWidth) or tonumber(width) or 0
    local sourceHeight = metrics and tonumber(metrics.sourceHeight) or tonumber(height) or 0
    local metricInsets = metrics and metrics.insets or fallbackInsets or {}
    local scaleX = sourceWidth > 0 and (tonumber(width) or 0) / sourceWidth or 1
    local scaleY = sourceHeight > 0 and (tonumber(height) or 0) / sourceHeight or 1
    local insetLeft = math.floor(((tonumber(metricInsets.left) or 0) * scaleX) + 0.5)
    local insetRight = math.floor(((tonumber(metricInsets.right) or 0) * scaleX) + 0.5)
    local insetTop = math.floor(((tonumber(metricInsets.top) or 0) * scaleY) + 0.5)
    local insetBottom = math.floor(((tonumber(metricInsets.bottom) or 0) * scaleY) + 0.5)
    local content = CreateFrame("Frame", nil, parent)
    content:SetPoint("TOPLEFT", parent, "TOPLEFT", insetLeft, -insetTop)
    content:SetSize(math.max(1, (tonumber(width) or 0) - insetLeft - insetRight), math.max(1, (tonumber(height) or 0) - insetTop - insetBottom))
    content.Insets = {
        left = insetLeft,
        right = insetRight,
        top = insetTop,
        bottom = insetBottom,
    }
    return content
end

local function createArtFrame(parent, rect, asset)
    local frame = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    applyRect(frame, parent, rect)

    if frame.SetBackdrop then
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0, 0, 0, 0)
        frame:SetBackdropBorderColor(0, 0, 0, 0)
    end

    local texture = frame:CreateTexture(nil, "BACKGROUND")
    texture:SetAllPoints(frame)
    applyArtTexture(texture, asset and asset.texture, { 0.10, 0.10, 0.12, 0.94 })
    if asset and type(asset.texCoord) == "table" and #asset.texCoord == 4 then
        texture:SetTexCoord(asset.texCoord[1], asset.texCoord[3], asset.texCoord[2], asset.texCoord[4])
    end
    frame.Texture = texture

    local content = createArtContentFrame(
        frame,
        asset and asset.texture,
        rect and rect.w,
        rect and rect.h,
        asset and asset.insets or { left = 16, right = 16, top = 12, bottom = 12 }
    )
    frame.Content = content
    return frame
end

local function createPanel(parent, rect, titleText)
    local panel = createArtFrame(parent, rect, resolveAsset(PANEL_ART.backgroundTextureKey, { left = 24, right = 24, top = 42, bottom = 22 }, rect.w, rect.h))
    local innerContent = panel.Content
    local insets = innerContent.Insets or { left = 24, right = 24, top = 42, bottom = 22 }
    local headerTop = math.max(8, math.floor(((tonumber(insets.top) or 42) * 0.33) + 0.5))

    local headerBand = panel:CreateTexture(nil, "ARTWORK")
    headerBand:SetPoint("TOPLEFT", panel, "TOPLEFT", tonumber(insets.left) or 24, -headerTop)
    headerBand:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -(tonumber(insets.right) or 24), -headerTop)
    headerBand:SetHeight(22)
    applyArtTexture(headerBand, "HomePanelHeader", { 0.08, 0.08, 0.10, 0.82 })
    headerBand:SetVertexColor(1, 1, 1, 0.98)

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("CENTER", headerBand, "CENTER", 0, -1)
    title:SetText(titleText)
    title:SetTextColor(0.98, 0.92, 0.76, 1)

    local headerRule = panel:CreateTexture(nil, "ARTWORK")
    headerRule:SetPoint("BOTTOMLEFT", headerBand, "BOTTOMLEFT", 0, 0)
    headerRule:SetPoint("BOTTOMRIGHT", headerBand, "BOTTOMRIGHT", 0, 0)
    headerRule:SetHeight(1)
    headerRule:SetColorTexture(0.58, 0.44, 0.16, 0.72)

    local body = CreateFrame("Frame", nil, panel)
    body:SetPoint("TOPLEFT", innerContent, "TOPLEFT", 0, 0)
    body:SetPoint("BOTTOMRIGHT", innerContent, "BOTTOMRIGHT", 0, 0)

    panel.HeaderBand = headerBand
    panel.TitleText = title
    panel.Body = body
    panel.Content = body
    return panel
end

local function createButton(parent, rect, labelText, iconPath, onClick)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    applyRect(button, parent, rect)

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, BUTTON_ART.backgroundTextureKey, { 0.14, 0.12, 0.08, 0.95 })
    button.Background = background

    local content = createArtContentFrame(button, BUTTON_ART.backgroundTextureKey, rect.w, rect.h, { left = 18, right = 18, top = 10, bottom = 10 })
    local icon = nil
    if trimString(iconPath) ~= "" then
        icon = button:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("LEFT", content, "LEFT", 0, 0)
        icon:SetSize(16, 16)
        icon:SetTexture(iconPath)
        icon:SetVertexColor(1, 1, 1, 0.95)
    end

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    text:SetPoint(icon and "LEFT" or "CENTER", icon or content, icon and "RIGHT" or "CENTER", icon and 8 or 0, 0)
    text:SetWidth(math.max(1, rect.w - (icon and 36 or 12)))
    text:SetWordWrap(false)
    text:SetJustifyH(icon and "LEFT" or "CENTER")
    text:SetText(labelText)
    text:SetTextColor(0.96, 0.93, 0.84, 1)

    button:SetScript("OnEnter", function()
        button.Background:SetVertexColor(1, 1, 1, 1)
    end)
    button:SetScript("OnLeave", function()
        button.Background:SetVertexColor(1, 1, 1, 0.98)
    end)
    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    button.Content = content
    button.Icon = icon
    button.Text = text
    return button
end

local CLASS_DISPLAY_NAMES = {
    DEATHKNIGHT = "Death Knight",
    DEMONHUNTER = "Demon Hunter",
    DRUID = "Druid",
    EVOKER = "Evoker",
    HUNTER = "Hunter",
    MAGE = "Mage",
    MONK = "Monk",
    PALADIN = "Paladin",
    PRIEST = "Priest",
    ROGUE = "Rogue",
    SHAMAN = "Shaman",
    WARLOCK = "Warlock",
    WARRIOR = "Warrior",
}

local CLASS_SPEC_COMBAT_PROFILES = {
    DEATHKNIGHT = { blood = "tank", frost = "melee", unholy = "melee" },
    DEMONHUNTER = { havoc = "melee", vengeance = "tank" },
    DRUID = { balance = "ranged", feral = "melee", guardian = "tank", restoration = "healer" },
    EVOKER = { augmentation = "ranged", devastation = "ranged", preservation = "healer" },
    HUNTER = { beastmastery = "ranged", marksmanship = "ranged", survival = "melee" },
    MAGE = { arcane = "ranged", fire = "ranged", frost = "ranged" },
    MONK = { brewmaster = "tank", mistweaver = "healer", windwalker = "melee" },
    PALADIN = { holy = "healer", protection = "tank", retribution = "melee" },
    PRIEST = { discipline = "healer", holy = "healer", shadow = "ranged" },
    ROGUE = { assassination = "melee", outlaw = "melee", subtlety = "melee" },
    SHAMAN = { elemental = "ranged", enhancement = "melee", restoration = "healer" },
    WARLOCK = { affliction = "ranged", demonology = "ranged", destruction = "ranged" },
    WARRIOR = { arms = "melee", fury = "melee", protection = "tank" },
}

local CLASS_DPS_COMBAT_PROFILES = {
    DEATHKNIGHT = "melee",
    DEMONHUNTER = "melee",
    DRUID = "ranged",
    EVOKER = "ranged",
    HUNTER = "ranged",
    MAGE = "ranged",
    MONK = "melee",
    PALADIN = "melee",
    PRIEST = "ranged",
    ROGUE = "melee",
    SHAMAN = "ranged",
    WARLOCK = "ranged",
    WARRIOR = "melee",
}

local RECOMMENDED_SPEC_POOLS = {
    healer = { "Restoration Shaman", "Holy Priest", "Discipline Priest", "Restoration Druid", "Mistweaver Monk", "Holy Paladin", "Preservation Evoker" },
    ranged = { "Augmentation Evoker", "Balance Druid", "Elemental Shaman", "Shadow Priest", "Fire Mage", "Destruction Warlock", "Devastation Evoker" },
    tank = { "Protection Paladin", "Guardian Druid", "Vengeance Demon Hunter", "Blood Death Knight", "Protection Warrior", "Brewmaster Monk" },
}

local function normalizeStatsKey(value)
    return string.lower((trimString(value):gsub("[^%a]", "")))
end

local function buildSpecDisplayLabel(row)
    local spec = trimString(row and row.spec)
    local classLabel = trimString(row and row.className)
    local normalizedRole = normalizeRoleLabel(row and row.role)

    if spec == "" then
        return nil
    end

    if spec == normalizedRole or spec == "Tank" or spec == "Healer" or spec == "DPS" then
        return nil
    end

    if classLabel ~= "" and not string.find(string.lower(spec), string.lower(classLabel), 1, true) then
        return spec .. " " .. classLabel
    end

    return spec
end

local function buildRosterStats(rows)
    local stats = {
        totalCharacters = #(rows or {}),
        uniquePlayers = 0,
        tanks = 0,
        healers = 0,
        dps = 0,
        meleeDps = 0,
        rangedDps = 0,
        classRows = {},
        specRows = {},
        lowestClasses = {},
        gapLines = {},
        recommendations = {},
    }
    local uniquePlayers = {}
    local classCounts = {}
    local specCounts = {}

    for _, row in ipairs(rows or {}) do
        local playerKey = string.lower(trimString(row.mainCharacter ~= "" and row.mainCharacter or row.mainToon ~= "" and row.mainToon or row.name))
        if playerKey ~= "" then
            uniquePlayers[playerKey] = true
        end

        local classToken = normalizeClassToken(row.classFile, row.className)
        local classLabel = trimString(row.className)
        if classLabel == "" then
            classLabel = CLASS_DISPLAY_NAMES[classToken] or "Unknown"
        end
        local classKey = classToken ~= "" and classToken or classLabel
        if not classCounts[classKey] then
            classCounts[classKey] = {
                key = classKey,
                label = classLabel,
                classToken = classToken,
                count = 0,
                color = { getClassColor(classLabel) },
            }
        end
        classCounts[classKey].count = classCounts[classKey].count + 1

        local specLabel = buildSpecDisplayLabel(row)
        local specKey = normalizeStatsKey(specLabel)
        if specKey ~= "" then
            if not specCounts[specKey] then
                specCounts[specKey] = {
                    key = specKey,
                    label = specLabel,
                    classToken = classToken,
                    classLabel = classLabel,
                    count = 0,
                    color = { getClassColor(classLabel) },
                }
            end
            specCounts[specKey].count = specCounts[specKey].count + 1
        end

        local role = normalizeRoleLabel(row.role)
        if role == "Tank" then
            stats.tanks = stats.tanks + 1
        elseif role == "Healer" then
            stats.healers = stats.healers + 1
        else
            local profileMap = CLASS_SPEC_COMBAT_PROFILES[classToken] or {}
            local combatProfile = profileMap[specKey] or CLASS_DPS_COMBAT_PROFILES[classToken] or "melee"
            stats.dps = stats.dps + 1
            if combatProfile == "ranged" then
                stats.rangedDps = stats.rangedDps + 1
            else
                stats.meleeDps = stats.meleeDps + 1
            end
        end
    end

    for _ in pairs(uniquePlayers) do
        stats.uniquePlayers = stats.uniquePlayers + 1
    end

    for _, entry in pairs(classCounts) do
        entry.percent = stats.totalCharacters > 0 and ((entry.count / stats.totalCharacters) * 100) or 0
        stats.classRows[#stats.classRows + 1] = entry
    end
    table.sort(stats.classRows, function(left, right)
        if left.count == right.count then
            return left.label < right.label
        end
        return left.count > right.count
    end)

    for _, entry in pairs(specCounts) do
        stats.specRows[#stats.specRows + 1] = entry
    end
    table.sort(stats.specRows, function(left, right)
        if left.count == right.count then
            return left.label < right.label
        end
        return left.count > right.count
    end)

    local lowestClassRows = {}
    for index, entry in ipairs(stats.classRows) do
        lowestClassRows[index] = entry
    end
    table.sort(lowestClassRows, function(left, right)
        if left.count == right.count then
            return left.label < right.label
        end
        return left.count < right.count
    end)
    for index = 1, math.min(3, #lowestClassRows) do
        stats.lowestClasses[index] = lowestClassRows[index]
    end

    local healerPercent = stats.totalCharacters > 0 and ((stats.healers / stats.totalCharacters) * 100) or 0
    local tankPercent = stats.totalCharacters > 0 and ((stats.tanks / stats.totalCharacters) * 100) or 0
    local meleePercent = stats.totalCharacters > 0 and ((stats.meleeDps / stats.totalCharacters) * 100) or 0
    local rangedPercent = stats.totalCharacters > 0 and ((stats.rangedDps / stats.totalCharacters) * 100) or 0
    stats.healerPercent = healerPercent
    stats.tankPercent = tankPercent
    stats.meleePercent = meleePercent
    stats.rangedPercent = rangedPercent

    if healerPercent < 18 then
        stats.gapLines[#stats.gapLines + 1] = string.format("Low on healers for raid coverage. Current %.1f%% of tracked characters.", healerPercent)
    end
    if stats.rangedDps < stats.meleeDps then
        stats.gapLines[#stats.gapLines + 1] = string.format("Ranged DPS depth is lower than melee (%.1f%% vs %.1f%%).", rangedPercent, meleePercent)
    end
    if stats.tanks < math.max(2, math.ceil(stats.totalCharacters * 0.08)) then
        stats.gapLines[#stats.gapLines + 1] = string.format("Tank coverage is light for the roster size. Current %.1f%% of tracked characters.", tankPercent)
    end
    if stats.lowestClasses[1] then
        stats.gapLines[#stats.gapLines + 1] = string.format("%s is the thinnest class bench right now with %d tracked characters.", stats.lowestClasses[1].label, stats.lowestClasses[1].count)
    end

    local function addRecommendations(poolKey)
        local pool = RECOMMENDED_SPEC_POOLS[poolKey] or {}
        local ranked = {}
        for _, label in ipairs(pool) do
            local key = normalizeStatsKey(label)
            local count = specCounts[key] and specCounts[key].count or 0
            ranked[#ranked + 1] = { label = label, count = count }
        end
        table.sort(ranked, function(left, right)
            if left.count == right.count then
                return left.label < right.label
            end
            return left.count < right.count
        end)
        for _, entry in ipairs(ranked) do
            local exists = false
            for _, label in ipairs(stats.recommendations) do
                if label == entry.label then
                    exists = true
                    break
                end
            end
            if not exists then
                stats.recommendations[#stats.recommendations + 1] = entry.label
            end
            if #stats.recommendations >= 3 then
                return
            end
        end
    end

    if healerPercent < 18 then
        addRecommendations("healer")
    end
    if stats.rangedDps < stats.meleeDps then
        addRecommendations("ranged")
    end
    if stats.tanks < math.max(2, math.ceil(stats.totalCharacters * 0.08)) then
        addRecommendations("tank")
    end
    if #stats.recommendations == 0 then
        addRecommendations("healer")
        addRecommendations("ranged")
    end
    while #stats.gapLines < 3 do
        stats.gapLines[#stats.gapLines + 1] = "Current roster spread is healthy, but filling low-count specs will improve raid flexibility."
    end

    return stats
end

local function buildRosterStatsExportText(stats)
    local lines = {
        "Roster Stats",
        "",
        string.format("Total Characters: %d", stats.totalCharacters or 0),
        string.format("Unique Players: %d", stats.uniquePlayers or 0),
        string.format("Tanks: %d", stats.tanks or 0),
        string.format("Healers: %d", stats.healers or 0),
        string.format("DPS: %d", stats.dps or 0),
        string.format("Melee DPS: %d", stats.meleeDps or 0),
        string.format("Ranged DPS: %d", stats.rangedDps or 0),
        "",
        "Class Distribution",
    }

    for _, entry in ipairs(stats.classRows or {}) do
        lines[#lines + 1] = string.format("- %s: %d (%.1f%%)", entry.label, entry.count, entry.percent or 0)
    end

    lines[#lines + 1] = ""
    lines[#lines + 1] = "Top Specs"
    for index = 1, math.min(10, #(stats.specRows or {})) do
        local entry = stats.specRows[index]
        lines[#lines + 1] = string.format("- %s: %d", entry.label, entry.count)
    end

    lines[#lines + 1] = ""
    lines[#lines + 1] = "Suggested Gaps"
    for _, line in ipairs(stats.gapLines or {}) do
        lines[#lines + 1] = "- " .. line
    end

    lines[#lines + 1] = ""
    lines[#lines + 1] = "Recommended Level-Up Options"
    for _, label in ipairs(stats.recommendations or {}) do
        lines[#lines + 1] = "- " .. label
    end

    return table.concat(lines, "\n")
end

local function createTextExportOverlay(parent, options, onClose)
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 44)

    local dim = overlay:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints(overlay)
    dim:SetColorTexture(0.02, 0.02, 0.03, 0.78)

    local dialog = createArtFrame(overlay, { x = 120, y = -54, w = 1140, h = 626 }, resolveAsset(PANEL_ART.backgroundTextureKey, { left = 24, right = 24, top = 42, bottom = 22 }, 1140, 626))
    dialog:SetFrameStrata(overlay:GetFrameStrata())
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 5)

    local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", dialog.Content, "TOPLEFT", 10, -2)
    title:SetText(options and options.title or "Export")
    title:SetTextColor(0.98, 0.92, 0.76, 1)

    local helper = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    helper:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    helper:SetWidth(1004)
    helper:SetJustifyH("LEFT")
    helper:SetText(options and options.helpText or "")
    helper:SetTextColor(0.82, 0.78, 0.68, 1)

    local scrollFrame = CreateFrame("ScrollFrame", nil, dialog.Content, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", dialog.Content, "TOPLEFT", 10, -58)
    scrollFrame:SetPoint("BOTTOMRIGHT", dialog.Content, "BOTTOMRIGHT", -28, 50)

    local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
    if editBox.Left then editBox.Left:SetAlpha(0) end
    if editBox.Middle then editBox.Middle:SetAlpha(0) end
    if editBox.Right then editBox.Right:SetAlpha(0) end
    editBox:SetMultiLine(true)
    editBox:SetAutoFocus(false)
    editBox:SetFontObject("ChatFontNormal")
    editBox:SetWidth(1068)
    editBox:SetTextInsets(6, 6, 6, 6)
    editBox:SetText(options and options.text or "")
    editBox:SetTextColor(0.96, 0.93, 0.84, 1)
    editBox:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    scrollFrame:SetScrollChild(editBox)

    createButton(dialog.Content, { x = 864, y = -544, w = 140, h = 34 }, "Close", nil, onClose)

    overlay.Dialog = dialog
    overlay.EditBox = editBox
    editBox:SetCursorPosition(0)
    if string.len(tostring(options and options.text or "")) <= TEXT_VIEWER_AUTOSELECT_MAX_CHARS then
        editBox:HighlightText()
        editBox:SetFocus()
    else
        editBox:HighlightText(0, 0)
        editBox:ClearFocus()
    end
    return overlay
end

local function createRosterStatsOverlay(parent, rows, onClose, onExport, onViewRoster)
    local stats = buildRosterStats(rows)
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 40)

    local dim = overlay:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints(overlay)
    dim:SetColorTexture(0.02, 0.02, 0.03, 0.72)

    local dialog = createArtFrame(overlay, { x = 10, y = -8, w = 1360, h = 724 }, resolveAsset(PANEL_ART.backgroundTextureKey, { left = 24, right = 24, top = 42, bottom = 22 }, 1360, 724))
    dialog:SetFrameStrata(overlay:GetFrameStrata())
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 5)

    local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", dialog.Content, "TOPLEFT", 10, -2)
    title:SetText("Roster Stats")
    title:SetTextColor(0.98, 0.92, 0.76, 1)

    local subtitle = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    subtitle:SetText("Class, spec, and role breakdown for your guild roster")
    subtitle:SetTextColor(0.82, 0.78, 0.68, 1)

    local closeButton = createButton(dialog.Content, { x = 1238, y = 0, w = 42, h = 34 }, "X", nil, onClose)
    local function createMetricCard(rect, textureKey, iconPath, labelText, valueText, valueColor)
        local card = createArtFrame(dialog.Content, rect, resolveAsset(textureKey, { left = 18, right = 18, top = 12, bottom = 12 }, rect.w, rect.h))
        local icon = card.Content:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("TOPLEFT", card.Content, "TOPLEFT", 10, -6)
        icon:SetSize(18, 18)
        icon:SetTexture(iconPath)

        local label = card.Content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("LEFT", icon, "RIGHT", 8, 0)
        label:SetText(labelText)
        label:SetTextColor(0.98, 0.82, 0.42, 1)

        local value = card.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        value:SetPoint("CENTER", card.Content, "CENTER", 0, 0)
        value:SetWidth(math.max(1, card.Content:GetWidth() - 18))
        value:SetJustifyH("CENTER")
        value:SetText(valueText)
        value:SetTextColor(valueColor[1], valueColor[2], valueColor[3], valueColor[4])
        return card
    end

    local function createCompactSection(rect, titleText)
        local section = createArtFrame(dialog.Content, rect, resolveAsset(PANEL_ART.backgroundTextureKey, { left = 18, right = 18, top = 16, bottom = 14 }, rect.w, rect.h))
        local headerBand = section:CreateTexture(nil, "ARTWORK")
        headerBand:SetPoint("TOPLEFT", section, "TOPLEFT", 16, -4)
        headerBand:SetPoint("TOPRIGHT", section, "TOPRIGHT", -16, -4)
        headerBand:SetHeight(22)
        applyArtTexture(headerBand, "HomePanelHeader", { 0.08, 0.08, 0.10, 0.82 })
        headerBand:SetVertexColor(1, 1, 1, 0.98)

        local title = section:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        title:SetPoint("CENTER", headerBand, "CENTER", 0, -1)
        title:SetText(titleText)
        title:SetTextColor(0.98, 0.92, 0.76, 1)

        local body = CreateFrame("Frame", nil, section)
        body:SetPoint("TOPLEFT", section.Content, "TOPLEFT", 0, -22)
        body:SetPoint("BOTTOMRIGHT", section.Content, "BOTTOMRIGHT", 0, 0)

        section.HeaderBand = headerBand
        section.TitleText = title
        section.Body = body
        return section
    end

    createMetricCard({ x = 8, y = -44, w = 250, h = 82 }, "HomeRaidCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "TOTAL CHARACTERS", tostring(stats.totalCharacters), { 0.98, 0.84, 0.42, 1 })
    createMetricCard({ x = 266, y = -44, w = 250, h = 82 }, "HomeMembersCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "UNIQUE PLAYERS", tostring(stats.uniquePlayers), { 0.98, 0.84, 0.42, 1 })
    createMetricCard({ x = 524, y = -44, w = 250, h = 82 }, "HomeNewsCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Tank-l.png", "TANKS", tostring(stats.tanks), { 0.56, 0.76, 1, 1 })
    createMetricCard({ x = 782, y = -44, w = 250, h = 82 }, "HomeMythicCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Healer-l.png", "HEALERS", tostring(stats.healers), { 0.60, 0.92, 0.44, 1 })
    createMetricCard({ x = 1040, y = -44, w = 250, h = 82 }, "HomeRaidCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_DPS-l.png", "DPS", tostring(stats.dps), { 0.96, 0.68, 0.28, 1 })

    local classPanel = createPanel(dialog.Content, { x = 8, y = -138, w = 522, h = 404 }, "CLASS DISTRIBUTION")
    local classHeader = classPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    classHeader:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 10, -2)
    classHeader:SetText("Class")
    classHeader:SetTextColor(0.98, 0.84, 0.40, 1)

    local countHeader = classPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    countHeader:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 234, -2)
    countHeader:SetText("Count")
    countHeader:SetTextColor(0.98, 0.84, 0.40, 1)

    local percentHeader = classPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    percentHeader:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 356, -2)
    percentHeader:SetText("% of Roster")
    percentHeader:SetTextColor(0.98, 0.84, 0.40, 1)

    for index, entry in ipairs(stats.classRows) do
        local row = CreateFrame("Frame", nil, classPanel.Content)
        row:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 0, -20 - ((index - 1) * 18))
        row:SetSize(470, 18)

        local divider = row:CreateTexture(nil, "BACKGROUND")
        divider:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
        divider:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0)
        divider:SetHeight(1)
        divider:SetColorTexture(0.44, 0.34, 0.16, 0.30)

        local icon = row:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("LEFT", row, "LEFT", 8, 0)
        icon:SetSize(16, 16)
        icon:SetTexture(CLASS_ICON_TEXTURE)
        if entry.classToken ~= "" and type(CLASS_ICON_TCOORDS) == "table" and CLASS_ICON_TCOORDS[entry.classToken] then
            icon:SetTexCoord(unpack(CLASS_ICON_TCOORDS[entry.classToken]))
        else
            icon:SetTexCoord(0, 1, 0, 1)
        end

        local label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", icon, "RIGHT", 8, 0)
        label:SetWidth(184)
        label:SetJustifyH("LEFT")
        label:SetWordWrap(false)
        label:SetText(entry.label)
        label:SetTextColor(entry.color[1], entry.color[2], entry.color[3], entry.color[4])

        local count = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        count:SetPoint("LEFT", row, "LEFT", 220, 0)
        count:SetWidth(56)
        count:SetJustifyH("RIGHT")
        count:SetText(tostring(entry.count))
        count:SetTextColor(0.98, 0.95, 0.84, 1)

        local fillRed, fillGreen, fillBlue = getReadableProgressBarColor(entry.color)

        local barBack = row:CreateTexture(nil, "ARTWORK")
        barBack:SetPoint("LEFT", row, "LEFT", 296, 0)
        barBack:SetSize(104, 10)
        barBack:SetColorTexture(0.10, 0.11, 0.14, 0.92)

        local barFill = row:CreateTexture(nil, "OVERLAY")
        barFill:SetPoint("LEFT", barBack, "LEFT", 0, 0)
        barFill:SetSize(math.max(2, math.floor(104 * ((entry.percent or 0) / 100))), 10)
        barFill:SetColorTexture(fillRed, fillGreen, fillBlue, 0.98)

        local barGloss = row:CreateTexture(nil, "OVERLAY")
        barGloss:SetPoint("TOPLEFT", barFill, "TOPLEFT", 0, 0)
        barGloss:SetPoint("TOPRIGHT", barFill, "TOPRIGHT", 0, 0)
        barGloss:SetHeight(4)
        barGloss:SetColorTexture(1, 1, 1, 0.10)

        local percent = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        percent:SetPoint("LEFT", barBack, "RIGHT", 10, 0)
        percent:SetWidth(52)
        percent:SetJustifyH("RIGHT")
        percent:SetText(string.format("%.1f%%", entry.percent or 0))
        percent:SetTextColor(0.98, 0.95, 0.84, 1)
    end

    local lowestTitle = classPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    lowestTitle:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 10, -258)
    lowestTitle:SetText("LOWEST REPRESENTATION")
    lowestTitle:SetTextColor(0.98, 0.84, 0.40, 1)

    for index, entry in ipairs(stats.lowestClasses) do
        local row = CreateFrame("Frame", nil, classPanel.Content)
        row:SetPoint("TOPLEFT", classPanel.Content, "TOPLEFT", 0, -280 - ((index - 1) * 18))
        row:SetSize(470, 18)

        local divider = row:CreateTexture(nil, "BACKGROUND")
        divider:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
        divider:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0)
        divider:SetHeight(1)
        divider:SetColorTexture(0.44, 0.34, 0.16, 0.30)

        local label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", row, "LEFT", 8, 0)
        label:SetWidth(170)
        label:SetJustifyH("LEFT")
        label:SetWordWrap(false)
        label:SetText(entry.label)
        label:SetTextColor(entry.color[1], entry.color[2], entry.color[3], entry.color[4])

        local count = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        count:SetPoint("LEFT", row, "LEFT", 184, 0)
        count:SetWidth(48)
        count:SetJustifyH("RIGHT")
        count:SetText(tostring(entry.count))
        count:SetTextColor(0.98, 0.95, 0.84, 1)

        local note = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        note:SetPoint("LEFT", row, "LEFT", 252, 0)
        note:SetWidth(198)
        note:SetJustifyH("LEFT")
        note:SetWordWrap(false)
        note:SetText("Good candidate to level")
        note:SetTextColor(0.82, 0.82, 0.74, 1)
    end

    local specPanel = createPanel(dialog.Content, { x = 542, y = -138, w = 742, h = 278 }, "SPEC DISTRIBUTION (TOP SPECS)")
    for column = 1, 2 do
        local columnX = column == 1 and 0 or 360
        local header = specPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        header:SetPoint("TOPLEFT", specPanel.Content, "TOPLEFT", 10 + columnX, -2)
        header:SetText("Spec")
        header:SetTextColor(0.98, 0.84, 0.40, 1)

        local countText = specPanel.Content:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        countText:SetPoint("TOPLEFT", specPanel.Content, "TOPLEFT", 286 + columnX, -2)
        countText:SetText("Count")
        countText:SetTextColor(0.98, 0.84, 0.40, 1)
    end

    for index = 1, math.min(16, #stats.specRows) do
        local entry = stats.specRows[index]
        local column = index > 8 and 2 or 1
        local rowIndex = index > 8 and (index - 8) or index
        local offsetX = column == 1 and 0 or 360
        local row = CreateFrame("Frame", nil, specPanel.Content)
        row:SetPoint("TOPLEFT", specPanel.Content, "TOPLEFT", offsetX, -22 - ((rowIndex - 1) * 23))
        row:SetSize(338, 22)

        local divider = row:CreateTexture(nil, "BACKGROUND")
        divider:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
        divider:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0)
        divider:SetHeight(1)
        divider:SetColorTexture(0.44, 0.34, 0.16, 0.30)

        local icon = row:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("LEFT", row, "LEFT", 8, 0)
        icon:SetSize(14, 14)
        icon:SetTexture(CLASS_ICON_TEXTURE)
        if entry.classToken ~= "" and type(CLASS_ICON_TCOORDS) == "table" and CLASS_ICON_TCOORDS[entry.classToken] then
            icon:SetTexCoord(unpack(CLASS_ICON_TCOORDS[entry.classToken]))
        else
            icon:SetTexCoord(0, 1, 0, 1)
        end

        local label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", icon, "RIGHT", 8, 0)
        label:SetWidth(236)
        label:SetJustifyH("LEFT")
        label:SetWordWrap(false)
        label:SetText(entry.label)
        label:SetTextColor(entry.color[1], entry.color[2], entry.color[3], entry.color[4])

        local count = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        count:SetPoint("RIGHT", row, "RIGHT", -10, 0)
        count:SetText(tostring(entry.count))
        count:SetTextColor(0.98, 0.95, 0.84, 1)
    end

    local roleSection = createCompactSection({ x = 542, y = -420, w = 742, h = 100 }, "ROLE BREAKDOWN")
    local roleBand = createCardBand(roleSection.Body, { x = 0, y = 24, w = 706, h = 44 }, { "HomeNewsCardBackground", "HomeMembersCardBackground", "HomeRaidCardBackground", "HomeMythicCardBackground" })
    local roleEntries = {
        { icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Tank-l.png", label = "Tanks", value = stats.tanks, percent = stats.tankPercent, color = { 0.56, 0.76, 1, 1 } },
        { icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Healer-l.png", label = "Healers", value = stats.healers, percent = stats.healerPercent, color = { 0.60, 0.92, 0.44, 1 } },
        { icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_DPS-l.png", label = "Melee DPS", value = stats.meleeDps, percent = stats.meleePercent, color = { 0.96, 0.68, 0.28, 1 } },
        { icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_DPS-l.png", label = "Ranged DPS", value = stats.rangedDps, percent = stats.rangedPercent, color = { 0.98, 0.84, 0.42, 1 } },
    }
    for index, entry in ipairs(roleEntries) do
        local segment = roleBand["Segment" .. index].Content
        local icon = segment:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("TOPLEFT", segment, "TOPLEFT", 8, -1)
        icon:SetSize(14, 14)
        icon:SetTexture(entry.icon)

        local label = segment:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("TOPLEFT", segment, "TOPLEFT", 30, -1)
        label:SetText(entry.label)
        label:SetTextColor(entry.color[1], entry.color[2], entry.color[3], entry.color[4])

        local value = segment:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        value:SetPoint("TOPLEFT", segment, "TOPLEFT", 24, -13)
        value:SetText(tostring(entry.value))
        value:SetTextColor(0.98, 0.95, 0.84, 1)

        local percent = segment:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        percent:SetPoint("LEFT", value, "RIGHT", 8, 1)
        percent:SetText(string.format("%.1f%%", entry.percent or 0))
        percent:SetTextColor(0.82, 0.82, 0.74, 1)
    end

    local gapsSection = createCompactSection({ x = 542, y = -530, w = 742, h = 112 }, "SUGGESTED GAPS")
    for index = 1, math.min(3, #stats.gapLines) do
        local line = gapsSection.Body:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        line:SetPoint("TOPLEFT", gapsSection.Body, "TOPLEFT", 8, 26 - ((index - 1) * 14))
        line:SetWidth(690)
        line:SetJustifyH("LEFT")
        line:SetWordWrap(false)
        line:SetText("- " .. stats.gapLines[index])
        line:SetTextColor(0.98, 0.95, 0.84, 1)
    end

    local recommendationText = gapsSection.Body:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    recommendationText:SetPoint("TOPLEFT", gapsSection.Body, "TOPLEFT", 8, -22)
    recommendationText:SetPoint("RIGHT", gapsSection.Body, "RIGHT", -8, 0)
    recommendationText:SetJustifyH("LEFT")
    recommendationText:SetWordWrap(false)
    recommendationText:SetText("RECOMMENDED LEVEL-UP OPTIONS: " .. table.concat(stats.recommendations, "  |  "))
    recommendationText:SetTextColor(0.46, 0.76, 1.00, 1)

    local bottomClose = createButton(dialog.Content, { x = 318, y = -640, w = 202, h = 34 }, "Close", nil, onClose)
    local exportButton = createButton(dialog.Content, { x = 556, y = -640, w = 202, h = 34 }, "Export", nil, onExport)
    local viewRosterButton = createButton(dialog.Content, { x = 794, y = -640, w = 202, h = 34 }, "View Roster", nil, onViewRoster)

    overlay.Dialog = dialog
    overlay.CloseButton = closeButton
    overlay.BottomCloseButton = bottomClose
    overlay.ExportButton = exportButton
    overlay.ViewRosterButton = viewRosterButton
    overlay.Stats = stats
    return overlay
end

local function hidePopupMenu(frame)
    if frame then
        if frame.childMenu then
            hidePopupMenu(frame.childMenu)
        end
        frame:Hide()
    end
end

local function ensurePopupMenuFrame()
    local anchorFrame = page._popupAnchorFrame

    if anchorFrame and anchorFrame._gh3MenuFrame then
        return anchorFrame._gh3MenuFrame
    end

    local frame = CreateFrame("Frame", nil, UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    frame:SetFrameStrata("TOOLTIP")
    frame:SetToplevel(true)
    frame:SetClampedToScreen(true)
    frame:Hide()
    frame.itemButtons = {}
    if frame.SetBackdrop then
        frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        frame:SetBackdropColor(0.05, 0.06, 0.08, 0.96)
        frame:SetBackdropBorderColor(0.44, 0.34, 0.16, 0.95)
    end
    frame:SetScript("OnHide", function(self)
        if self.childMenu then
            hidePopupMenu(self.childMenu)
        end
    end)

    if anchorFrame then
        anchorFrame._gh3MenuFrame = frame
    else
        page._popupMenuFrame = frame
    end

    return frame
end

local function ensurePopupMenuItem(frame, index)
    local item = frame.itemButtons[index]
    if item then return item end

    item = CreateFrame("Button", nil, frame, BackdropTemplateMixin and "BackdropTemplate" or nil)
    if item.SetBackdrop then
        item:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        item:SetBackdropColor(0.10, 0.11, 0.14, 0.92)
        item:SetBackdropBorderColor(0.30, 0.24, 0.12, 0.75)
    end
    local text = item:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("LEFT", item, "LEFT", 8, 0)
    text:SetPoint("RIGHT", item, "RIGHT", -8, 0)
    text:SetJustifyH("LEFT")
    text:SetTextColor(0.96, 0.93, 0.84, 1)
    item.Text = text
    frame.itemButtons[index] = item
    return item
end

local function measurePopupMenuWidth(anchorFrame, menuItems)
    local width = math.max(160, tonumber(anchorFrame and anchorFrame:GetWidth()) or 160)

    for _, option in ipairs(type(menuItems) == "table" and menuItems or {}) do
        local text = tostring(option.text or option.label or option.value or "Select")
        local isChecked = type(option.checked) == "function" and option.checked() or option.checked
        local prefix = option.notCheckable and "" or (isChecked and "[x] " or "[ ] ")
        local suffix = option.hasArrow and "  >" or ""
        width = math.max(width, (#tostring(prefix .. text .. suffix) * 7) + 28)
    end

    return math.min(width, 320)
end

local function populatePopupMenu(frame, anchorFrame, menuItems, level, menuList)
    local items = type(menuItems) == "function" and (menuItems(level, menuList) or {}) or (type(menuItems) == "table" and menuItems or {})
    local width = measurePopupMenuWidth(anchorFrame, items)

    for index, option in ipairs(items) do
        local item = ensurePopupMenuItem(frame, index)
        local isChecked = type(option.checked) == "function" and option.checked() or option.checked
        local prefix = option.notCheckable and "" or (isChecked and "[x] " or "[ ] ")
        item:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8 - ((index - 1) * 24))
        item:SetSize(width, 22)
        item.Text:SetText(prefix .. tostring(option.text or "Select") .. (option.hasArrow and "  >" or ""))

        item:SetScript("OnClick", function()
            if option.disabled then return end
            if type(option.onClick) == "function" then
                option.onClick(option.value, option, menuList, level)
            elseif type(option.func) == "function" then
                option.func()
            end

            if option.keepShownOnClick then
                populatePopupMenu(frame, anchorFrame, menuItems, level, menuList)
                frame:Show()
                page._activePopupAnchor = anchorFrame
            else
                hidePopupMenu(frame)
                page._activePopupAnchor = nil
            end
        end)
        item:Show()
    end

    for index = #items + 1, #(frame.itemButtons or {}) do
        frame.itemButtons[index]:Hide()
    end

    frame:SetSize(width + 16, (#items * 24) + 16)
end

local function showDropdownMenu(anchorFrame, menuItems)
    if not anchorFrame then
        return
    end

    page._popupAnchorFrame = anchorFrame
    local frame = ensurePopupMenuFrame()
    if page._activePopupAnchor == anchorFrame and frame:IsShown() then
        hidePopupMenu(frame)
        page._activePopupAnchor = nil
        return
    end

    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", anchorFrame, "BOTTOMLEFT", 0, -4)
    populatePopupMenu(frame, anchorFrame, menuItems, 1, nil)
    frame:Show()
    page._activePopupAnchor = anchorFrame
end

local function createInputBar(parent, rect, placeholderText, valueText, onCommit)
    local frame = createArtFrame(parent, rect, resolveAsset(CARD_ART.backgroundTextureKey, { left = 18, right = 18, top = 10, bottom = 12 }, rect.w, rect.h))
    local editBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
    local skipNextFocusLostCommit = false
    local lastCommittedValue = trimString(valueText)
    if editBox.Left then editBox.Left:SetAlpha(0) end
    if editBox.Middle then editBox.Middle:SetAlpha(0) end
    if editBox.Right then editBox.Right:SetAlpha(0) end
    editBox:SetAutoFocus(false)
    editBox:SetFontObject("GameFontHighlight")
    editBox:SetPoint("TOPLEFT", frame.Content, "TOPLEFT", 12, 2)
    editBox:SetPoint("BOTTOMRIGHT", frame.Content, "BOTTOMRIGHT", -8, -2)
    editBox:SetTextInsets(0, 0, 0, 0)
    editBox:SetTextColor(0.96, 0.93, 0.84, 1)
    editBox:SetText(valueText or "")

    local placeholder = frame.Content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    placeholder:SetPoint("LEFT", frame.Content, "LEFT", 12, 0)
    placeholder:SetText(placeholderText)
    placeholder:SetTextColor(0.62, 0.58, 0.50, 1)
    placeholder:SetShown(trimString(valueText) == "")

    local function commit()
        local nextValue = trimString(editBox:GetText())
        if nextValue == lastCommittedValue then
            return
        end
        lastCommittedValue = nextValue
        if onCommit then
            onCommit(nextValue)
        end
    end

    editBox:SetScript("OnTextChanged", function(self)
        placeholder:SetShown(trimString(self:GetText()) == "" and not self:HasFocus())
    end)
    editBox:SetScript("OnEditFocusGained", function()
        placeholder:Hide()
    end)
    editBox:SetScript("OnEditFocusLost", function(self)
        placeholder:SetShown(trimString(self:GetText()) == "")
        if skipNextFocusLostCommit then
            skipNextFocusLostCommit = false
            return
        end
        commit()
    end)
    editBox:SetScript("OnEnterPressed", function(self)
        commit()
        skipNextFocusLostCommit = true
        self:ClearFocus()
    end)

    frame.EditBox = editBox
    frame.PlaceholderText = placeholder
    return frame
end

local function createSelectBar(parent, rect, textValue, onClick)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    applyRect(button, parent, rect)

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, CARD_ART.backgroundTextureKey, { 0.10, 0.10, 0.12, 0.95 })

    local content = createArtContentFrame(button, CARD_ART.backgroundTextureKey, rect.w, rect.h, { left = 18, right = 18, top = 10, bottom = 12 })
    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("LEFT", content, "LEFT", 8, 0)
    text:SetWidth(math.max(1, rect.w - 34))
    text:SetWordWrap(false)
    text:SetJustifyH("LEFT")
    text:SetText(textValue)
    text:SetTextColor(0.96, 0.93, 0.84, 1)

    local arrow = button:CreateTexture(nil, "OVERLAY")
    arrow:SetPoint("RIGHT", content, "RIGHT", -2, 0)
    arrow:SetSize(16, 16)
    arrow:SetTexture("Interface\\ChatFrame\\ChatFrameExpandArrow")
    arrow:SetVertexColor(0.96, 0.82, 0.46, 1)

    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    button.Background = background
    button.Content = content
    button.Text = text
    button.Arrow = arrow
    return button
end

local function createToggleBar(parent, rect, labelText, isChecked, onClick)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    applyRect(button, parent, rect)

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, CARD_ART.backgroundTextureKey, { 0.10, 0.10, 0.12, 0.95 })

    local content = createArtContentFrame(button, CARD_ART.backgroundTextureKey, rect.w, rect.h, { left = 18, right = 18, top = 10, bottom = 12 })
    local box = CreateFrame("Frame", nil, button, BackdropTemplateMixin and "BackdropTemplate" or nil)
    box:SetPoint("LEFT", content, "LEFT", 6, 0)
    box:SetSize(16, 16)
    if box.SetBackdrop then
        box:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        box:SetBackdropColor(isChecked and 0.12 or 0.08, isChecked and 0.18 or 0.09, 0.10, 0.98)
        box:SetBackdropBorderColor(0.62, 0.48, 0.16, 0.82)
    end

    local check = box:CreateTexture(nil, "OVERLAY")
    check:SetPoint("CENTER", box, "CENTER", 0, 0)
    check:SetSize(16, 16)
    check:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
    check:SetTexCoord(0, 1, 0, 1)
    check:SetBlendMode("ADD")
    check:SetVertexColor(0.42, 0.92, 0.36, 1)
    check:SetShown(isChecked and true or false)

    local mark = box:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    mark:SetPoint("CENTER", box, "CENTER", 0, 0)
    mark:SetText("")
    mark:SetTextColor(0.42, 0.92, 0.36, 1)
    mark:SetShown(isChecked and true or false)

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("LEFT", box, "RIGHT", 10, 0)
    text:SetText(labelText)
    text:SetTextColor(0.96, 0.93, 0.84, 1)

    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    button.CheckBox = box
    button.Check = check
    button.CheckMark = mark
    button.Background = background
    button.Content = content
    button.Text = text
    return button
end

normalizeClassToken = function(classFile, className)
    for _, value in ipairs({ classFile, className }) do
        local normalized = string.upper((trimString(value):gsub("%s+", "")))
        if normalized ~= "" and type(CLASS_ICON_TCOORDS) == "table" and CLASS_ICON_TCOORDS[normalized] then
            return normalized
        end
    end

    return ""
end

getClassColor = function(className)
    local normalized = string.lower((trimString(className):gsub("%s+", "")))
    local colors = {
        deathknight = { 0.77, 0.12, 0.23, 1 },
        demonhunter = { 0.64, 0.19, 0.79, 1 },
        druid = { 1.00, 0.49, 0.04, 1 },
        evoker = { 0.20, 0.58, 0.50, 1 },
        hunter = { 0.67, 0.83, 0.45, 1 },
        mage = { 0.25, 0.78, 0.92, 1 },
        monk = { 0.00, 1.00, 0.60, 1 },
        paladin = { 0.96, 0.55, 0.73, 1 },
        priest = { 0.90, 0.90, 0.90, 1 },
        rogue = { 1.00, 0.96, 0.41, 1 },
        shaman = { 0.00, 0.44, 0.87, 1 },
        warlock = { 0.53, 0.53, 0.93, 1 },
        warrior = { 0.78, 0.61, 0.43, 1 },
    }

    return unpack(colors[normalized] or { 0.96, 0.82, 0.46, 1 })
end

getReadableProgressBarColor = function(color)
    local red = tonumber(color and color[1]) or 0.96
    local green = tonumber(color and color[2]) or 0.82
    local blue = tonumber(color and color[3]) or 0.46
    local luminance = (red * 0.299) + (green * 0.587) + (blue * 0.114)

    if luminance < 0.48 then
        local lift = math.min(0.28, 0.48 - luminance + 0.08)
        red = math.min(1, red + lift)
        green = math.min(1, green + lift)
        blue = math.min(1, blue + lift)
    end

    return red, green, blue
end

local function resolveRoleIcon(roleText)
    local normalized = string.lower(trimString(roleText))
    if string.find(normalized, "tank", 1, true) then return "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Tank-l.png" end
    if string.find(normalized, "heal", 1, true) then return "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Healer-l.png" end
    return "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_DPS-l.png"
end

local function resolveReadinessVisual(statusText, readinessPercent)
    local normalized = string.lower(trimString(statusText))

    if string.find(normalized, "ready", 1, true) or string.find(normalized, "complete", 1, true) then
        return "Ready", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png", { 0.11, 0.30, 0.14, 0.95 }, { 0.42, 0.92, 0.36, 1 }
    end

    if string.find(normalized, "missing", 1, true) or string.find(normalized, "issues", 1, true) or string.find(normalized, "needs", 1, true) or string.find(normalized, "low", 1, true) then
        return "Issues", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Warning-l.png", { 0.34, 0.12, 0.08, 0.95 }, { 0.96, 0.48, 0.32, 1 }
    end

    if tonumber(readinessPercent) and tonumber(readinessPercent) >= 100 then
        return "Ready", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png", { 0.11, 0.30, 0.14, 0.95 }, { 0.42, 0.92, 0.36, 1 }
    end

    return "Unknown", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", { 0.12, 0.16, 0.24, 0.95 }, { 0.66, 0.82, 1, 1 }
end

local function buildFallbackRows()
    local rows = {
        { id = "Aelthas-Stormrage", name = "Aelthas", realm = "Stormrage", fullName = "Aelthas-Stormrage", mainToon = "Aelthas", role = "Tank", spec = "Protection", itemLevel = "696", rank = "Officer", readiness = "Ready", readinessPercent = 100, readinessCategory = "Ready", online = true, note = "Main tank for mythic nights.", battleTag = "Aelthas#11837", className = "Paladin", classFile = "PALADIN", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 120 },
        { id = "Brewmasterx-Stormrage", name = "Brewmasterx", realm = "Stormrage", fullName = "Brewmasterx-Stormrage", mainToon = "Brewmasterx", role = "Healer", spec = "Mistweaver", itemLevel = "691", rank = "Member", readiness = "Ready", readinessPercent = 100, readinessCategory = "Ready", online = true, note = "Heals raid and keys.", battleTag = "Brewmasterx#1901", className = "Monk", classFile = "MONK", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 240 },
        { id = "Lunarya-Stormrage", name = "Lunarya", realm = "Stormrage", fullName = "Lunarya-Stormrage", mainToon = "Lunarya", role = "DPS", spec = "Balance", itemLevel = "688", rank = "Member", readiness = "Missing Enchant", readinessPercent = 82, readinessCategory = "Issues", online = true, note = "Needs enchant refresh.", battleTag = "Lunarya#4521", className = "Druid", classFile = "DRUID", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 900 },
        { id = "Doomscythe-Stormrage", name = "Doomscythe", realm = "Stormrage", fullName = "Doomscythe-Stormrage", mainToon = "Doomscythe", role = "DPS", spec = "Fury", itemLevel = "681", rank = "Member", readiness = "Needs Gems", readinessPercent = 76, readinessCategory = "Issues", online = false, note = "Alt raid bench slot.", battleTag = "Doomscythe#9999", className = "Warrior", classFile = "WARRIOR", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 7200 },
        { id = "Valdrin-Stormrage", name = "Valdrin", realm = "Stormrage", fullName = "Valdrin-Stormrage", mainToon = "Valdrin", role = "Healer", spec = "Holy", itemLevel = "676", rank = "Member", readiness = "Ready", readinessPercent = 100, readinessCategory = "Ready", online = true, note = "Backups holy and disc.", battleTag = "Valdrin#7712", className = "Priest", classFile = "PRIEST", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 1800 },
        { id = "Thalrion-Stormrage", name = "Thalrion", realm = "Stormrage", fullName = "Thalrion-Stormrage", mainToon = "Thalrion", role = "DPS", spec = "Beast Mastery", itemLevel = "670", rank = "Member", readiness = "Missing Enchant", readinessPercent = 84, readinessCategory = "Issues", online = true, note = "Needs updated weapon enchant.", battleTag = "Thalrion#3348", className = "Hunter", classFile = "HUNTER", level = 70, faction = "Alliance", guildName = "GuildHelper", updatedAt = time() - 3600 },
    }

    for _, row in ipairs(rows) do
        row.mainCharacter = row.mainToon
        row.guildJoinDate = "-"
        row.birthdate = "-"
        row.interests = "-"
        row.professions = "-"
        row.roles = row.role
    end

    return rows
end

local function mapMemberToRow(member)
    local fullName = trimString(member and (member.fullName or member.id))
    local name = trimString(member and member.name)
    local realm = trimString(member and member.realm)
    local mainName = trimString(member and member.mainCharacter)

    if name == "" and fullName ~= "" then
        name, realm = splitFullName(fullName)
    end

    if mainName ~= "" then
        mainName = splitFullName(mainName)
    else
        mainName = name
    end

    local readiness = type(member) == "table" and type(member.readiness) == "table" and member.readiness or nil
    local readinessText = trimString(readiness and readiness.statusText)
    local readinessPercent = tonumber(readiness and readiness.readinessPercent)

    if readinessText == "" then
        if readinessPercent then
            readinessText = string.format("%d%% Ready", roundNumber(readinessPercent))
        elseif member and member.online then
            readinessText = "Profile Pending"
        else
            readinessText = "Unknown"
        end
    end

    local readinessCategory = (select(1, resolveReadinessVisual(readinessText, readinessPercent)))
    local guildJoinDate = trimString(member and member.guildJoinDate)
    local birthdate = trimString(member and member.birthdate)
    local className = trimString(member and (member.className or member.classFile))
    local classFile = trimString(member and member.classFile)

    return {
        id = fullName ~= "" and fullName or (name ~= "" and name or tostring(math.random(100000, 999999))),
        name = name ~= "" and name or "Unknown",
        realm = realm ~= "" and realm or (GetRealmName() or "Unknown"),
        fullName = fullName ~= "" and fullName or name,
        mainToon = mainName ~= "" and mainName or name,
        mainCharacter = mainName ~= "" and mainName or name,
        role = normalizeRoleLabel(member and (member.primaryRole or member.role or member.spec)),
        spec = trimString(member and member.spec) ~= "" and trimString(member and member.spec) or normalizeRoleLabel(member and (member.primaryRole or member.role or member.spec)),
        itemLevel = formatItemLevel(member and member.itemLevel),
        rank = trimString(member and member.rank) ~= "" and trimString(member and member.rank) or "Member",
        readiness = readinessText,
        readinessPercent = readinessPercent,
        readinessCategory = readinessCategory,
        online = member and member.online and true or false,
        note = trimString(member and member.note),
        battleTag = trimString(member and member.battleTag),
        className = className ~= "" and className or "Unknown",
        classFile = classFile,
        level = tonumber(member and member.level) or 0,
        faction = trimString(member and member.faction),
        guildName = trimString(member and member.guildName),
        guildJoinDate = guildJoinDate ~= "" and guildJoinDate or "-",
        birthdate = birthdate ~= "" and birthdate or "-",
        interests = joinList(member and member.interests),
        professions = joinList(member and member.professions),
        roles = buildRolesValue(member),
        updatedAt = tonumber(member and (member.updatedAt or member.lastSeenAt)) or 0,
    }
end

local function getLegacyColumnText(rowData, columnKey)
    if columnKey == "Toon" then
        return trimString(rowData and rowData.name)
    end

    if columnKey == "Role" then
        return trimString(rowData and rowData.role) ~= "" and trimString(rowData and rowData.role) or "-"
    end

    if columnKey == "Readiness" then
        return trimString(rowData and rowData.readiness) ~= "" and trimString(rowData and rowData.readiness) or "-"
    end

    local definition = findLegacyColumnDefinition(columnKey)
    if not definition then
        return "-"
    end

    local value = rowData and rowData[definition.valueKey]
    local text = trimString(value)
    if columnKey == "Roles" and text == "" then
        text = trimString(rowData and rowData.role)
    end
    return text ~= "" and text or "-"
end

local function buildRosterRows()
    if not addon.RosterService or type(addon.RosterService.ListVisibleMembers) ~= "function" then
        return buildFallbackRows(), true
    end

    local ok, members = pcall(addon.RosterService.ListVisibleMembers, addon.RosterService)
    if not ok or type(members) ~= "table" or #members == 0 then
        return buildFallbackRows(), true
    end

    local rows = {}
    for _, member in ipairs(members) do
        if type(member) == "table" and not member.deleted then
            rows[#rows + 1] = mapMemberToRow(member)
        end
    end

    table.sort(rows, function(left, right)
        if left.online ~= right.online then
            return left.online and not right.online
        end

        return (left.name or "") < (right.name or "")
    end)

    if #rows == 0 then
        return buildFallbackRows(), true
    end

    return rows, false
end

local function collectOptions(rows, fieldName, allLabel)
    local options = { allLabel }
    local seen = { [allLabel] = true }

    for _, row in ipairs(type(rows) == "table" and rows or {}) do
        local value = trimString(row and row[fieldName])
        if value ~= "" and not seen[value] then
            seen[value] = true
            options[#options + 1] = value
        end
    end

    table.sort(options, function(left, right)
        if left == allLabel then return true end
        if right == allLabel then return false end
        return left < right
    end)

    return options
end

local function hasOption(options, expected)
    local needle = trimString(expected)
    for _, option in ipairs(options or {}) do
        if option == needle then
            return true
        end
    end
    return false
end

local function filterRows(rows)
    page.State = page.State or {}
    local search = string.lower(trimString(page.State.searchQuery))
    local selectedRole = trimString(page.State.selectedRole)
    local selectedRank = trimString(page.State.selectedRank)
    local selectedReadiness = trimString(page.State.selectedReadiness)
    local onlineOnly = page.State.onlineOnly and true or false
    local filtered = {}

    for _, row in ipairs(type(rows) == "table" and rows or {}) do
        local haystack = string.lower(table.concat({
            trimString(row.name),
            trimString(row.mainToon),
            trimString(row.realm),
            trimString(row.role),
            trimString(row.spec),
            trimString(row.rank),
        }, " "))
        local matchesSearch = search == "" or string.find(haystack, search, 1, true) ~= nil
        local matchesRole = selectedRole == "" or selectedRole == "All Roles" or row.role == selectedRole
        local matchesRank = selectedRank == "" or selectedRank == "All Ranks" or row.rank == selectedRank
        local matchesReadiness = selectedReadiness == "" or selectedReadiness == "All Readiness" or row.readinessCategory == selectedReadiness
        local matchesOnline = not onlineOnly or row.online

        if matchesSearch and matchesRole and matchesRank and matchesReadiness and matchesOnline then
            filtered[#filtered + 1] = row
        end
    end

    return filtered
end

local function getSortValue(row, sortKey)
    if sortKey == "Toon" then return string.lower(trimString(row.name)) end
    if sortKey == "Role" then return string.lower(trimString(row.role)) end
    if sortKey == "Spec" then return string.lower(trimString(row.spec)) end
    if sortKey == "Item Level" then return tonumber(row.itemLevel) or 0 end
    if sortKey == "Rank" then return string.lower(trimString(row.rank)) end
    if sortKey == "Readiness" then return string.lower(trimString(row.readinessCategory ~= "" and row.readinessCategory or row.readiness)) end
    if sortKey == "Joined Guild" then return string.lower(trimString(row.guildJoinDate)) end
    if sortKey == "Birthdate" then return string.lower(trimString(row.birthdate)) end

    if findLegacyColumnDefinition(sortKey) then
        return string.lower(getLegacyColumnText(row, sortKey))
    end

    return string.lower(trimString(row.name))
end

local function sortRows(rows, sortKey, sortDirection)
    local sorted = {}
    for index, row in ipairs(rows or {}) do
        sorted[index] = row
    end

    if trimString(sortKey) == "" or sortKey == "Actions" then
        return sorted
    end

    local function compareAsc(left, right)
        local leftValue = getSortValue(left, sortKey)
        local rightValue = getSortValue(right, sortKey)

        if leftValue == rightValue then
            return string.lower(trimString(left.name)) < string.lower(trimString(right.name))
        end

        return leftValue < rightValue
    end

    table.sort(sorted, function(left, right)
        if sortDirection == "desc" then
            return compareAsc(right, left)
        end

        return compareAsc(left, right)
    end)

    return sorted
end

local function paginateRows(rows, currentPage, pageSize)
    local safePageSize = math.max(1, tonumber(pageSize) or 1)
    local totalRows = #(rows or {})
    local totalPages = math.max(1, math.ceil(totalRows / safePageSize))
    local pageNumber = math.min(totalPages, math.max(1, tonumber(currentPage) or 1))
    local firstIndex = ((pageNumber - 1) * safePageSize) + 1
    local lastIndex = math.min(totalRows, firstIndex + safePageSize - 1)
    local pageRows = {}

    for index = firstIndex, lastIndex do
        pageRows[#pageRows + 1] = rows[index]
    end

    return pageRows, pageNumber, totalPages
end

local function buildSummary(rows)
    local summary = {
        total = #rows,
        online = 0,
        tanks = 0,
        healers = 0,
        dps = 0,
        ready = 0,
    }

    for _, row in ipairs(rows or {}) do
        if row.online then
            summary.online = summary.online + 1
        end

        if row.role == "Tank" then
            summary.tanks = summary.tanks + 1
        elseif row.role == "Healer" then
            summary.healers = summary.healers + 1
        else
            summary.dps = summary.dps + 1
        end

        if row.readinessCategory == "Ready" then
            summary.ready = summary.ready + 1
        end
    end

    summary.readyPercent = summary.total > 0 and roundNumber((summary.ready / summary.total) * 100) or 0
    return summary
end

local function getSelectedRow(rows)
    local selectedId = trimString(page.State and page.State.selectedMemberId)

    for _, row in ipairs(rows or {}) do
        if trimString(row.id) == selectedId then
            return row
        end
    end

    local first = rows and rows[1] or nil
    page.State.selectedMemberId = first and first.id or nil
    return first
end

createCardBand = function(parent, rect, textureKeys)
    local host = CreateFrame("Frame", nil, parent)
    applyRect(host, parent, rect)
    local keys = (type(textureKeys) == "table" and #textureKeys > 0) and textureKeys or { CARD_ART.backgroundTextureKey }
    local segmentWidth = math.floor(rect.w / #keys)

    for index, key in ipairs(keys) do
        local x = (index - 1) * segmentWidth
        local width = index == #keys and (rect.w - x) or segmentWidth
        host["Segment" .. index] = createArtFrame(host, { x = x, y = 0, w = width, h = rect.h }, resolveAsset(key, { left = 0, right = 0, top = 0, bottom = 0 }, width, rect.h))
    end

    host.Content = CreateFrame("Frame", nil, host)
    host.Content:SetAllPoints(host)
    return host
end

local function createSummaryBar(parent, rect, summary)
    local cells = {
        { textureKey = "HomeRaidCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", value = tostring(summary.total), label = "Total Members", color = { 0.96, 0.82, 0.46, 1 } },
        { textureKey = "HomeMembersCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", value = tostring(summary.online), label = "Online", color = { 0.42, 0.92, 0.36, 1 } },
        { textureKey = "HomeNewsCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Tank-l.png", value = tostring(summary.tanks), label = "Tanks", color = { 0.56, 0.76, 1, 1 } },
        { textureKey = "HomeMythicCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_Healer-l.png", value = tostring(summary.healers), label = "Healers", color = { 0.60, 0.92, 0.44, 1 } },
        { textureKey = "HomeRaidCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Role_DPS-l.png", value = tostring(summary.dps), label = "DPS", color = { 0.96, 0.68, 0.28, 1 } },
        { textureKey = "HomeMembersCardBackground", icon = "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png", value = string.format("%d%%", summary.readyPercent), label = "Ready", color = { 0.74, 0.98, 0.52, 1 } },
    }
    local textureKeys = {}
    for index, cell in ipairs(cells) do textureKeys[index] = cell.textureKey end
    local bar = createCardBand(parent, rect, textureKeys)

    for index, cell in ipairs(cells) do
        local cellFrame = bar["Segment" .. index].Content

        local icon = cellFrame:CreateTexture(nil, "OVERLAY")
        icon:SetPoint("LEFT", cellFrame, "LEFT", 18, 0)
        icon:SetSize(18, 18)
        icon:SetTexture(cell.icon)

        local value = cellFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        value:SetPoint("LEFT", icon, "RIGHT", 10, 4)
        value:SetText(cell.value)
        value:SetTextColor(cell.color[1], cell.color[2], cell.color[3], cell.color[4])

        local label = cellFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", value, "BOTTOMLEFT", 0, -3)
        label:SetText(cell.label)
        label:SetTextColor(0.96, 0.93, 0.84, 1)
    end

    return bar
end

local function createColumnHeaders(parent, columns, columnDefinitions, sortKey, sortAscending, onSort)
    local headerBand = parent:CreateTexture(nil, "BACKGROUND")
    headerBand:SetAllPoints(parent)
    applyArtTexture(headerBand, "HomePanelHeader", { 0.08, 0.08, 0.10, 0.72 })
    headerBand:SetVertexColor(1, 1, 1, 0.98)

    local topRule = parent:CreateTexture(nil, "ARTWORK")
    topRule:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    topRule:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    topRule:SetHeight(1)
    topRule:SetColorTexture(0.58, 0.44, 0.16, 0.72)

    for _, entry in ipairs(columnDefinitions or {}) do
        local column = columns[entry.key]
        if column then
            if entry.key ~= "Toon" then
                local divider = parent:CreateTexture(nil, "ARTWORK")
                divider:SetPoint("TOPLEFT", parent, "TOPLEFT", column.x, -2)
                divider:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", column.x, 2)
                divider:SetWidth(1)
                divider:SetColorTexture(0.44, 0.34, 0.16, 0.42)
            end

            local button = CreateFrame("Button", nil, parent)
            button:SetPoint("TOPLEFT", parent, "TOPLEFT", column.x, 0)
            button:SetSize(column.w, parent:GetHeight())
            button:SetScript("OnClick", function()
                if onSort and entry.key ~= "Actions" then
                    onSort(entry.key)
                end
            end)

            local text = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            text:SetPoint("CENTER", button, "CENTER", 0, -1)
            text:SetWidth(column.w)
            text:SetWordWrap(false)
            text:SetJustifyH("CENTER")
            text:SetText((entry.text or entry.key) .. ((sortKey == entry.key and entry.key ~= "Actions") and (sortAscending == false and " v" or " ^") or ""))
            text:SetTextColor(0.96, 0.82, 0.46, 1)
        end
    end

    local divider = parent:CreateTexture(nil, "ARTWORK")
    divider:SetPoint("BOTTOMLEFT", parent, "BOTTOMLEFT", 0, 0)
    divider:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", 0, 0)
    divider:SetHeight(1)
    divider:SetColorTexture(0.58, 0.44, 0.16, 0.72)
end

local function createMiniActionButton(parent, x, labelText, tooltipTitle, tooltipText)
    local button = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    button:SetPoint("TOPLEFT", parent, "TOPLEFT", x, -3)
    button:SetSize(18, 18)

    local texture = button:CreateTexture(nil, "BACKGROUND")
    texture:SetAllPoints(button)
    applyArtTexture(texture, CARD_ART.backgroundTextureKey, { 0.10, 0.10, 0.12, 0.95 })

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    text:SetPoint("CENTER", button, "CENTER", 0, 0)
    text:SetText(labelText)
    text:SetTextColor(0.96, 0.82, 0.46, 1)

    button:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(tooltipTitle or labelText, 0.96, 0.82, 0.46, 1, true)
        if trimString(tooltipText) ~= "" then
            GameTooltip:AddLine(tooltipText, 0.86, 0.84, 0.76, true)
        end
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

    return button
end

local function createRosterRow(parent, rowIndex, rowData, columns, columnDefinitions, rowHeight, isSelected, onClick)
    local rowStep = math.max(1, rowHeight)
    local row = CreateFrame("Button", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -((rowIndex - 1) * rowStep))
    row:SetSize(parent:GetWidth(), rowHeight)

    local background = row:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(row)
    applyArtTexture(background, ROW_TEXTURE_KEY, { 0.08, 0.08, 0.10, 0.42 })
    background:SetVertexColor(1, 1, 1, isSelected and 1 or 0.92)

    local content = createArtContentFrame(row, ROW_TEXTURE_KEY, row:GetWidth(), rowHeight, { left = 8, right = 8, top = 3, bottom = 3 })
    local classColor = { getClassColor(rowData.className) }
    local classToken = normalizeClassToken(rowData.classFile, rowData.className)

    if columns.Toon then
        local iconFrame = CreateFrame("Frame", nil, row, BackdropTemplateMixin and "BackdropTemplate" or nil)
        iconFrame:SetPoint("LEFT", content, "LEFT", columns.Toon.x, 0)
        iconFrame:SetSize(20, 20)
        if iconFrame.SetBackdrop then
            iconFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
            iconFrame:SetBackdropColor(0.08, 0.08, 0.10, 0.98)
            iconFrame:SetBackdropBorderColor(classColor[1], classColor[2], classColor[3], classColor[4])
        end

        local portrait = iconFrame:CreateTexture(nil, "OVERLAY")
        portrait:SetPoint("TOPLEFT", iconFrame, "TOPLEFT", 2, -2)
        portrait:SetPoint("BOTTOMRIGHT", iconFrame, "BOTTOMRIGHT", -2, 2)
        if classToken ~= "" and type(CLASS_ICON_TCOORDS) == "table" and CLASS_ICON_TCOORDS[classToken] then
            local coords = CLASS_ICON_TCOORDS[classToken]
            portrait:SetTexture(CLASS_ICON_TEXTURE)
            portrait:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
        else
            portrait:SetColorTexture(classColor[1], classColor[2], classColor[3], 0.22)
        end

        local name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        name:SetPoint("TOPLEFT", content, "TOPLEFT", columns.Toon.x + 28, 2)
        name:SetWidth(columns.Toon.w - 32)
        name:SetWordWrap(false)
        name:SetJustifyH("LEFT")
        name:SetText(rowData.name)
        name:SetTextColor(classColor[1], classColor[2], classColor[3], classColor[4])

        local realm = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        realm:SetPoint("TOPLEFT", name, "BOTTOMLEFT", 0, 0)
        realm:SetWidth(columns.Toon.w - 32)
        realm:SetWordWrap(false)
        realm:SetJustifyH("LEFT")
        realm:SetText(rowData.realm)
        realm:SetTextColor(0.72, 0.68, 0.58, 1)
    end

    for _, definition in ipairs(columnDefinitions or {}) do
        local column = columns[definition.key]
        if column and definition.key ~= "Toon" and definition.key ~= "Actions" then
            local valueText = getLegacyColumnText(rowData, definition.key)
            local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            text:SetPoint("LEFT", content, "LEFT", column.x + (column.align == "CENTER" and 0 or 4), 0)
            text:SetWidth(math.max(1, column.w - (column.align == "CENTER" and 0 or 8)))
            text:SetWordWrap(false)
            text:SetJustifyH(column.align or "LEFT")
            text:SetText(valueText)

            if definition.key == "Class" or definition.key == "Spec" then
                text:SetTextColor(classColor[1], classColor[2], classColor[3], classColor[4])
            else
                text:SetTextColor(0.96, 0.93, 0.84, 1)
            end
        end
    end

    if columns.Actions then
        createMiniActionButton(content, columns.Actions.x + 6, "I", "Invite", "Invite this member to your current group.")
        createMiniActionButton(content, columns.Actions.x + 30, "W", "Whisper", "Open a whisper to this member.")
    end

    row:SetScript("OnClick", function()
        if onClick then
            onClick(rowData)
        end
    end)

    row.Background = background
    return row
end

local function createPagination(parent, rect, currentPage, totalPages, onPageChange)
    local host = CreateFrame("Frame", nil, parent)
    applyRect(host, parent, rect)
    local offsetX = 0

    local function addPageButton(labelText, width, isActive, targetPage)
        local button = createButton(host, { x = offsetX, y = 0, w = width, h = rect.h }, labelText, "", function()
            if onPageChange and tonumber(targetPage) and tonumber(targetPage) ~= tonumber(currentPage) then
                onPageChange(targetPage)
            end
        end)
        button.Background:SetVertexColor(1, 1, 1, isActive and 1 or 0.90)
        offsetX = offsetX + width + 8
        return button
    end

    local function addEllipsis()
        local text = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        text:SetPoint("TOPLEFT", host, "TOPLEFT", offsetX + 6, -4)
        text:SetText("...")
        text:SetTextColor(0.96, 0.93, 0.84, 1)
        offsetX = offsetX + 22
    end

    addPageButton("< Previous", 92, false, math.max(1, (tonumber(currentPage) or 1) - 1))

    local startPage = math.max(1, (tonumber(currentPage) or 1) - 1)
    local endPage = math.min(tonumber(totalPages) or 1, (tonumber(currentPage) or 1) + 1)

    if startPage > 1 then
        addPageButton("1", 28, currentPage == 1, 1)
        if startPage > 2 then
            addEllipsis()
        end
    end

    for pageNumber = startPage, endPage do
        addPageButton(tostring(pageNumber), pageNumber >= 10 and 34 or 28, currentPage == pageNumber, pageNumber)
    end

    if endPage < totalPages then
        if endPage < totalPages - 1 then
            addEllipsis()
        end
        addPageButton(tostring(totalPages), totalPages >= 10 and 34 or 28, currentPage == totalPages, totalPages)
    end

    addPageButton("Next >", 78, false, math.min(tonumber(totalPages) or 1, (tonumber(currentPage) or 1) + 1))

    return host
end

local function createSelectedMemberPanel(parent, rect, selectedRow)
    local panel = createPanel(parent, rect, "SELECTED MEMBER")
    local row = selectedRow or buildFallbackRows()[1]
    local content = panel.Content
    local classColor = { getClassColor(row.className) }
    local classToken = normalizeClassToken(row.classFile, row.className)

    local portraitFrame = createArtFrame(content, { x = 0, y = -2, w = 142, h = 120 }, resolveAsset(CARD_ART.backgroundTextureKey, { left = 10, right = 10, top = 12, bottom = 12 }, 142, 120))
    local portrait = portraitFrame:CreateTexture(nil, "OVERLAY")
    portrait:SetPoint("TOPLEFT", portraitFrame.Content, "TOPLEFT", 0, 0)
    portrait:SetPoint("BOTTOMRIGHT", portraitFrame.Content, "BOTTOMRIGHT", 0, 0)
    if classToken ~= "" and type(CLASS_ICON_TCOORDS) == "table" and CLASS_ICON_TCOORDS[classToken] then
        local coords = CLASS_ICON_TCOORDS[classToken]
        portrait:SetTexture(CLASS_ICON_TEXTURE)
        portrait:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
    else
        portrait:SetColorTexture(classColor[1], classColor[2], classColor[3], 0.22)
    end

    local name = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    name:SetPoint("TOPLEFT", content, "TOPLEFT", 160, -2)
    name:SetText(row.name)
    name:SetTextColor(classColor[1], classColor[2], classColor[3], classColor[4])

    local subtitle = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    subtitle:SetPoint("TOPLEFT", name, "BOTTOMLEFT", 0, -4)
    subtitle:SetText(string.format("Level %d %s", math.max(0, tonumber(row.level) or 0), row.className ~= "" and row.className or row.role))
    subtitle:SetTextColor(0.98, 0.88, 0.62, 1)

    local roleIcon = panel:CreateTexture(nil, "OVERLAY")
    roleIcon:SetPoint("TOPLEFT", content, "TOPLEFT", 160, -40)
    roleIcon:SetSize(16, 16)
    roleIcon:SetTexture(resolveRoleIcon(row.role))

    local roleText = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    roleText:SetPoint("LEFT", roleIcon, "RIGHT", 8, 0)
    roleText:SetText(row.role)
    roleText:SetTextColor(0.98, 0.82, 0.42, 1)

    local specText = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    specText:SetPoint("LEFT", roleText, "RIGHT", 18, 0)
    specText:SetText(row.spec)
    specText:SetTextColor(classColor[1], classColor[2], classColor[3], classColor[4])

    local infoRule = panel:CreateTexture(nil, "ARTWORK")
    infoRule:SetPoint("TOPLEFT", content, "TOPLEFT", 160, -62)
    infoRule:SetPoint("TOPRIGHT", content, "TOPRIGHT", -8, -66)
    infoRule:SetHeight(1)
    infoRule:SetColorTexture(0.44, 0.34, 0.16, 0.45)

    local infoDivider = panel:CreateTexture(nil, "ARTWORK")
    infoDivider:SetPoint("TOPLEFT", content, "TOPLEFT", 300, -70)
    infoDivider:SetPoint("BOTTOMLEFT", content, "TOPLEFT", 300, -124)
    infoDivider:SetWidth(1)
    infoDivider:SetColorTexture(0.44, 0.34, 0.16, 0.35)

    local fields = {
        { label = "Rank", value = row.rank, x = 160, y = -78, valueWidth = 72 },
        { label = "Main Toon", value = row.mainToon, x = 160, y = -102, valueWidth = 72 },
        { label = "Item Level", value = row.itemLevel, x = 160, y = -126, valueWidth = 72 },
        { label = "Realm", value = row.realm, x = 316, y = -78, valueWidth = 110 },
        { label = "BattleTag", value = trimString(row.battleTag) ~= "" and row.battleTag or "Not shared", x = 316, y = -102, valueWidth = 110 },
        { label = "Updated", value = formatTimestamp(row.updatedAt), x = 316, y = -126, valueWidth = 110 },
    }

    for _, field in ipairs(fields) do
        local label = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", content, "TOPLEFT", field.x, field.y)
        label:SetWidth(68)
        label:SetText(field.label .. ":")
        label:SetTextColor(0.96, 0.80, 0.36, 1)

        local value = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        value:SetPoint("LEFT", label, "RIGHT", 6, 0)
        value:SetWidth(field.valueWidth or 132)
        value:SetWordWrap(false)
        value:SetText(field.value)
        value:SetTextColor(1.00, 0.96, 0.86, 1)
    end

    return panel
end

local function createProfileRow(parent, offsetY, iconPath, labelText, valueText, valueColor)
    local row = CreateFrame("Frame", nil, parent)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, offsetY)
    row:SetSize(parent:GetWidth(), 24)

    local icon = row:CreateTexture(nil, "OVERLAY")
    icon:SetPoint("LEFT", row, "LEFT", 0, 0)
    icon:SetSize(16, 16)
    icon:SetTexture(iconPath)

    local label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    label:SetPoint("LEFT", icon, "RIGHT", 8, 0)
    label:SetWidth(102)
    label:SetText(labelText)
    label:SetTextColor(1.00, 0.84, 0.36, 1)

    local value = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    value:SetPoint("LEFT", label, "RIGHT", 10, 0)
    value:SetWidth(math.max(1, parent:GetWidth() - 136))
    value:SetWordWrap(false)
    value:SetJustifyH("LEFT")
    value:SetText(valueText)
    value:SetTextColor(valueColor[1], valueColor[2], valueColor[3], valueColor[4])

    local divider = row:CreateTexture(nil, "ARTWORK")
    divider:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    divider:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0)
    divider:SetHeight(1)
    divider:SetColorTexture(0.44, 0.34, 0.16, 0.28)
end

local function createReadinessPanel(parent, rect, selectedRow)
    local panel = createPanel(parent, rect, "READINESS / PROFILE")
    local row = selectedRow or buildFallbackRows()[1]
    local content = panel.Content
    local _, readinessIcon, _, readinessColor = resolveReadinessVisual(row.readiness, row.readinessPercent)
    local onlineText = row.online and "Online" or "Offline"
    local onlineColor = row.online and { 0.42, 0.92, 0.36, 1 } or { 0.96, 0.72, 0.28, 1 }

    local leftColumn = CreateFrame("Frame", nil, content)
    leftColumn:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -4)
    leftColumn:SetSize(312, 96)

    local rightColumn = CreateFrame("Frame", nil, content)
    rightColumn:SetPoint("TOPLEFT", content, "TOPLEFT", 346, -4)
    rightColumn:SetSize(308, 96)

    local columnDivider = panel:CreateTexture(nil, "ARTWORK")
    columnDivider:SetPoint("TOPLEFT", content, "TOPLEFT", 330, -2)
    columnDivider:SetPoint("BOTTOMLEFT", content, "TOPLEFT", 330, -98)
    columnDivider:SetWidth(1)
    columnDivider:SetColorTexture(0.44, 0.34, 0.16, 0.35)

    createProfileRow(leftColumn, 0, readinessIcon, "Status", row.readiness, readinessColor)
    createProfileRow(leftColumn, -24, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "Guild", trimString(row.guildName) ~= "" and row.guildName or "Unknown", { 1.00, 0.96, 0.86, 1 })
    createProfileRow(leftColumn, -48, resolveRoleIcon(row.role), "Role / Spec", row.role .. " / " .. row.spec, { 0.98, 0.84, 0.40, 1 })
    createProfileRow(leftColumn, -72, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png", "Online", onlineText, onlineColor)

    createProfileRow(rightColumn, 0, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "Main Toon", row.mainToon, { 1.00, 0.96, 0.82, 1 })
    createProfileRow(rightColumn, -24, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "Realm", row.realm, { 1.00, 0.96, 0.86, 1 })
    createProfileRow(rightColumn, -48, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Warning-l.png", "Last Updated", formatTimestamp(row.updatedAt), { 0.99, 0.84, 0.50, 1 })
    createProfileRow(rightColumn, -72, "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", "BattleTag", trimString(row.battleTag) ~= "" and row.battleTag or "Not shared", { 1.00, 0.96, 0.86, 1 })

    local noteLabel = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    noteLabel:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -108)
    noteLabel:SetText("Notes")
    noteLabel:SetTextColor(0.96, 0.82, 0.46, 1)

    local noteBox = createArtFrame(content, { x = 118, y = -104, w = rect.w - 166, h = 56 }, resolveAsset("HomeMembersCardBackground", { left = 0, right = 0, top = 0, bottom = 0 }, rect.w - 166, 56))
    if noteBox and noteBox.Texture then
        noteBox.Texture:SetVertexColor(0.82, 1.00, 0.84, 0.98)
    end
    local noteText = noteBox.Content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    noteText:SetPoint("TOPLEFT", noteBox.Content, "TOPLEFT", 12, -5)
    noteText:SetWidth(math.max(1, rect.w - 190))
    noteText:SetJustifyH("LEFT")
    noteText:SetJustifyV("TOP")
    noteText:SetWordWrap(true)
    noteText:SetText(trimString(row.note) ~= "" and row.note or "No roster note captured for this member.")
    noteText:SetTextColor(0.98, 0.95, 0.84, 1)

    return panel
end

refreshPage = function(parent, snapshot)
    if page._popupMenuFrame then
        hidePopupMenu(page._popupMenuFrame)
        page._activePopupAnchor = nil
    end

    if page._rootFrame then
        page._rootFrame:Hide()
        page._rootFrame:SetParent(nil)
        page._rootFrame = nil
    end

    if parent then
        local childFrames = { parent:GetChildren() }
        local childRegions = { parent:GetRegions() }

        for _, region in ipairs(childRegions) do
            if region and region.Hide then
                region:Hide()
            end
            if region and region.SetParent then
                region:SetParent(nil)
            end
        end

        for _, child in ipairs(childFrames) do
            if child and child ~= page._dropdownMenuFrame and child ~= page._popupMenuFrame and child.Hide then
                child:Hide()
            end
            if child and child ~= page._dropdownMenuFrame and child ~= page._popupMenuFrame and child.SetParent then
                child:SetParent(nil)
            end
        end
    end

    return page:Render(parent, snapshot)
end

function page:Render(parent, snapshot)
    if not parent then
        return nil
    end

    self._lastParent = parent
    self._lastSnapshot = snapshot
    self.State = self.State or {}
    self.State.searchQuery = trimString(self.State.searchQuery)
    self.State.selectedRole = trimString(self.State.selectedRole)
    self.State.selectedRank = trimString(self.State.selectedRank)
    self.State.selectedReadiness = trimString(self.State.selectedReadiness)
    self.State.sortKey = trimString(self.State.sortKey)
    if self.State.sortKey == "" then
        self.State.sortKey = "Toon"
    end
    if self.State.sortDirection ~= "desc" then
        self.State.sortDirection = "asc"
    end
    self.State.currentPage = math.max(1, tonumber(self.State.currentPage) or 1)
    self.State.selectedColumns = normalizeSelectedColumns(self.State.selectedColumns)

    local layout = addon.NewUILayout and addon.NewUILayout.Roster or {}
    local pageRect = layout.Page or { x = 0, y = 0, w = tonumber(parent:GetWidth()) or 1380, h = tonumber(parent:GetHeight()) or 742 }
    local containerRect = layout.Container or { x = 8, y = -8, w = 1364, h = 714 }
    local root = CreateFrame("Frame", nil, parent)
    applyRect(root, parent, pageRect)
    self._rootFrame = root

    local rows, usingFallback = buildRosterRows()
    local roleOptions = collectOptions(rows, "role", "All Roles")
    local rankOptions = collectOptions(rows, "rank", "All Ranks")
    local readinessOptions = { "All Readiness", "Ready", "Issues", "Unknown" }

    if not hasOption(roleOptions, self.State.selectedRole) then self.State.selectedRole = "All Roles" end
    if not hasOption(rankOptions, self.State.selectedRank) then self.State.selectedRank = "All Ranks" end
    if not hasOption(readinessOptions, self.State.selectedReadiness) then self.State.selectedReadiness = "All Readiness" end

    local filteredRows = filterRows(rows)
    local sortedRows = sortRows(filteredRows, self.State.sortKey, self.State.sortDirection)
    local summary = buildSummary(sortedRows)
    local selectedRow = getSelectedRow(sortedRows)
    local visibleColumns, visibleColumnDefinitions = resolveVisibleColumns(layout.Columns or {}, self.State.selectedColumns)

    local container = createArtFrame(root, containerRect, resolveAsset(PANEL_ART.backgroundTextureKey, { left = 24, right = 24, top = 42, bottom = 22 }, containerRect.w, containerRect.h))
    local content = container.Content

    local statsButton = createButton(content, layout.Actions and layout.Actions.Stats or { x = 720, y = -10, w = 132, h = 38 }, "Stats", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", function()
        self.State.showStatsPopup = true
        refreshPage(parent, snapshot)
    end)

    local exportButton = createButton(content, layout.Actions and layout.Actions.Export or { x = 870, y = -10, w = 146, h = 38 }, "Export All", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Roster-l.png", function(anchor)
        showDropdownMenu(anchor, {
            {
                text = "Export Pane CSV",
                notCheckable = true,
                func = function()
                    if addon.MainFrame and addon.MainFrame.BuildRosterExportCsv and addon.RosterService and addon.RosterService.ListVisibleMembers then
                        self.State.exportDialog = {
                            title = "Roster Pane CSV Export",
                            text = addon.MainFrame:BuildRosterExportCsv(addon.RosterService:ListVisibleMembers()),
                            helpText = "Copy this text and save it as a .csv file. This export reflects the current visible roster records.",
                        }
                        refreshPage(parent, snapshot)
                    end
                end,
            },
            {
                text = "Export Join Dates",
                notCheckable = true,
                disabled = not (addon.MainFrame and addon.MainFrame.BuildGuildJoinDateExportCsv and addon.RosterService and addon.RosterService.ListCurrentGuildMembersRaw),
                func = function()
                    if addon.MainFrame and addon.MainFrame.BuildGuildJoinDateExportCsv and addon.RosterService and addon.RosterService.ListCurrentGuildMembersRaw then
                        self.State.exportDialog = {
                            title = "Guild Join Date CSV Export",
                            text = addon.MainFrame:BuildGuildJoinDateExportCsv(addon.RosterService:ListCurrentGuildMembersRaw()),
                            helpText = "Copy this text and save it as a .csv file.",
                        }
                        refreshPage(parent, snapshot)
                    end
                end,
            },
        })
    end)

    local columnsButton = createButton(content, layout.Actions and layout.Actions.Columns or { x = 1028, y = -10, w = 146, h = 38 }, "Columns", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_News-l.png", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, definition in ipairs(LEGACY_COLUMN_DEFINITIONS) do
                items[#items + 1] = {
                    text = definition.key,
                    checked = self.State.selectedColumns[definition.key] and true or false,
                    keepShownOnClick = true,
                    func = function()
                        local isSelected = self.State.selectedColumns[definition.key] and true or false
                        local selectedCount = countSelectedColumns(self.State.selectedColumns)

                        if isSelected then
                            self.State.selectedColumns[definition.key] = false
                        elseif selectedCount < MAX_SELECTED_COLUMNS then
                            self.State.selectedColumns[definition.key] = true
                        else
                            if addon.Print then
                                addon:Print(string.format("You can select up to %d roster columns at a time.", MAX_SELECTED_COLUMNS))
                            end
                            return
                        end

                        refreshPage(parent, snapshot)
                    end,
                }
            end
            return items
        end)
    end)
    if columnsButton and columnsButton.Text then
        columnsButton.Text:SetText(string.format("Columns (%d)", countSelectedColumns(self.State.selectedColumns)))
    end

    local refreshButton = createButton(content, layout.Actions and layout.Actions.Refresh or { x = 1186, y = -10, w = 126, h = 38 }, "Refresh", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png", function()
        if addon.RosterService and addon.RosterService.CaptureGuildRoster then
            addon.RosterService:CaptureGuildRoster()
        end
        if addon.RosterService and addon.RosterService.RefreshLivePlayerMember then
            addon.RosterService:RefreshLivePlayerMember()
        end
        if addon.RosterService and addon.RosterService.RefreshCurrentPlayerReadiness then
            addon.RosterService:RefreshCurrentPlayerReadiness()
        end
        refreshPage(parent, snapshot)
    end)

    local searchBar = createInputBar(content, layout.Filters and layout.Filters.Search or { x = 0, y = -72, w = 318, h = 36 }, "Search by toon or BattleTag", self.State.searchQuery, function(value)
        self.State.searchQuery = value
        self.State.currentPage = 1
        refreshPage(parent, snapshot)
    end)

    local roleFilter = createSelectBar(content, layout.Filters and layout.Filters.Role or { x = 344, y = -72, w = 218, h = 36 }, self.State.selectedRole ~= "" and self.State.selectedRole or "All Roles", function(anchor)
        local menuItems = {}
        for index, option in ipairs(roleOptions) do
            menuItems[index] = {
                text = option,
                checked = option == self.State.selectedRole,
                func = function()
                    self.State.selectedRole = option
                    self.State.currentPage = 1
                    refreshPage(parent, snapshot)
                end,
            }
        end
        showDropdownMenu(anchor, menuItems)
    end)

    local rankFilter = createSelectBar(content, layout.Filters and layout.Filters.Rank or { x = 588, y = -72, w = 218, h = 36 }, self.State.selectedRank ~= "" and self.State.selectedRank or "All Ranks", function(anchor)
        local menuItems = {}
        for index, option in ipairs(rankOptions) do
            menuItems[index] = {
                text = option,
                checked = option == self.State.selectedRank,
                func = function()
                    self.State.selectedRank = option
                    self.State.currentPage = 1
                    refreshPage(parent, snapshot)
                end,
            }
        end
        showDropdownMenu(anchor, menuItems)
    end)

    local readinessFilter = createSelectBar(content, layout.Filters and layout.Filters.Readiness or { x = 832, y = -72, w = 226, h = 36 }, self.State.selectedReadiness ~= "" and self.State.selectedReadiness or "All Readiness", function(anchor)
        local menuItems = {}
        for index, option in ipairs(readinessOptions) do
            menuItems[index] = {
                text = option,
                checked = option == self.State.selectedReadiness,
                func = function()
                    self.State.selectedReadiness = option
                    self.State.currentPage = 1
                    refreshPage(parent, snapshot)
                end,
            }
        end
        showDropdownMenu(anchor, menuItems)
    end)

    local onlineToggle = createToggleBar(content, layout.Filters and layout.Filters.OnlineOnly or { x = 1084, y = -72, w = 208, h = 36 }, "Online only", self.State.onlineOnly and true or false, function()
        self.State.onlineOnly = not (self.State.onlineOnly and true or false)
        self.State.currentPage = 1
        refreshPage(parent, snapshot)
    end)

    if searchBar and searchBar.Texture then
        searchBar.Texture:SetVertexColor(0.74, 0.88, 1.00, 0.98)
    end
    if roleFilter and roleFilter.Background then
        roleFilter.Background:SetVertexColor(1.00, 0.90, 0.66, 0.98)
    end
    if rankFilter and rankFilter.Background then
        rankFilter.Background:SetVertexColor(0.88, 0.78, 1.00, 0.98)
    end
    if readinessFilter and readinessFilter.Background then
        readinessFilter.Background:SetVertexColor(0.74, 0.98, 0.78, 0.98)
    end
    if onlineToggle and onlineToggle.Background then
        onlineToggle.Background:SetVertexColor(1.00, 0.84, 0.58, 0.98)
    end

    local summaryBar = createSummaryBar(content, layout.Summary and layout.Summary.Bar or { x = 0, y = -120, w = 1312, h = 52 }, summary)

    local tableFrame = CreateFrame("Frame", nil, content)
    applyRect(tableFrame, content, layout.Table and layout.Table.Frame or { x = 0, y = -184, w = 1312, h = 276 })

    local headerRow = CreateFrame("Frame", nil, tableFrame)
    applyRect(headerRow, tableFrame, layout.Table and layout.Table.Header or { x = 0, y = -2, w = 1288, h = 28 })
    createColumnHeaders(headerRow, visibleColumns, visibleColumnDefinitions, self.State.sortKey, self.State.sortDirection == "asc", function(nextSortKey)
        if self.State.sortKey == nextSortKey then
            self.State.sortDirection = self.State.sortDirection == "asc" and "desc" or "asc"
        else
            self.State.sortKey = nextSortKey
            self.State.sortDirection = "asc"
        end
        refreshPage(parent, snapshot)
    end)

    local rowsRect = layout.Table and layout.Table.Rows or { x = 0, y = -34, w = 1288, h = 206 }
    rowsRect = { x = rowsRect.x, y = rowsRect.y, w = rowsRect.w, h = (rowsRect.h or 206) + 8 }
    local rowsViewport = CreateFrame("ScrollFrame", nil, tableFrame, "UIPanelScrollFrameTemplate")
    applyRect(rowsViewport, tableFrame, rowsRect)
    local rowHeight = math.max(24, ((layout.Spacing and layout.Spacing.RowHeight) or 40) - 8)
    local rowStep = math.max(1, rowHeight)
    local rowsPerPage = math.max(1, math.floor((rowsRect.h or rowHeight) / rowHeight))
    local pagedRows, currentPage, totalPages = paginateRows(sortedRows, self.State.currentPage, rowsPerPage)
    self.State.currentPage = currentPage
    local rowsHostHeight = math.max(rowsViewport:GetHeight(), (#pagedRows > 0 and (((#pagedRows - 1) * rowStep) + rowHeight) or rowHeight))
    local needsScrollbar = rowsHostHeight > (rowsViewport:GetHeight() + 1)
    local rowsHost = CreateFrame("Frame", nil, rowsViewport)
    rowsHost:SetPoint("TOPLEFT", rowsViewport, "TOPLEFT", 0, 0)
    rowsHost:SetSize(math.max(1, rowsViewport:GetWidth() - (needsScrollbar and 24 or 0)), rowsHostHeight)
    rowsViewport:SetScrollChild(rowsHost)

    local scrollBar = rowsViewport.ScrollBar
    if not scrollBar then
        for _, child in ipairs({ rowsViewport:GetChildren() }) do
            if child and child.GetObjectType and child:GetObjectType() == "Slider" then
                scrollBar = child
                break
            end
        end
    end
    if scrollBar then
        scrollBar:SetShown(needsScrollbar)
    end

    if #sortedRows == 0 then
        local emptyText = rowsHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        emptyText:SetPoint("TOPLEFT", rowsHost, "TOPLEFT", 0, 0)
        emptyText:SetText("No roster members match the current filters.")
        emptyText:SetTextColor(0.96, 0.93, 0.84, 1)
    else
        for index, rowData in ipairs(pagedRows) do
            createRosterRow(rowsHost, index, rowData, visibleColumns, visibleColumnDefinitions, rowHeight, selectedRow and rowData.id == selectedRow.id, function(clickedRow)
                self.State.selectedMemberId = clickedRow.id
                refreshPage(parent, snapshot)
            end)
        end
    end

    local footerText = tableFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    applyRect(footerText, tableFrame, layout.Table and layout.Table.Footer or { x = 4, y = -248, w = 260, h = 20 })
    footerText:SetPoint("TOPLEFT", tableFrame, "TOPLEFT", (layout.Table and layout.Table.Footer and layout.Table.Footer.x) or 4, ((layout.Table and layout.Table.Footer and layout.Table.Footer.y) or -248) - 5)
    footerText:SetJustifyH("LEFT")
    footerText:SetText(string.format("Total Members: %d", #sortedRows))
    footerText:SetTextColor(0.72, 0.68, 0.58, 1)

    local footerDivider = tableFrame:CreateTexture(nil, "ARTWORK")
    footerDivider:SetPoint("BOTTOMLEFT", tableFrame, "BOTTOMLEFT", 0, 25)
    footerDivider:SetPoint("BOTTOMRIGHT", tableFrame, "BOTTOMRIGHT", 0, 25)
    footerDivider:SetHeight(1)
    footerDivider:SetColorTexture(0.58, 0.44, 0.16, 0.62)

    local paginationRect = layout.Table and layout.Table.Pagination or { x = 470, y = -244, w = 356, h = 30 }
    local pagination = createPagination(tableFrame, { x = paginationRect.x, y = paginationRect.y - 5, w = paginationRect.w, h = paginationRect.h }, currentPage, totalPages, function(nextPage)
        self.State.currentPage = nextPage
        refreshPage(parent, snapshot)
    end)

    local selectedMemberPanel = createSelectedMemberPanel(content, layout.Bottom and layout.Bottom.Selected or { x = 0, y = -474, w = 572, h = 176 }, selectedRow)
    local readinessPanel = createReadinessPanel(content, layout.Bottom and layout.Bottom.Profile or { x = 588, y = -474, w = 724, h = 176 }, selectedRow)
    local statsOverlay = nil

    if self.State.showStatsPopup then
        statsOverlay = createRosterStatsOverlay(root, rows, function()
            self.State.showStatsPopup = false
            refreshPage(parent, snapshot)
        end, function()
            self.State.showStatsPopup = false
            self.State.exportDialog = {
                title = "Roster Stats Export",
                text = buildRosterStatsExportText(buildRosterStats(rows)),
                helpText = "Copy this text to save or share the current roster stats snapshot.",
            }
            refreshPage(parent, snapshot)
        end, function()
            self.State.showStatsPopup = false
            refreshPage(parent, snapshot)
        end)
    end

    if self.State.exportDialog then
        root.ExportOverlay = createTextExportOverlay(root, self.State.exportDialog, function()
            self.State.exportDialog = nil
            refreshPage(parent, snapshot)
        end)
    end

    root.Container = container
    root.StatsButton = statsButton
    root.ExportButton = exportButton
    root.ColumnsButton = columnsButton
    root.RefreshButton = refreshButton
    root.SearchBar = searchBar
    root.RoleFilter = roleFilter
    root.RankFilter = rankFilter
    root.ReadinessFilter = readinessFilter
    root.OnlineToggle = onlineToggle
    root.SummaryBar = summaryBar
    root.TableFrame = tableFrame
    root.RowsViewport = rowsViewport
    root.RowsHost = rowsHost
    root.FooterText = footerText
    root.Pagination = pagination
    root.SelectedMemberPanel = selectedMemberPanel
    root.ReadinessPanel = readinessPanel
    root.StatsOverlay = statsOverlay
    return root
end

return page