GuildHelper = GuildHelper or {}

local addon = GuildHelper
addon.MainFrame = addon.MainFrame or {}
addon.MainFrame._mythicPortalTextureCache = addon.MainFrame._mythicPortalTextureCache or {}

local NAV_ITEMS = {
    { key = "board", label = "News & Announcements" },
    { key = "dashboard", label = "Guild Information" },
    { key = "roster", label = "Roster" },
    { key = "mytoons", label = "My Toons" },
    { key = "mythics", label = "Mythic+", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("mythics") end },
    { key = "bis", label = "BIS", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("bis") end },
    { key = "raidbingo", label = "Raid Bingo", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("raidbingo") end },
    { key = "gratsu", label = "GratsU", visible = function() return addon.GratsUService and addon.GratsUService:CanAccess() end },
    { key = "celebrations", label = "Toon Celebrations", visible = function() return addon.ToonCelebrationService and addon.ToonCelebrationService:CanAccess() end },
    { key = "groups", label = "Groups", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("groups") end },
    { key = "settings", label = "Settings" },
    { key = "sync", label = "DataSync" },
    { key = "setup", label = "Linked Guild Setup" },
    { key = "help", label = "Help" },
    { key = "maintenance", label = "Maintenance" },
}

local BOARD_TYPE_OPTIONS = { "all", "announcement", "event", "resource" }
local PROFESSION_OPTIONS = { "alchemy", "blacksmithing", "enchanting", "engineering", "herbalism", "inscription", "jewelcrafting", "leatherworking", "mining", "skinning", "tailoring" }
local INTEREST_OPTIONS = { "raiding", "mythic+", "delves", "pvp", "alts", "crafting", "farming", "events", "social" }
local MIN_FRAME_WIDTH = 1280
local MIN_FRAME_HEIGHT = 820
local ROSTER_PAGE_SIZE = 20
local LEGACY_ROSTER_MAX_COLUMNS = 7
local BACKGROUND_REFRESH_MIN_INTERVAL_SECONDS = 5
local RECRUITER_INVITE_MACRO_NAME = "GH3InviteRun"
local BUTTON_FONT_PATH = "Fonts\\MORPHEUS.ttf"
local TITLE_FONT_PATH = "Fonts\\MORPHEUS.ttf"
local DEFAULT_PANE_BODY_FONT_KEY = "default"
local DEFAULT_PANE_TITLE_FONT_KEY = "morpheus"
local TEXT_VIEWER_AUTOSELECT_MAX_CHARS = 12000
local PANE_TITLE_FONT_OPTIONS = {
    { key = "morpheus", label = "Morpheus", path = "Fonts\\MORPHEUS.ttf" },
    { key = "frizqt", label = "Friz Quadrata", path = "Fonts\\FRIZQT__.TTF" },
    { key = "arialn", label = "Arial Narrow", path = "Fonts\\ARIALN.ttf" },
    { key = "skurri", label = "Skurri", path = "Fonts\\skurri.ttf" },
}
local PANE_BODY_FONT_OPTIONS = {
    { key = "default", label = "Default UI", path = false },
    { key = "frizqt", label = "Friz Quadrata", path = "Fonts\\FRIZQT__.TTF" },
    { key = "arialn", label = "Arial Narrow", path = "Fonts\\ARIALN.ttf" },
    { key = "morpheus", label = "Morpheus", path = "Fonts\\MORPHEUS.ttf" },
    { key = "skurri", label = "Skurri", path = "Fonts\\skurri.ttf" },
}
local TITLE_TEXT_COLOR = { 0.93, 0.84, 0.63, 1 }
local HIGHLIGHT_TEXT_COLOR = { 0.88, 0.78, 0.56, 1 }
local BODY_TEXT_COLOR = { 0.8, 0.79, 0.74, 1 }
local MUTED_TEXT_COLOR = { 0.7, 0.71, 0.72, 1 }
local ACTION_TEXT_COLOR = { 0.86, 0.64, 0.33, 1 }
local ACTION_DISABLED_TEXT_COLOR = { 0.5, 0.38, 0.28, 1 }
local BIS_ROW_VISUALS = {
    ["below-champion"] = {
        baseColor = { 0.18, 0.18, 0.2, 0.96 },
        borderColor = { 0.56, 0.58, 0.62, 1 },
        selectedBaseColor = { 0.23, 0.23, 0.26, 0.98 },
        selectedBorderColor = { 0.76, 0.78, 0.82, 1 },
        textColor = { 0.9, 0.9, 0.92, 1 },
    },
    ["champion-bis"] = {
        baseColor = { 0.08, 0.16, 0.32, 0.96 },
        borderColor = { 0.34, 0.62, 0.98, 1 },
        selectedBaseColor = { 0.1, 0.21, 0.41, 0.98 },
        selectedBorderColor = { 0.62, 0.82, 1, 1 },
        textColor = { 0.88, 0.93, 1, 1 },
    },
    ["empty"] = {
        baseColor = { 0.16, 0.13, 0.08, 0.94 },
        borderColor = { 0.52, 0.4, 0.18, 1 },
        selectedBaseColor = { 0.21, 0.17, 0.1, 0.98 },
        selectedBorderColor = { 0.72, 0.56, 0.24, 1 },
        textColor = { 0.9, 0.84, 0.72, 1 },
    },
    ["heroic-bis"] = {
        baseColor = { 0.14, 0.12, 0.34, 0.96 },
        borderColor = { 0.48, 0.52, 0.98, 1 },
        selectedBaseColor = { 0.18, 0.15, 0.42, 0.98 },
        selectedBorderColor = { 0.7, 0.74, 1, 1 },
        textColor = { 0.91, 0.9, 1, 1 },
    },
    ["missing"] = {
        baseColor = { 0.29, 0.24, 0.08, 0.96 },
        borderColor = { 0.9, 0.78, 0.28, 1 },
        selectedBaseColor = { 0.35, 0.29, 0.1, 0.98 },
        selectedBorderColor = { 0.98, 0.88, 0.48, 1 },
        textColor = { 0.98, 0.94, 0.78, 1 },
    },
    ["mythic-bis"] = {
        baseColor = { 0.26, 0.08, 0.32, 0.96 },
        borderColor = { 0.76, 0.36, 0.96, 1 },
        selectedBaseColor = { 0.32, 0.1, 0.38, 0.98 },
        selectedBorderColor = { 0.88, 0.62, 1, 1 },
        textColor = { 0.97, 0.9, 1, 1 },
    },
    ["optional"] = {
        baseColor = { 0.08, 0.24, 0.12, 0.96 },
        borderColor = { 0.34, 0.82, 0.44, 1 },
        selectedBaseColor = { 0.1, 0.3, 0.15, 0.98 },
        selectedBorderColor = { 0.58, 0.94, 0.66, 1 },
        textColor = { 0.9, 0.98, 0.9, 1 },
    },
}

local AUDIT_TEXT_COLOR_DEFAULTS = {
    default = { 0.84, 0.84, 0.84, 1 },
    join = { 0.24, 0.84, 0.36, 1 },
    leave = { 0.66, 0.66, 0.66, 1 },
    kick = { 0.96, 0.28, 0.28, 1 },
    rank = { 0.96, 0.84, 0.24, 1 },
    note = { 0.76, 0.5, 0.92, 1 },
    officerNote = { 0.14, 0.56, 0.22, 1 },
}

local AUDIT_TEXT_COLOR_SELECTOR_GROUPS = {
    { key = "join", label = "Joins" },
    { key = "leave", label = "Leaves" },
    { key = "kick", label = "Kicks" },
    { key = "rank", label = "Rank Change" },
    { key = "note", label = "Public Notes" },
    { key = "officerNote", label = "Officer Notes" },
}

local function copyAuditTextColor(color, fallback)
    local base = type(fallback) == "table" and fallback or AUDIT_TEXT_COLOR_DEFAULTS.default
    local source = type(color) == "table" and color or base

    local function clampChannel(value, channelFallback)
        local numericValue = tonumber(value)

        if numericValue == nil then
            return channelFallback
        end

        if numericValue < 0 then
            return 0
        end

        if numericValue > 1 then
            return 1
        end

        return numericValue
    end

    return {
        clampChannel(source.r or source[1], base[1]),
        clampChannel(source.g or source[2], base[2]),
        clampChannel(source.b or source[3], base[3]),
        clampChannel(source.a or source[4], base[4] or 1),
    }
end

local function getAuditTextColor(auditTextColors, eventType)
    local resolvedEventType = tostring(eventType or "")
    local fallback = AUDIT_TEXT_COLOR_DEFAULTS[resolvedEventType] or AUDIT_TEXT_COLOR_DEFAULTS.default

    if type(auditTextColors) ~= "table" then
        return copyAuditTextColor(fallback, fallback)
    end

    return copyAuditTextColor(auditTextColors[resolvedEventType], fallback)
end

local function setAuditColorButtonPreview(button, color)
    if not button then
        return
    end

    local resolvedColor = copyAuditTextColor(color, AUDIT_TEXT_COLOR_DEFAULTS.default)

    if not button._gh3ColorPreview then
        local preview = button:CreateTexture(nil, "OVERLAY")
        preview:SetSize(14, 14)
        preview:SetPoint("LEFT", button, "LEFT", 6, 0)
        button._gh3ColorPreview = preview

        if button:GetFontString() then
            button:GetFontString():ClearAllPoints()
            button:GetFontString():SetPoint("LEFT", preview, "RIGHT", 6, 0)
            button:GetFontString():SetPoint("RIGHT", button, "RIGHT", -6, 0)
            button:GetFontString():SetJustifyH("LEFT")
        end
    end

    if button._gh3ColorPreview.SetColorTexture then
        button._gh3ColorPreview:SetColorTexture(unpack(resolvedColor))
    else
        button._gh3ColorPreview:SetTexture("Interface\\Buttons\\WHITE8X8")
        button._gh3ColorPreview:SetVertexColor(unpack(resolvedColor))
    end

    if button._gh3ColorPreview.Show then
        button._gh3ColorPreview:Show()
    end
end

local function refreshAuditColorButtonPreviewNextFrame(button, color)
    if not (C_Timer and C_Timer.After) then
        return
    end

    local queuedColor = copyAuditTextColor(color, AUDIT_TEXT_COLOR_DEFAULTS.default)

    C_Timer.After(0, function()
        if button and button.GetParent and button:GetParent() then
            setAuditColorButtonPreview(button, queuedColor)
        end
    end)
end

local function readColorPickerRGB()
    if ColorPickerFrame and type(ColorPickerFrame.GetColorRGB) == "function" then
        local red, green, blue = ColorPickerFrame:GetColorRGB()

        if red ~= nil and green ~= nil and blue ~= nil then
            return red, green, blue
        end
    end

    if ColorPickerFrame and ColorPickerFrame.Content and ColorPickerFrame.Content.ColorPicker and type(ColorPickerFrame.Content.ColorPicker.GetColorRGB) == "function" then
        local red, green, blue = ColorPickerFrame.Content.ColorPicker:GetColorRGB()

        if red ~= nil and green ~= nil and blue ~= nil then
            return red, green, blue
        end
    end

    return nil, nil, nil
end

local function presentAuditColorPicker(ownerFrame)
    if not ColorPickerFrame then
        return
    end

    if type(ColorPickerFrame.SetFrameStrata) == "function" then
        ColorPickerFrame:SetFrameStrata("TOOLTIP")
    end

    if ownerFrame and type(ColorPickerFrame.SetFrameLevel) == "function" and type(ownerFrame.GetFrameLevel) == "function" then
        ColorPickerFrame:SetFrameLevel(math.max(tonumber(ownerFrame:GetFrameLevel()) or 0, tonumber(ColorPickerFrame:GetFrameLevel()) or 0) + 20)
    end

    if ownerFrame and type(ColorPickerFrame.ClearAllPoints) == "function" and type(ColorPickerFrame.SetPoint) == "function" then
        local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 0
        local ownerLeft = ownerFrame.GetLeft and ownerFrame:GetLeft() or 0
        local ownerRight = ownerFrame.GetRight and ownerFrame:GetRight() or 0
        local pickerWidth = ColorPickerFrame.GetWidth and ColorPickerFrame:GetWidth() or 260
        local canAnchorRight = parentWidth > 0 and (ownerRight + pickerWidth + 20) <= parentWidth

        ColorPickerFrame:ClearAllPoints()

        if canAnchorRight then
            ColorPickerFrame:SetPoint("TOPLEFT", ownerFrame, "TOPRIGHT", 12, 0)
        elseif ownerLeft >= (pickerWidth + 20) then
            ColorPickerFrame:SetPoint("TOPRIGHT", ownerFrame, "TOPLEFT", -12, 0)
        else
            ColorPickerFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
        end
    end
end

local function openAuditColorPicker(initialColor, onChanged, onCancel, ownerFrame)
    local startingColor = copyAuditTextColor(initialColor, AUDIT_TEXT_COLOR_DEFAULTS.default)

    local function applyPickedColor()
        local red, green, blue = readColorPickerRGB()
        local nextColor = copyAuditTextColor({ red, green, blue, 1 }, startingColor)

        if type(onChanged) == "function" then
            onChanged(nextColor)
        end
    end

    local function cancelPickedColor(cancelData)
        local cancelledColor = startingColor

        if type(cancelData) == "table" then
            cancelledColor = copyAuditTextColor({
                cancelData.r or cancelData[1],
                cancelData.g or cancelData[2],
                cancelData.b or cancelData[3],
                cancelData.opacity or cancelData.a or cancelData[4] or 1,
            }, startingColor)
        end

        if type(onCancel) == "function" then
            onCancel(cancelledColor)
        end
    end

    if ColorPickerFrame and type(ColorPickerFrame.SetupColorPickerAndShow) == "function" then
        ColorPickerFrame:SetupColorPickerAndShow({
            r = startingColor[1],
            g = startingColor[2],
            b = startingColor[3],
            opacity = 1,
            hasOpacity = false,
            swatchFunc = applyPickedColor,
            cancelFunc = cancelPickedColor,
        })
        presentAuditColorPicker(ownerFrame)
        return true
    end

    if type(OpenColorPicker) == "function" then
        OpenColorPicker({
            r = startingColor[1],
            g = startingColor[2],
            b = startingColor[3],
            opacity = 1,
            hasOpacity = false,
            swatchFunc = applyPickedColor,
            cancelFunc = cancelPickedColor,
        })
        presentAuditColorPicker(ownerFrame)
        return true
    end

    if ColorPickerFrame then
        ColorPickerFrame.hasOpacity = false
        ColorPickerFrame.opacity = 1
        ColorPickerFrame.previousValues = {
            r = startingColor[1],
            g = startingColor[2],
            b = startingColor[3],
            opacity = 1,
        }
        ColorPickerFrame.func = applyPickedColor
        ColorPickerFrame.opacityFunc = nil
        ColorPickerFrame.cancelFunc = cancelPickedColor

        if type(ColorPickerFrame.SetColorRGB) == "function" then
            ColorPickerFrame:SetColorRGB(startingColor[1], startingColor[2], startingColor[3])
        end

        if type(ColorPickerFrame.Hide) == "function" then
            ColorPickerFrame:Hide()
        end

        if type(ColorPickerFrame.Show) == "function" then
            ColorPickerFrame:Show()
            presentAuditColorPicker(ownerFrame)
            return true
        end
    end

    return false
end

local function applyAuditRowVisual(row, labels, auditTextColors, eventType)
    if not row then
        return
    end

    local textColor = getAuditTextColor(auditTextColors, eventType)

    for _, label in ipairs(labels or {}) do
        if label and label.SetTextColor then
            label:SetTextColor(unpack(textColor))
        end
    end
end

local function isLeapYear(year)
    return (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
end

local function getDaysInMonth(year, month)
    local monthDays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
    if month == 2 and isLeapYear(year) then
        return 29
    end
    return monthDays[month] or 30
end

local function getCurrentBannerGuildKey()
    return addon.Store:GetGuildKey()
        or addon.Store:EnsureHomeGuild()
        or ("Unguilded-" .. (GetRealmName() or "Unknown"))
end

local function getPaneFontOption(options, optionKey, defaultKey)
    local resolvedKey = tostring(optionKey or defaultKey or "")

    for _, option in ipairs(options or {}) do
        if option.key == resolvedKey then
            return option
        end
    end

    for _, option in ipairs(options or {}) do
        if option.key == defaultKey then
            return option
        end
    end

    return options and options[1] or nil
end

local function getPaneFontProfile()
    local profile = addon.Store and addon.Store.GetProfile and addon.Store:GetProfile() or nil

    if type(profile) ~= "table" then
        return nil
    end

    profile.ui = profile.ui or {}
    profile.ui.fonts = profile.ui.fonts or {}
    return profile.ui.fonts
end

local function getConfiguredPaneFontOption(fontKind)
    local fontProfile = getPaneFontProfile() or {}

    if fontKind == "paneTitle" then
        return getPaneFontOption(PANE_TITLE_FONT_OPTIONS, fontProfile.paneTitle, DEFAULT_PANE_TITLE_FONT_KEY)
    end

    return getPaneFontOption(PANE_BODY_FONT_OPTIONS, fontProfile.paneBody, DEFAULT_PANE_BODY_FONT_KEY)
end

local function getConfiguredPaneFontPath(fontKind)
    local option = getConfiguredPaneFontOption(fontKind)
    return option and option.path or nil
end

local function setConfiguredPaneFont(fontKind, optionKey)
    local fontProfile = getPaneFontProfile()

    if not fontProfile then
        return
    end

    if fontKind == "paneTitle" then
        fontProfile.paneTitle = getPaneFontOption(PANE_TITLE_FONT_OPTIONS, optionKey, DEFAULT_PANE_TITLE_FONT_KEY).key
        return
    end

    fontProfile.paneBody = getPaneFontOption(PANE_BODY_FONT_OPTIONS, optionKey, DEFAULT_PANE_BODY_FONT_KEY).key
end

local function applyConfiguredFont(fontObject, requestedPath, requestedSize, requestedFlags, fallbackPath, fallbackSize, fallbackFlags)
    if not fontObject or type(fontObject.SetFont) ~= "function" then
        return
    end

    local currentPath
    local currentSize
    local currentFlags

    if type(fontObject.GetFont) == "function" then
        currentPath, currentSize, currentFlags = fontObject:GetFont()
    end

    local fontPath = requestedPath or currentPath or fallbackPath
    local fontSize = requestedSize or currentSize or fallbackSize
    local fontFlags = requestedFlags

    if fontFlags == nil then
        if currentFlags ~= nil and currentFlags ~= "" then
            fontFlags = currentFlags
        else
            fontFlags = fallbackFlags or ""
        end
    end

    if fontPath and fontSize then
        fontObject:SetFont(fontPath, fontSize, fontFlags)
    end
end

local THEME = {
    window = {
        bg = { 0.01, 0.02, 0.04, 0.985 },
        border = { 0.54, 0.42, 0.16, 1 },
        highlight = { 0.26, 0.3, 0.37, 0.7 },
        shadow = { 0, 0, 0, 0.9 },
        glow = { 0.88, 0.7, 0.24, 0.12 },
    },
    title = {
        bg = { 0.06, 0.07, 0.1, 0.98 },
        border = { 0.62, 0.48, 0.18, 1 },
        highlight = { 0.34, 0.37, 0.42, 0.72 },
        shadow = { 0, 0, 0, 0.92 },
        glow = { 0.94, 0.76, 0.25, 0.16 },
    },
    banner = {
        bg = { 0.07, 0.09, 0.13, 0.97 },
        border = { 0.66, 0.5, 0.17, 1 },
        highlight = { 0.32, 0.35, 0.41, 0.72 },
        shadow = { 0, 0, 0, 0.92 },
        glow = { 0.95, 0.78, 0.28, 0.14 },
    },
    sidebar = {
        bg = { 0.03, 0.04, 0.07, 0.985 },
        border = { 0.34, 0.4, 0.5, 1 },
        highlight = { 0.2, 0.23, 0.29, 0.68 },
        shadow = { 0, 0, 0, 0.92 },
        glow = { 0.44, 0.58, 0.8, 0.1 },
    },
    content = {
        bg = { 0.02, 0.03, 0.05, 0.985 },
        border = { 0.36, 0.42, 0.52, 1 },
        highlight = { 0.21, 0.24, 0.3, 0.7 },
        shadow = { 0, 0, 0, 0.94 },
        glow = { 0.46, 0.62, 0.86, 0.08 },
    },
    panel = {
        bg = { 0.05, 0.07, 0.1, 0.975 },
        border = { 0.34, 0.42, 0.52, 1 },
        highlight = { 0.22, 0.26, 0.32, 0.68 },
        shadow = { 0, 0, 0, 0.9 },
        glow = { 0.48, 0.64, 0.88, 0.08 },
    },
    card = {
        bg = { 0.07, 0.09, 0.13, 0.98 },
        border = { 0.38, 0.47, 0.58, 1 },
        highlight = { 0.24, 0.28, 0.35, 0.72 },
        shadow = { 0, 0, 0, 0.9 },
        glow = { 0.54, 0.68, 0.9, 0.08 },
    },
    input = {
        bg = { 0.03, 0.04, 0.07, 0.985 },
        border = { 0.58, 0.46, 0.18, 1 },
        highlight = { 0.32, 0.35, 0.4, 0.76 },
        shadow = { 0, 0, 0, 0.94 },
        glow = { 0.96, 0.78, 0.28, 0.1 },
    },
    button = {
        bg = { 0.09, 0.11, 0.15, 0.98 },
        border = { 0.58, 0.46, 0.18, 1 },
        highlight = { 0.3, 0.33, 0.39, 0.72 },
        shadow = { 0, 0, 0, 0.92 },
        glow = { 0.94, 0.76, 0.26, 0.12 },
        normal = { 0.11, 0.13, 0.18, 0.96 },
        pushed = { 0.06, 0.07, 0.1, 0.98 },
        highlightFill = { 0.94, 0.76, 0.24, 0.08 },
        disabled = { 0.05, 0.06, 0.08, 0.72 },
        text = { 0.96, 0.84, 0.28, 1 },
        disabledText = { 0.56, 0.54, 0.48, 1 },
    },
}
local LEGACY_ROSTER_COLUMN_ORDER = {
    "Main Toon",
    "Item Level",
    "Guild Name",
    "Rank",
    "Realm",
    "Faction",
    "Joined Guild",
    "Class",
    "Spec",
    "Birthdate",
    "Roles",
    "Interests",
    "Professions",
    "BattleTag",
}
local LEGACY_ROSTER_COLUMN_KEY_MAP = {
    ["BattleTag"] = "battleTag",
    ["Item Level"] = "itemLevel",
    ["Interests"] = "interests",
    ["Guild Name"] = "guildName",
    ["Professions"] = "professions",
    ["Realm"] = "realm",
    ["Main Toon"] = "mainCharacter",
    ["Spec"] = "spec",
    ["Joined Guild"] = "guildJoinDate",
    ["Class"] = "className",
    ["Birthdate"] = "birthdate",
    ["Faction"] = "faction",
    ["Roles"] = "roles",
    ["Rank"] = "rank",
}
local DEFAULT_ROSTER_COLUMNS = {
    ["BattleTag"] = true,
    ["Item Level"] = true,
    ["Interests"] = true,
    ["Guild Name"] = true,
    ["Professions"] = false,
    ["Realm"] = false,
    ["Main Toon"] = true,
    ["Spec"] = false,
    ["Joined Guild"] = false,
    ["Class"] = false,
    ["Birthdate"] = false,
    ["Faction"] = false,
    ["Roles"] = true,
    ["Rank"] = true,
}
local HELP_RAID_BINGO_DISCLAIMER = "Note: This is not a traditional Bingo game.  Its been adjusted to work with the UI and a Raids/Party.  Pick 5 numbers after the Raid Lead has loaded their interface. \n \n Disclaimer: This is a zero risk or stakes game for raid members. You are not allowed to buy in or exchange anything of value to play. This is a giveaway for raid members who consistently play with the raid group. To be eligible for the giveaway, you must be present for the entire raid and be a regular, showing up for at least 6 raids a month. The winner will be selected at the end of the raid once a month. The winner will be given a prize of one or more of the following: gold, gear, or gifts. If you have any questions or concerns, please contact the raid leader."
local HELP_CATEGORY_ORDER = {
    "Start Here",
    "Profiles And Planning",
    "Sync And Federation",
    "Admin Tools",
}
local HELP_ARTICLES = {
    {
        key = "overview",
        category = "Start Here",
        title = "Open GH3 And Find The Right Page",
        summary = "Use this article when someone asks what GH3 does or where they should start. GH3 keeps roster details, My Toons profiles, Mythic+ tracking, BIS lists, group planning, sync tools, and officer automation in one place.",
        steps = {
            "Open GH3 with /gh3 or the minimap button.",
            "Use the left menu to jump straight to the page you need instead of keeping notes in chat.",
            "If a feature is missing, check Settings first because that menu may be disabled there.",
            "Use Help as the KB when someone needs steps for setup, BIS, sync, Mythic+, or linked guilds.",
        },
        examples = {
            "Use /gh3 bis to jump straight to the BIS page.",
            "Use /gh3 mythics to open the Mythic+ page.",
            "Use /gh3 sync to request peer status from visible guildmates running GH3.",
        },
        notes = {
            "Roster data is the shared guild view. My Toons is the player-maintained profile view.",
            "Use the article titles in the left navigation as the first answer to repeated how-do-I questions.",
        },
    },
    {
        key = "first-time-setup",
        category = "Start Here",
        title = "First-Time Setup",
        summary = "Use these steps for a new player or a returning player who needs GH3 working again.",
        steps = {
            "Log in on a character in the guild that will use GH3.",
            "Open GH3 and click Refresh Peers at least once so GH3 can discover visible guildmates running the addon.",
            "Open My Toons and fill in the character's main, role, interests, and professions.",
            "If your guild uses linked guilds, wait until the guild masters finish Linked Guild Setup before expecting cross-guild sync to work.",
            "After a linked guild is approved, members should Refresh Peers again and run Sync Federation once.",
        },
        examples = {
            "A normal guild member usually needs My Toons plus same-guild sync, not Linked Guild Setup.",
            "A guild master working on linked guilds should read the Linked Guild article before testing roster sync.",
        },
        notes = {
            "Same-guild roster visibility comes from the live guild roster. My Toons sync fills in the player-managed details.",
        },
    },
    {
        key = "my-toons-roster",
        category = "Profiles And Planning",
        title = "My Toons And Roster",
        summary = "This article answers the common question: what should each player fill out so the guild roster and planners stay useful?",
        steps = {
            "Open My Toons and select the character you want to maintain.",
            "Set Main Toon correctly so alts roll up to the right main.",
            "Set Primary Role, Secondary Roles, and Combat Profile so GH3 can place you correctly in planners and recommendations.",
            "Fill in professions and interests so officers know who crafts, raids, farms, or pushes Mythic+.",
            "Click Save Toon after editing, then sync My Toons when you want guildmates to receive the updated profile data.",
        },
        examples = {
            "A raid-ready alt should still have its own role, item level, and spec updated so it shows correctly in roster reviews.",
            "If a player can tank and DPS, Primary Role should be the default job they expect to play and Secondary Roles should list the flex role.",
        },
        notes = {
            "If My Toons data is stale, the roster may still know the character exists, but recommendations will be weaker or misleading.",
        },
    },
    {
        key = "mythics",
        category = "Profiles And Planning",
        title = "Mythic+",
        summary = "Mythic+ is split into My Toons, Party Planning, Season, and Info. Use it for personal run tracking and quick party comparisons.",
        steps = {
            "Open Mythic+ and use My Toons to review the selected character's rating, current key, season rows, and portal state.",
            "Use Use Current Character when you want the page to snap back to the toon you are logged into.",
            "Join a party and use Party Planning to compare what each visible GH3 user is missing or likely to gain from a run.",
            "Use Season when you want a dungeon-pool view that keeps missing rows visible instead of hiding no-data entries.",
            "Use Info when someone asks why some live data is still sparse or lower confidence early in a season.",
        },
        examples = {
            "If Party Planning shows No Data for someone, that usually means Blizzard has not exposed current live data yet for that character or that player has not refreshed GH3 recently while grouped.",
            "If you want to answer who still needs a dungeon this week, Party Planning is usually the fastest page to check.",
        },
        notes = {
            "Mythic+ party visibility is best when party members are also running GH3 and have current Mythic data available locally.",
        },
    },
    {
        key = "bis",
        category = "Profiles And Planning",
        title = "BIS Tracking",
        summary = "Use BIS as a gear wishlist by toon and spec. GH3 tracks owned and equipped items, highlights slot state, supports journal-backed gear selection, and whispers about tracked drops so they are easier to notice.",
        steps = {
            "Open BIS and make sure the correct toon and spec are selected.",
            "Click Gear Select when you want to build or maintain the list inside GH3 instead of importing a spreadsheet.",
            "Inside Gear Select, use the Spec and Loot Spec buttons at the top to choose the list you are editing.",
            "For raid, dungeon, or tier pieces, click Select Item on a slot row and choose a journal item. GH3 will fill the item, source, instance, boss, and upgrade-track fields when that data is available.",
            "Use the Source button on each row to switch between Dungeon, Raid, Tier, PvP, Crafted, Vault, or another source type before picking or saving the item.",
            "Use + to add alternate targets for the same slot. Alternates count as tracked items and can still show as owned or equipped in the BIS table.",
            "For the most reliable BIS lookup and whisper alerts, use the ItemID / URL field with the Blizzard item ID, a Wowhead item URL, or a WoW item link whenever you have it.",
            "For PvP gear, set the row Source to PvP and enter the item through ItemID / URL because Blizzard does not expose PvP vendor stock through Encounter Journal.",
            "Use Share when you want a JSON payload for your guild site or another GH3 player. Import accepts that same JSON back into GH3 and still accepts spreadsheet-style BIS CSV rows.",
            "If Excel pastes all rows into one column, use Text to Columns and choose comma-delimited before editing the sheet.",
            "On Wowhead item pages, the item ID is the number in the URL. Example: wowhead.com/item=249913 means itemId 249913.",
            "Use Stat Goals on the BIS page to enter the raw secondary-stat targets from your guide, Murlok, or another source for the selected spec.",
            "Review the gear table after import: green means your main BIS is equipped, blue means an alternate tracked item is equipped, and yellow means you still need a tracked target for that slot.",
        },
        examples = {
            "Gear Select flow: choose the slot, set Source if needed, click Select Item, then save the row.",
            "Tier pieces should use Source = Tier so the picker shows tier-set rows instead of generic raid loot.",
            "PvP row example: head,249913,Conquest Helm,pvp",
            "Simple import format: head,My Best Helm",
            "Best lookup format: head,249913,Mask of Darkest Intent",
            "Wowhead URL also works in the itemId column: head,https://www.wowhead.com/item=249913,Mask of Darkest Intent",
            "Optional detailed format: trinket1,My Best Trinket,dungeon,Operation: Floodgate,+10 or higher,upgrade",
        },
        notes = {
            "BIS drop and tradable-share messages use whispers for the important cases so the notice is harder to miss than normal chat traffic.",
            "Import accepts both GH3 BIS share JSON and the older CSV row format, so website round-trips and spreadsheet edits can use the same button.",
            "Template and Simple are still useful for starting a list quickly, but Gear Select is now the easiest way to browse raid, dungeon, and tier items in-game.",
            "If you only have a Wowhead URL or an in-game item link, GH3 can extract the itemId from that text when you paste it into the ItemID / URL field.",
            "Stat Goals are informational targets for the selected spec. Leave a field blank when you do not want to track that stat goal.",
        },
    },
    {
        key = "groups",
        category = "Profiles And Planning",
        title = "Groups And Signups",
        summary = "Groups is the guild event planner. Use it for raids, Mythic+ events, recurring runs, and signup management.",
        steps = {
            "Officers or guild masters click Create Activity and choose the event type.",
            "Set the event name, schedule, recurrence, minimum item level, and role requirements before saving.",
            "Players use Sign Up on the activity detail page to join the selected run or occurrence.",
            "Officers can review suggested lineups, deficits, and signups from the activity detail pane.",
            "Use Invite Best or Invite All when the accepted lineup is ready to bring in-game.",
        },
        examples = {
            "Recurring raid: set Type to Raid, choose the difficulty, set the schedule, then use Weekly and select the raid days.",
            "Weekly M+ night: set Type to Mythic+, enter the target key level, and save it as a weekly recurring activity.",
        },
        notes = {
            "Only officers and the guild master can edit activities. Regular members can still sign up from the detail pane.",
        },
    },
    {
        key = "sync",
        category = "Sync And Federation",
        title = "DataSync",
        summary = "Use DataSync when someone asks how to compare data with another GH3 user or why synced data is missing.",
        steps = {
            "Open DataSync and click Refresh Peers so GH3 can discover visible peers and compare table status.",
            "Make sure the correct peer is selected before syncing anything.",
            "Sync the specific table you need instead of pressing Sync All by default.",
            "Inside the same guild, use Toons+Mythics for player-managed profile data and mythic records. Use Board or Groups only when those tables need an update.",
            "Review diagnostics and the conflict queue if a sync result looks wrong or incomplete.",
        },
        examples = {
            "A new player who only needs profile updates usually needs Sync Toons+Mythics, not Sync Roster.",
            "If a linked-guild member cannot see cross-guild roster data yet, Sync Federation usually has to happen before Roster sync.",
        },
        notes = {
            "If Mythic+ or Groups is disabled in Settings, DataSync will warn that reduced activity is expected.",
            "Use Clear Conflicts only after deciding whether local or remote data should win.",
        },
    },
    {
        key = "linked-guilds",
        category = "Sync And Federation",
        title = "Linked Guild Setup",
        summary = "This is the KB article for cross-guild setup. It is only for guild masters creating or approving a linked-guild relationship.",
        steps = {
            "Both guild masters join the same party or raid.",
            "Both guild masters open Linked Guild Setup.",
            "Click Refresh Party GMs to discover the other guild master and their home guild automatically.",
            "Click Request Link for the discovered guild master.",
            "Once both sides have requested the link, both guild masters click Approve.",
            "After approval, members should Refresh Peers and Sync Federation from someone who already has the approved link.",
        },
        examples = {
            "If only one guild master has approved the link, the relationship is not finished yet and members should not expect cross-guild roster sync to work.",
        },
        notes = {
            "Home guild always stays local to each player. Federation only distributes approved linked-guild relationships so allowed cross-guild sync can work.",
        },
    },
    {
        key = "settings",
        category = "Admin Tools",
        title = "Settings",
        summary = "Settings is where you quiet unused systems and adjust pane-only font styles for readability.",
        steps = {
            "Open Settings when you want to disable a feature you do not use or when troubleshooting addon activity.",
            "Toggle Mythic+, BIS, Raid Bingo, or Groups off to hide the menu and stop their related background work.",
            "Use Pane Title Font and Pane Text Font if players need a different font family inside the menu panes.",
            "Use Reset Fonts to return pane fonts to the default choices.",
        },
        examples = {
            "If someone never uses Raid Bingo, disabling it removes that menu and its related background handling.",
            "If a player has trouble reading a decorative font, switch pane text to a simpler font family without changing frame layouts.",
        },
        notes = {
            "Pane font settings do not change the left menu, menu button fonts, or the main GH3 frame title.",
        },
    },
    {
        key = "helper-kick",
        category = "Admin Tools",
        title = "Kick With GH3 Helper",
        summary = "Use this article when officers ask how to open GH3 Helper from Guild & Communities and run batch kicks from the live guild roster.",
        steps = {
            "Open Blizzard Guild & Communities on the guild roster view.",
            "Click the GH3 Helper button near the guild controls or footer launcher.",
            "Stay on the Kick tab and click Run Scan to rebuild the inactive-member queue.",
            "Set Inactive Days, Scope, and the linked-alt option before reviewing the queue.",
            "Use Select All or manual selection to choose the members you actually want in the batch.",
            "Click Build Macro to preview the current /gremove batch.",
            "Click Batch Run and confirm the preview when you are ready to execute that kick batch.",
            "Let GH3 refresh and rebuild the next batch automatically when more candidates remain than one macro can hold.",
        },
        examples = {
            "Mains-only cleanup: set Scope to Mains, run the scan, then build the batch from the filtered queue.",
            "Family cleanup: enable the linked-alt option before building the batch when a kicked main should take linked alts with them.",
        },
        notes = {
            "GH3 Helper stays unavailable during combat and only works for characters who can use the guild-helper workflow.",
            "Maintenance in the GH3 main frame is for recruiter and local-record cleanup. Live guild kicks run from the Guild & Communities GH3 Helper button.",
        },
    },
    {
        key = "helper-promote",
        category = "Admin Tools",
        title = "Promote With GH3 Helper",
        summary = "Use this article when officers ask how to batch promote members from the Guild & Communities GH3 Helper button.",
        steps = {
            "Open Blizzard Guild & Communities on the guild roster view.",
            "Click the GH3 Helper button, then switch to the Promote tab.",
            "Choose the From rank, To rank, and Tenure filter before reviewing the queue.",
            "Review the queued promotion candidates and adjust the selection if needed.",
            "Click Build Macro to preview the current /gpromote batch.",
            "Click Batch Run and confirm the preview when you are ready to promote that batch.",
            "If the target rank is more than one step above the source rank, keep following the GH3 Helper step chain until the chosen target rank is reached.",
        },
        examples = {
            "Tenure-based promotion sweep: set From to the current rank, To to the next destination rank, then raise the Tenure filter before building the batch.",
            "Small spot promotion: leave the queue broad, deselect everyone except the members you want, then run the batch for that reduced selection.",
        },
        notes = {
            "WoW protects guild promotions one rank step at a time, so multi-rank promotions run as a chained series of GH3 Helper batch steps.",
            "GH3 Helper uses the live guild roster for promotion work. The Maintenance pane is not the launcher for batch promotions.",
        },
    },
    {
        key = "recruiter",
        category = "Admin Tools",
        title = "Recruiter Invites",
        summary = "Use this article when officers ask how the recruiter flow works inside Maintenance > Invites.",
        steps = {
            "Open Maintenance and switch to the Invites tab.",
            "Click Start Scan to begin one broad recruiter WHO pass for the configured level range.",
            "Use Scan Next after the cooldown expires when GH3 has queued narrower follow-up scans from the previous pass.",
            "Review the scan queue, then keep or deselect names before inviting.",
            "Use Invite Next for one name or Invite Batch to send a one-click secure guild-invite batch for the selected queue.",
            "Open Settings when you need to change level range, retry days, invite whispers, sync options, or zone-only filtering.",
        },
        examples = {
            "Fresh sweep: Start Scan, wait for the first WHO pass to finish, then use Scan Next only when GH3 says another refinement is queued.",
            "Selective recruiting: leave the scan queue broad, deselect anyone you do not want, then run Invite Batch for the remaining names.",
        },
        notes = {
            "GH3 waits 8 seconds between recruiter WHO scans to reduce disconnect-risk spam.",
            "Recruiter batch invites now count real outcomes from Blizzard system messages instead of assuming every raw invite call worked.",
            "If a batch name never returns a follow-up system message, GH3 marks that name as contacted and removes it from the active queue so it is not immediately offered again.",
            "Players can suppress random guild invites in Blizzard Social settings, so invite acceptance still depends on the player's client settings and availability.",
        },
    },
    {
        key = "raid-bingo",
        category = "Admin Tools",
        title = "Raid Bingo",
        summary = "Raid Bingo is a raid-night giveaway workflow, not a traditional buy-in bingo system. Use this article when the raid lead or players need the quick process.",
        steps = {
            "Leader starts or resets the session and sets the prize pool.",
            "Players pick 5 numbers and lock their cards after the leader says to begin.",
            "Leader confirms enough locked cards are present before rolling numbers.",
            "Leader rolls numbers slowly so players can follow along.",
            "When GH3 shows a winner, the leader handles the prize or giveaway result.",
        },
        examples = {
            "Leader workflow: Start Session, wait for locked cards, Roll Number, then Post Winners when the raid is finished.",
            "Player workflow: pick numbers once, lock the card, then wait for rolls and results.",
        },
        notes = {
            "Players cannot change the prize pool locally during a live session.",
            "Only the leader can post winners and payout messages.",
        },
        disclaimer = HELP_RAID_BINGO_DISCLAIMER,
    },
    {
        key = "automation",
        category = "Admin Tools",
        title = "GratsU And Toon Celebrations",
        summary = "Use this article when officers ask how the automated guild messaging tools are intended to work.",
        steps = {
            "Open GratsU to manage achievement and welcome message templates if you are an officer or guild master.",
            "Open Toon Celebrations to manage birthday and anniversary reminders and message templates.",
            "Preview messages before saving when you want to confirm formatting and tone.",
            "Use Restore All only when you want to reset the template library for that tool back to defaults.",
        },
        examples = {
            "GratsU is for automated guild congratulations and welcome messages.",
            "Toon Celebrations is for login reminders and send-or-ignore celebration queues, not blind auto-posting.",
        },
        notes = {
            "These menus are officer and guild master tools. They stay hidden when the player does not have access.",
        },
    },
    {
        key = "maintenance",
        category = "Admin Tools",
        title = "Maintenance And Cleanup",
        summary = "Maintenance is the repair and cleanup page for stale roster records and suspect data.",
        steps = {
            "Run Scan to refresh the stale-member review list.",
            "Use Exclude when a record should stay out of cleanup review.",
            "Use Purge only when a stale record is truly ready to be removed.",
            "Review the status reason before purging so you understand why the record was flagged.",
        },
        examples = {
            "Missing From Roster means the member was not found in the latest current-toon guild roster scan. It does not automatically mean the record is bad.",
        },
        notes = {
            "Use the GH3 Helper button in Guild & Communities for live guild kick and promote batches.",
            "This page is for recruiter work plus cleanup and corrective actions inside GH3, not the launcher for live guild kick and promote workflows.",
        },
    },
}

local function getHelpArticleIndexByKey(articleKey)
    for index, article in ipairs(HELP_ARTICLES or {}) do
        if article.key == articleKey then
            return index
        end
    end

    return 1
end

local function getHelpArticleByKey(articleKey)
    local index = getHelpArticleIndexByKey(articleKey)
    return HELP_ARTICLES[index], index
end

local function formatHelpList(items, ordered)
    if type(items) ~= "table" or #items == 0 then
        return ""
    end

    local lines = {}

    for index, item in ipairs(items) do
        if ordered then
            lines[#lines + 1] = string.format("%d. %s", index, tostring(item))
        else
            lines[#lines + 1] = "- " .. tostring(item)
        end
    end

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

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

local function attachItemHyperlinkTooltip(widget, itemLink, fallbackTitle)
    if not widget then
        return
    end

    widget:EnableMouse(true)
    widget:SetScript("OnEnter", function(control)
        local link = safeTrim(itemLink)

        GameTooltip:SetOwner(control, "ANCHOR_RIGHT")

        if link ~= "" and GameTooltip.SetHyperlink then
            GameTooltip:SetHyperlink(link)
        else
            GameTooltip:SetText(safeTrim(fallbackTitle) ~= "" and fallbackTitle or "Item", 0.93, 0.84, 0.63, 1, true)
        end

        GameTooltip:Show()
    end)
    widget:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

local function cleanupFrameTree(frame)
    if not frame then
        return
    end

    local children = { frame:GetChildren() }

    for _, child in ipairs(children) do
        cleanupFrameTree(child)
    end

    if type(frame._gh3Cleanup) == "function" then
        pcall(frame._gh3Cleanup, frame)
        frame._gh3Cleanup = nil
    end

    if type(frame.UnregisterAllEvents) == "function" then
        frame:UnregisterAllEvents()
    end

    if type(frame.SetScript) == "function" then
        frame:SetScript("OnEvent", nil)
        frame:SetScript("OnUpdate", nil)
    end
end

local function clearChildren(frame)
    local children = { frame:GetChildren() }

    for _, child in ipairs(children) do
        cleanupFrameTree(child)
        child:Hide()
        child:SetParent(nil)
    end
end

local function applySurfaceStyle(frame, palette)
    if not frame or not palette or type(frame.SetBackdrop) ~= "function" then
        return
    end

    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    frame:SetBackdropColor(unpack(palette.bg))
    frame:SetBackdropBorderColor(unpack(palette.border))

    if not frame._gh3Depth then
        local depth = {}
        depth.top = frame:CreateTexture(nil, "BORDER")
        depth.left = frame:CreateTexture(nil, "BORDER")
        depth.bottom = frame:CreateTexture(nil, "BORDER")
        depth.right = frame:CreateTexture(nil, "BORDER")
        depth.inner = frame:CreateTexture(nil, "ARTWORK")
        depth.glow = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        depth.glow:SetFrameLevel(math.max(frame:GetFrameLevel() - 1, 0))
        depth.glow:SetBackdrop({
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
        })
        depth.top:SetPoint("TOPLEFT", frame, "TOPLEFT", 1, -1)
        depth.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
        depth.top:SetHeight(1)
        depth.left:SetPoint("TOPLEFT", frame, "TOPLEFT", 1, -1)
        depth.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 1, 1)
        depth.left:SetWidth(1)
        depth.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 1, 1)
        depth.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -1, 1)
        depth.bottom:SetHeight(1)
        depth.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
        depth.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -1, 1)
        depth.right:SetWidth(1)
        depth.inner:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2)
        depth.inner:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
        depth.glow:SetPoint("TOPLEFT", frame, "TOPLEFT", -2, 2)
        depth.glow:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 2, -2)
        frame._gh3Depth = depth
    end

    frame._gh3Depth.top:SetColorTexture(unpack(palette.highlight))
    frame._gh3Depth.left:SetColorTexture(unpack(palette.highlight))
    frame._gh3Depth.bottom:SetColorTexture(unpack(palette.shadow))
    frame._gh3Depth.right:SetColorTexture(unpack(palette.shadow))
    frame._gh3Depth.inner:SetColorTexture(1, 1, 1, 0.025)
    frame._gh3Depth.glow:SetBackdropBorderColor(unpack(palette.glow or palette.border))
end

local function styleButton(button)
    local palette = THEME.button
    local fontSize = math.max(10, math.min(14, math.floor((button:GetHeight() or 24) * 0.55)))

    applySurfaceStyle(button, palette)

    button:SetNormalTexture("Interface\\Buttons\\WHITE8X8")
    button:SetPushedTexture("Interface\\Buttons\\WHITE8X8")
    button:SetHighlightTexture("Interface\\Buttons\\WHITE8X8", "ADD")
    button:SetDisabledTexture("Interface\\Buttons\\WHITE8X8")

    if button:GetNormalTexture() then
        button:GetNormalTexture():SetAllPoints(button)
        button:GetNormalTexture():SetVertexColor(unpack(palette.normal))
    end

    if button:GetPushedTexture() then
        button:GetPushedTexture():SetAllPoints(button)
        button:GetPushedTexture():SetVertexColor(unpack(palette.pushed))
    end

    if button:GetHighlightTexture() then
        button:GetHighlightTexture():SetAllPoints(button)
        button:GetHighlightTexture():SetVertexColor(unpack(palette.highlightFill))
    end

    if button:GetDisabledTexture() then
        button:GetDisabledTexture():SetAllPoints(button)
        button:GetDisabledTexture():SetVertexColor(unpack(palette.disabled))
    end

    if button:GetFontString() then
        button:GetFontString():SetFont(BUTTON_FONT_PATH, fontSize, "OUTLINE")
        button:GetFontString():SetTextColor(unpack(palette.text))
        button:GetFontString():SetShadowOffset(1, -1)
    end

    hooksecurefunc(button, "Disable", function(self)
        if self:GetFontString() then
            self:GetFontString():SetTextColor(unpack(palette.disabledText))
        end
    end)
    hooksecurefunc(button, "Enable", function(self)
        if self:GetFontString() then
            self:GetFontString():SetTextColor(unpack(palette.text))
        end
    end)

    if button:GetFontString() and not button:IsEnabled() then
        button:GetFontString():SetTextColor(unpack(palette.disabledText))
    end

end

local function styleHelperLauncherButton(button)
    if not button then
        return
    end

    local borderColor = { 0.92, 0.76, 0.22, 1 }
    local glowColor = { 0.98, 0.84, 0.34, 1 }

    styleButton(button)

    if button.SetBackdropBorderColor then
        button:SetBackdropBorderColor(unpack(borderColor))
    end

    if button._gh3Depth then
        button._gh3Depth.top:SetColorTexture(unpack(borderColor))
        button._gh3Depth.left:SetColorTexture(unpack(borderColor))
        button._gh3Depth.bottom:SetColorTexture(unpack(borderColor))
        button._gh3Depth.right:SetColorTexture(unpack(borderColor))
        button._gh3Depth.glow:SetBackdropBorderColor(unpack(glowColor))
    end
end

local function styleTitleText(fontString, size)
    if not fontString then
        return
    end

    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneTitle") or TITLE_FONT_PATH, size or 20, "OUTLINE", TITLE_FONT_PATH, size or 20, "OUTLINE")
    fontString:SetTextColor(unpack(TITLE_TEXT_COLOR))
    fontString:SetShadowOffset(1, -1)
end

local function styleHighlightText(fontString)
    if not fontString then
        return
    end

    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneTitle"), nil, nil, TITLE_FONT_PATH, 14, "")
    fontString:SetTextColor(unpack(HIGHLIGHT_TEXT_COLOR))
    fontString:SetShadowOffset(1, -1)
end

local function styleBodyText(fontString, muted)
    if not fontString then
        return
    end

    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneBody"), nil, nil, nil, 12, "")

    if muted then
        fontString:SetTextColor(unpack(MUTED_TEXT_COLOR))
    else
        fontString:SetTextColor(unpack(BODY_TEXT_COLOR))
    end

    fontString:SetShadowOffset(1, -1)
end

local function styleInputText(input, muted)
    if not input then
        return
    end

    applyConfiguredFont(input, getConfiguredPaneFontPath("paneBody"), nil, nil, nil, 12, "")

    if muted then
        input:SetTextColor(unpack(MUTED_TEXT_COLOR))
    else
        input:SetTextColor(unpack(BODY_TEXT_COLOR))
    end

    input:SetShadowOffset(1, -1)
    input:SetShadowColor(0, 0, 0, 0.85)

    if input.SetTextInsets then
        input:SetTextInsets(6, 6, 0, 0)
    end
end

local function styleDropdownText(dropdown)
    if not dropdown then
        return
    end

    local text = dropdown.Text or (dropdown.GetName and dropdown:GetName() and _G[dropdown:GetName() .. "Text"])
    if text then
        styleBodyText(text)
    end
end

local function styleActionText(fontString)
    if not fontString then
        return
    end

    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneBody"), nil, nil, nil, 12, "")
    fontString:SetTextColor(unpack(ACTION_TEXT_COLOR))
    fontString:SetShadowOffset(1, -1)
end

local function styleActionButton(button)
    if not button then
        return
    end

    styleButton(button)

    if button:GetFontString() then
        button:GetFontString():SetTextColor(unpack(button:IsEnabled() and ACTION_TEXT_COLOR or ACTION_DISABLED_TEXT_COLOR))
    end

    hooksecurefunc(button, "Disable", function(self)
        if self:GetFontString() then
            self:GetFontString():SetTextColor(unpack(ACTION_DISABLED_TEXT_COLOR))
        end
    end)

    hooksecurefunc(button, "Enable", function(self)
        if self:GetFontString() then
            self:GetFontString():SetTextColor(unpack(ACTION_TEXT_COLOR))
        end
    end)
end

local function setButtonTone(button, color, textColor)
    if not button then
        return
    end

    local background = color or THEME.button.normal

    if button.GetNormalTexture and button:GetNormalTexture() then
        button:GetNormalTexture():SetVertexColor(unpack(background))
    end

    if button.GetPushedTexture and button:GetPushedTexture() then
        button:GetPushedTexture():SetVertexColor(background[1] * 0.75, background[2] * 0.75, background[3] * 0.75, background[4] or 1)
    end

    if button.GetFontString and button:GetFontString() then
        button:GetFontString():SetTextColor(unpack(textColor or THEME.button.text))
    end
end

local function shortPlayerName(name)
    return tostring(name or ""):match("([^%-]+)") or tostring(name or "")
end

local function normalizeDisplayName(name)
    local text = tostring(name or "")
    local player, realm = text:match("^([^-]+)%-(.+)$")

    if not player or not realm then
        return text
    end

    realm = tostring(realm):match("^([^-]+)") or realm
    return player .. "-" .. realm
end

local function formatTimestampText(timestamp)
    if not timestamp or timestamp <= 0 then
        return "Not started"
    end

    return date("%m/%d %H:%M", timestamp)
end

local function createPanel(parent)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    applySurfaceStyle(panel, THEME.panel)
    return panel
end

local createButton
local createCloseButton
local createInput
local cropText
local lowerText

local function isPlainArray(value)
    if type(value) ~= "table" then
        return false
    end

    local count = 0

    for key in pairs(value) do
        if type(key) ~= "number" then
            return false
        end

        count = count + 1
    end

    for index = 1, count do
        if value[index] == nil then
            return false
        end
    end

    return true
end

local function stringifyExportValue(value)
    if value == nil then
        return ""
    end

    local valueType = type(value)

    if valueType == "boolean" then
        return value and "true" or "false"
    end

    if valueType == "number" then
        return tostring(value)
    end

    if valueType == "string" then
        return value
    end

    if valueType == "table" then
        if isPlainArray(value) then
            local parts = {}

            for _, entry in ipairs(value) do
                parts[#parts + 1] = stringifyExportValue(entry)
            end

            return table.concat(parts, " | ")
        end

        if addon.Json and addon.Json.Encode then
            return addon.Json:Encode(value) or ""
        end
    end

    return tostring(value)
end

local function escapeCsvValue(value)
    local text = stringifyExportValue(value)
    text = tostring(text or "")

    if text:find('[",\r\n]') then
        text = '"' .. text:gsub('"', '""') .. '"'
    end

    return text
end

local function convertExportTimestamps(record)
    local converted = {}

    for key, value in pairs(record or {}) do
        if type(key) == "string"
            and key:match("At$")
            and type(value) == "number"
            and value > 0
            and math.floor(value) == value then
            converted[key] = date("%Y-%m-%d %H:%M:%S", value)
        else
            converted[key] = value
        end
    end

    return converted
end

local function buildCsvFromRecords(records, preferredOrder)
    local headers = {}
    local seen = {}

    for _, key in ipairs(preferredOrder or {}) do
        if key and key ~= "" and not seen[key] then
            seen[key] = true
            headers[#headers + 1] = key
        end
    end

    local extraHeaders = {}

    for _, record in ipairs(records or {}) do
        for key in pairs(record or {}) do
            if not seen[key] then
                seen[key] = true
                extraHeaders[#extraHeaders + 1] = key
            end
        end
    end

    table.sort(extraHeaders)

    for _, key in ipairs(extraHeaders) do
        headers[#headers + 1] = key
    end

    local lines = {}
    lines[#lines + 1] = table.concat(headers, ",")

    for _, record in ipairs(records or {}) do
        local row = {}

        for _, key in ipairs(headers) do
            row[#row + 1] = escapeCsvValue(record and record[key] or "")
        end

        lines[#lines + 1] = table.concat(row, ",")
    end

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

local function colorizeClassName(text, classFile)
    local label = tostring(text or "")
    local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[tostring(classFile or "")]

    if label == "" or not color then
        return label
    end

    if type(color.colorStr) == "string" and color.colorStr ~= "" then
        return "|c" .. color.colorStr .. label .. "|r"
    end

    return string.format(
        "|cff%02x%02x%02x%s|r",
        math.floor((tonumber(color.r) or 1) * 255),
        math.floor((tonumber(color.g) or 1) * 255),
        math.floor((tonumber(color.b) or 1) * 255),
        label
    )
end

function addon.MainFrame:ShowCsvExportDialog(titleText, exportText, helpText)
    local frame = self.csvExportFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3CsvExportFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(200)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(786)
        helper:SetJustifyH("LEFT")
        helper:SetJustifyV("TOP")
        helper:SetTextColor(0.82, 0.82, 0.82, 1)
        frame.helperText = helper

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)
        frame.scrollFrame = scrollFrame

        local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
        editBox:SetMultiLine(true)
        editBox:SetFontObject("ChatFontNormal")
        editBox:SetAutoFocus(false)
        editBox:SetWidth(760)
        editBox:SetTextColor(unpack(BODY_TEXT_COLOR))
        if editBox.SetTextInsets then
            editBox:SetTextInsets(6, 6, 6, 6)
        end
        editBox:SetScript("OnEscapePressed", function(edit)
            edit:ClearFocus()
        end)
        scrollFrame:SetScrollChild(editBox)
        frame.editBox = editBox

        createButton(frame, "Close", 120, 24, "BOTTOM", frame, "BOTTOM", 0, 14, function()
            frame:Hide()
        end)

        self.csvExportFrame = frame
    end

    frame:SetSize(860, 520)
    frame:SetPoint("CENTER")
    frame.title:SetText(titleText or "GH3 Export")
    local resolvedHelpText = tostring(helpText or "")
    local hasHelpText = resolvedHelpText:match("^%s*(.-)%s*$") ~= ""
    frame.helperText:SetText(resolvedHelpText)
    frame.helperText:SetShown(hasHelpText)
    frame.scrollFrame:ClearAllPoints()

    if frame.helperText:IsShown() then
        frame.scrollFrame:SetPoint("TOPLEFT", frame.helperText, "BOTTOMLEFT", 0, -10)
    else
        frame.scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
    end
    frame.scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)

    local resolvedExportText = tostring(exportText or "")
    frame.editBox:SetText(resolvedExportText)
    frame.editBox:SetCursorPosition(0)

    if string.len(resolvedExportText) <= TEXT_VIEWER_AUTOSELECT_MAX_CHARS then
        frame.editBox:HighlightText()
        frame.editBox:SetFocus()
    else
        frame.editBox:HighlightText(0, 0)
        frame.editBox:ClearFocus()
    end
    frame:Raise()
    frame:Show()
end

function addon.MainFrame:ShowTextImportDialog(titleText, initialText, onAccept, helpText)
    local frame = self.textImportFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3TextImportFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(200)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(786)
        helper:SetJustifyH("LEFT")
        helper:SetJustifyV("TOP")
        helper:SetTextColor(0.82, 0.82, 0.82, 1)
        frame.helperText = helper

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)
        frame.scrollFrame = scrollFrame

        local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
        editBox:SetMultiLine(true)
        editBox:SetFontObject("ChatFontNormal")
        editBox:SetAutoFocus(false)
        editBox:SetWidth(760)
        editBox:SetTextColor(unpack(BODY_TEXT_COLOR))
        if editBox.SetTextInsets then
            editBox:SetTextInsets(6, 6, 6, 6)
        end
        editBox:SetScript("OnEscapePressed", function(edit)
            edit:ClearFocus()
        end)
        scrollFrame:SetScrollChild(editBox)
        frame.editBox = editBox

        createButton(frame, "Import", 120, 24, "BOTTOMRIGHT", frame, "BOTTOM", -8, 14, function()
            if type(frame.onAccept) == "function" then
                frame.onAccept(frame.editBox:GetText())
            end
            frame:Hide()
        end)

        createButton(frame, "Cancel", 120, 24, "BOTTOMLEFT", frame, "BOTTOM", 8, 14, function()
            frame:Hide()
        end)

        self.textImportFrame = frame
    end

    frame:SetSize(860, 520)
    frame:SetPoint("CENTER")
    frame.title:SetText(titleText or "GH3 Import")
    local resolvedHelpText = tostring(helpText or "")
    local hasHelpText = resolvedHelpText:match("^%s*(.-)%s*$") ~= ""
    frame.helperText:SetText(resolvedHelpText)
    frame.helperText:SetShown(hasHelpText)
    frame.scrollFrame:ClearAllPoints()

    if frame.helperText:IsShown() then
        frame.scrollFrame:SetPoint("TOPLEFT", frame.helperText, "BOTTOMLEFT", 0, -10)
    else
        frame.scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
    end

    frame.scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)
    frame.editBox:SetText(initialText or "")
    frame.editBox:HighlightText()
    frame.editBox:SetFocus()
    frame.onAccept = onAccept
    frame:Raise()
    frame:Show()
end

function addon.MainFrame:ShowBISStatGoalsDialog(titleText, rows, onAccept)
    local frame = self.bisStatGoalsFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3BISStatGoalsFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(205)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -42)
        helper:SetWidth(528)
        helper:SetJustifyH("LEFT")
        helper:SetText("Enter the raw stat-goal numbers for the selected spec from Murlok, Raidbots, or your guide. These are target values, not percentages. Leave a field blank to clear that goal.")
        styleBodyText(helper, true)
        frame.helperText = helper

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        frame.fields = {}

        for index = 1, 8 do
            local column = (index - 1) % 2
            local rowIndex = math.floor((index - 1) / 2)
            local field = createPanel(frame)
            field:SetSize(248, 56)
            field:SetPoint("TOPLEFT", frame, "TOPLEFT", 16 + (column * 256), -78 - (rowIndex * 64))

            local label = field:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            label:SetPoint("TOPLEFT", field, "TOPLEFT", 10, -8)
            styleTitleText(label, 14)
            field.label = label

            local current = field:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            current:SetPoint("TOPLEFT", field, "TOPLEFT", 10, -28)
            current:SetWidth(100)
            current:SetJustifyH("LEFT")
            styleBodyText(current, true)
            field.current = current

            local goalLabel = field:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            goalLabel:SetPoint("TOPLEFT", field, "TOPLEFT", 120, -12)
            goalLabel:SetText("Goal")
            styleBodyText(goalLabel, true)

            local input = createInput(field, 102, 22, "TOPLEFT", field, "TOPLEFT", 120, -30)
            input:SetNumeric(false)
            input:SetMaxLetters(10)
            input:SetScript("OnEscapePressed", function(edit)
                edit:ClearFocus()
            end)
            field.input = input

            frame.fields[index] = field
        end

        frame.saveButton = createButton(frame, "Save", 120, 24, "BOTTOMRIGHT", frame, "BOTTOM", -8, 14, function()
            local payload = {}

            for _, field in ipairs(frame.fields or {}) do
                if field.key then
                    payload[field.key] = field.input and field.input:GetText() or ""
                end
            end

            if type(frame.onAccept) == "function" then
                frame.onAccept(payload)
            end

            frame:Hide()
        end)

        createButton(frame, "Cancel", 120, 24, "BOTTOMLEFT", frame, "BOTTOM", 8, 14, function()
            frame:Hide()
        end)

        self.bisStatGoalsFrame = frame
    end

    frame:SetSize(560, 390)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "BIS Stat Goals")
    frame.onAccept = onAccept

    for index, field in ipairs(frame.fields or {}) do
        local row = rows and rows[index] or nil

        field.key = row and row.key or nil
        field:Show()

        if row then
            field.label:SetText(row.label or "Stat")
            field.current:SetText(string.format("Current %s", row.currentLabel or "-"))
            field.input:SetText(row.targetValue and row.targetValue > 0 and tostring(row.targetLabel or "") or "")
        else
            field.label:SetText("")
            field.current:SetText("")
            field.input:SetText("")
            field:Hide()
        end
    end

    if frame.fields and frame.fields[1] and frame.fields[1].input then
        frame.fields[1].input:SetFocus()
        frame.fields[1].input:HighlightText()
    end

    frame:Raise()
    frame:Show()
end

function addon.MainFrame:ShowConfirmDialog(titleText, bodyText, confirmText, onConfirm, secondaryText, onSecondary)
    local frame = self.confirmDialogFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3ConfirmDialogFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(210)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local body = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        body:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -46)
        body:SetWidth(388)
        body:SetJustifyH("LEFT")
        body:SetJustifyV("TOP")
        body:SetTextColor(0.84, 0.84, 0.84, 1)
        frame.body = body

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        frame.confirmButton = createButton(frame, "Confirm", 128, 24, "BOTTOMRIGHT", frame, "BOTTOM", -8, 14, function()
            if type(frame.onConfirm) == "function" then
                frame.onConfirm()
            end
            frame:Hide()
        end)

        frame.secondaryButton = createButton(frame, "Secondary", 128, 24, "BOTTOM", frame, "BOTTOM", 0, 14, function()
            if type(frame.onSecondary) == "function" then
                frame.onSecondary()
            end
            frame:Hide()
        end)

        frame.cancelButton = createButton(frame, "Cancel", 128, 24, "BOTTOMLEFT", frame, "BOTTOM", 8, 14, function()
            frame:Hide()
        end)

        self.confirmDialogFrame = frame
    end

    frame:SetSize(420, 188)
    frame:SetPoint("CENTER")
    frame.title:SetText(titleText or "Confirm")
    frame.body:SetText(bodyText or "")
    if frame.confirmButton and frame.confirmButton.SetText then
        frame.confirmButton:SetText(confirmText or "Confirm")
    end
    if frame.secondaryButton and frame.secondaryButton.SetText then
        frame.secondaryButton:SetText(secondaryText or "Secondary")
        frame.secondaryButton:SetShown(type(onSecondary) == "function")
    end

    if frame.cancelButton then
        frame.cancelButton:ClearAllPoints()
        frame.confirmButton:ClearAllPoints()

        if type(onSecondary) == "function" then
            frame.cancelButton:SetPoint("BOTTOMLEFT", frame, "BOTTOM", -188, 14)
            frame.secondaryButton:ClearAllPoints()
            frame.secondaryButton:SetPoint("BOTTOM", frame, "BOTTOM", 0, 14)
            frame.confirmButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOM", 188, 14)
        else
            frame.cancelButton:SetPoint("BOTTOMLEFT", frame, "BOTTOM", 8, 14)
            frame.confirmButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOM", -8, 14)
        end
    end
    frame.onConfirm = onConfirm
    frame.onSecondary = onSecondary
    frame:Raise()
    frame:Show()
end

local createSecureMacroButton

function addon.MainFrame:HideRecruiterInviteBatchButton()
    local button = self.recruiterInviteBatchButton

    if not button or (InCombatLockdown and InCombatLockdown()) then
        return
    end

    button._gh3RecruiterBatchPayload = nil
    button._gh3RecruiterBatchResult = nil
    button:SetEnabled(false)
    button:SetAttribute("type", nil)
    button:SetAttribute("macro", nil)
    button:SetAttribute("type1", nil)
    button:SetAttribute("macro1", nil)
    button:Hide()
end

function addon.MainFrame:EnsureRecruiterInviteBatchButton()
    if self.recruiterInviteBatchButton then
        return self.recruiterInviteBatchButton
    end

    local service = addon.MaintenanceService
    local button = createSecureMacroButton(self.frame, "Invite Batch", 96, 24, "TOPLEFT", self.frame, "TOPLEFT", -10000, -10000, nil)

    button:SetFrameStrata(self.frame:GetFrameStrata())
    button:SetFrameLevel((self.frame:GetFrameLevel() or 0) + 40)
    button:Hide()

    button:HookScript("PreClick", function(control)
        control._gh3RecruiterBatchResult = service:PrepareInviteBatchRun(control._gh3RecruiterBatchPayload)
    end)

    button:HookScript("PostClick", function(control)
        local result = type(control._gh3RecruiterBatchResult) == "table" and control._gh3RecruiterBatchResult or nil

        if result and result.ok then
            addon:Print(result.message or string.format("Recruiter batch invites sent for %d candidate(s).", result.queuedCount or 0))
        else
            addon:Print((result and result.message) or "Unable to start the recruiter invite batch.")
        end

        if addon.MainFrame and addon.MainFrame.MarkBackgroundRefreshPending then
            addon.MainFrame:MarkBackgroundRefreshPending()
        end

        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if addon.MainFrame
                    and addon.MainFrame.frame
                    and addon.MainFrame.frame:IsShown()
                    and addon.Router
                    and addon.Router.GetCurrentView
                    and addon.Router:GetCurrentView() == "maintenance"
                    and not (InCombatLockdown and InCombatLockdown()) then
                    addon.MainFrame:RefreshCurrentView(true)
                end
            end)
        end
    end)

    self.recruiterInviteBatchButton = button
    return button
end

function addon.MainFrame:PositionRecruiterInviteBatchButton(anchorFrame, payload, preparedMacro)
    local button = self:EnsureRecruiterInviteBatchButton()
    local hostFrame = self.frame

    if not button or not hostFrame or not anchorFrame or (InCombatLockdown and InCombatLockdown()) then
        return nil
    end

    local anchorLeft = anchorFrame:GetLeft()
    local anchorBottom = anchorFrame:GetBottom()
    local hostLeft = hostFrame:GetLeft()
    local hostBottom = hostFrame:GetBottom()

    if not anchorLeft or not anchorBottom or not hostLeft or not hostBottom then
        self:HideRecruiterInviteBatchButton()

        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if addon.MainFrame == self
                    and addon.MainFrame.frame
                    and addon.MainFrame.frame:IsShown()
                    and addon.Router
                    and addon.Router.GetCurrentView
                    and addon.Router:GetCurrentView() == "maintenance"
                    and not (InCombatLockdown and InCombatLockdown()) then
                    self:PositionRecruiterInviteBatchButton(anchorFrame, payload, preparedMacro)
                end
            end)
        end

        return button
    end

    button:ClearAllPoints()
    button:SetPoint(
        "BOTTOMLEFT",
        hostFrame,
        "BOTTOMLEFT",
        math.floor((anchorLeft - hostLeft) + 0.5),
        math.floor((anchorBottom - hostBottom) + 0.5)
    )
    button:SetSize(anchorFrame:GetWidth(), anchorFrame:GetHeight())
    button:SetText("Invite Batch")
    button:SetAttribute("useOnKeyDown", false)
    button._gh3RecruiterBatchPayload = preparedMacro and payload or nil
    button._gh3RecruiterBatchResult = nil

    if preparedMacro and preparedMacro.macroName then
        button:SetAttribute("type", "macro")
        button:SetAttribute("macro", preparedMacro.macroName)
        button:SetAttribute("type1", "macro")
        button:SetAttribute("macro1", preparedMacro.macroName)
        button:SetEnabled(true)
        button:Show()
        return button
    end

    button:SetAttribute("type", nil)
    button:SetAttribute("macro", nil)
    button:SetAttribute("type1", nil)
    button:SetAttribute("macro1", nil)
    button:SetEnabled(false)
    button:Hide()
    return button
end

function addon.MainFrame:BuildRosterExportCsv(members)
    local records = {}

    for _, member in ipairs(members or {}) do
        records[#records + 1] = convertExportTimestamps(member)
    end

    table.sort(records, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    return buildCsvFromRecords(records, {
        "id", "fullName", "name", "mainCharacter", "guildName", "rank", "realm", "faction", "className", "classFile",
        "spec", "primaryRole", "secondaryRoles", "roles", "meleeOrRanged", "itemLevel", "battleTag", "birthdate",
        "guildJoinDate", "interests", "professions", "online", "note", "updatedAt", "updatedBy", "revision"
    })
end

function addon.MainFrame:BuildMyToonsExportCsv(members)
    local records = {}

    for _, member in ipairs(members or {}) do
        records[#records + 1] = convertExportTimestamps(member)
    end

    table.sort(records, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    return buildCsvFromRecords(records, {
        "id", "fullName", "name", "mainCharacter", "guildName", "className", "classFile", "spec", "primaryRole",
        "secondaryRoles", "roles", "meleeOrRanged", "itemLevel", "battleTag", "birthdate", "guildJoinDate",
        "interests", "professions", "online", "updatedAt", "updatedBy", "revision"
    })
end

function addon.MainFrame:BuildGuildJoinDateExportCsv(members)
    local records = {}

    for _, member in ipairs(members or {}) do
        records[#records + 1] = convertExportTimestamps(member)
    end

    table.sort(records, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    return buildCsvFromRecords(records, {
        "id", "fullName", "name", "guildName", "rank", "mainCharacter", "guildJoinDate",
        "className", "classFile", "level", "online", "updatedAt", "updatedBy", "revision"
    })
end

function addon.MainFrame:BuildRawGuildRosterExportCsv(members)
    local records = {}

    for _, member in ipairs(members or {}) do
        records[#records + 1] = convertExportTimestamps(member)
    end

    table.sort(records, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    return buildCsvFromRecords(records, {
        "exportedAt", "rosterIndex", "fullName", "shortName", "joinedDate", "joinedDateSource", "guildName", "rankName", "rankIndex", "level",
        "classDisplayName", "classFile", "zone", "publicNote", "officerNote", "isOnline", "statusCode", "statusText",
        "isMobile", "achievementPoints", "achievementRank", "repStanding", "repStandingText", "guid",
        "offlineYears", "offlineMonths", "offlineDays", "offlineHours", "offlineText", "canSoR"
    })
end

function addon.MainFrame:BuildCurrentPartyRaidExportCsv()
    local exportedAt = time()
    local records = {}
    local seen = {}
    local units = {}

    local function normalizeGroupRole(roleValue, member)
        local role = tostring(roleValue or "")

        if role == "TANK" then
            return "tank"
        end

        if role == "HEALER" then
            return "healer"
        end

        if role == "DAMAGER" then
            return "dps"
        end

        if member and member.primaryRole and member.primaryRole ~= "" then
            return tostring(member.primaryRole)
        end

        return ""
    end

    if IsInRaid() then
        for index = 1, (GetNumGroupMembers() or 0) do
            units[#units + 1] = "raid" .. index
        end
    elseif IsInGroup() then
        units[#units + 1] = "player"

        for index = 1, (GetNumSubgroupMembers() or 0) do
            units[#units + 1] = "party" .. index
        end
    else
        units[#units + 1] = "player"
    end

    for _, unitId in ipairs(units) do
        if UnitExists(unitId) then
            local unitName, unitRealm = UnitFullName(unitId)
            local fullName = addon.Store:NormalizeFullName(unitName, unitRealm or GetRealmName() or "Unknown")

            if fullName ~= "" and not seen[fullName] then
                seen[fullName] = true

                local _, localizedClass, classFile = UnitClass(unitId)
                local member = addon.RosterService:GetMyToonMember(fullName) or addon.RosterService:GetMember(fullName) or {}
                local itemLevel = tonumber(member.itemLevel) or 0
                local roleValue = UnitGroupRolesAssigned and UnitGroupRolesAssigned(unitId) or nil

                records[#records + 1] = {
                    exportedAt = date("%Y-%m-%d %H:%M:%S", exportedAt),
                    source = IsInRaid() and "raid" or (IsInGroup() and "party" or "solo"),
                    toonName = fullName,
                    className = localizedClass or member.className or "",
                    classFile = classFile or member.classFile or "",
                    role = normalizeGroupRole(roleValue, member),
                    itemLevel = itemLevel,
                    guildName = member.guildName or "",
                    mainCharacter = member.mainCharacter or "",
                    battleTag = member.battleTag or "",
                    online = UnitIsConnected and UnitIsConnected(unitId) or (member.online and true or false),
                    timestamp = date("%Y-%m-%d %H:%M:%S", exportedAt),
                }
            end
        end
    end

    table.sort(records, function(left, right)
        return lowerText(left.toonName) < lowerText(right.toonName)
    end)

    return buildCsvFromRecords(records, {
        "exportedAt", "source", "toonName", "className", "classFile", "role", "itemLevel", "guildName",
        "mainCharacter", "battleTag", "online", "timestamp"
    })
end

function addon.MainFrame:BuildGroupsEventsExportCsv(groups)
    local records = {}

    for _, group in ipairs(groups or {}) do
        local planner = addon.GroupsService:GetPlannerData(group.id)

        records[#records + 1] = {
            id = group.id or "",
            eventName = group.eventName or group.name or "",
            owner = group.owner or "",
            activityType = group.activityType or "",
            difficulty = group.difficulty or "",
            startAt = group.startAt and date("%Y-%m-%d %H:%M:%S", group.startAt) or "",
            nextOccurrenceAt = planner and planner.activeOccurrenceStart and planner.activeOccurrenceStart > 0 and date("%Y-%m-%d %H:%M:%S", planner.activeOccurrenceStart) or "",
            recurrenceSummary = group.recurrenceSummary or "",
            minItemLevel = group.minItemLevel or 0,
            durationHours = group.durationHours or 0,
            tanksRequired = group.requirements and group.requirements.tanks or 0,
            healersRequired = group.requirements and group.requirements.healers or 0,
            meleeRequired = group.requirements and group.requirements.melee or 0,
            rangedRequired = group.requirements and group.requirements.ranged or 0,
            totalSignups = planner and planner.totalSignups or 0,
            qualifiedCount = planner and planner.qualifiedCount or 0,
            suggestedCount = planner and #(planner.suggested or {}) or 0,
            updatedAt = group.updatedAt and date("%Y-%m-%d %H:%M:%S", group.updatedAt) or "",
            updatedBy = group.updatedBy or "",
        }
    end

    table.sort(records, function(left, right)
        if left.startAt == right.startAt then
            return lowerText(left.eventName) < lowerText(right.eventName)
        end

        return tostring(left.startAt) < tostring(right.startAt)
    end)

    return buildCsvFromRecords(records, {
        "id", "eventName", "owner", "activityType", "difficulty", "startAt", "nextOccurrenceAt", "recurrenceSummary",
        "minItemLevel", "durationHours", "tanksRequired", "healersRequired", "meleeRequired", "rangedRequired",
        "totalSignups", "qualifiedCount", "suggestedCount", "updatedAt", "updatedBy"
    })
end

function addon.MainFrame:GetViewHost()
    return self.viewHost or self.contentHost
end

local function createCard(parent, width, height, title, value, subtext, x, y, options)
    options = options or {}
    local isShortCard = height <= (options.compactThreshold or 92)
    local insetX = options.insetX or 14
    local titleTop = options.titleTop or (isShortCard and 10 or 12)
    local valueGap = options.valueGap or (isShortCard and 8 or 16)
    local subGap = options.subGap or (isShortCard and 4 or 12)

    local card = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    card:SetSize(width, height)
    card:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
    applySurfaceStyle(card, THEME.card)

    local titleText = card:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    titleText:SetPoint("TOPLEFT", card, "TOPLEFT", insetX, -titleTop)
    titleText:SetText(title)
    styleTitleText(titleText, options.titleSize or 16)

    local valueText = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    valueText:SetPoint("TOPLEFT", titleText, "BOTTOMLEFT", 0, -valueGap)
    valueText:SetText(value)
    styleTitleText(valueText, options.valueSize or 20)

    local subText = card:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    subText:SetPoint("TOPLEFT", valueText, "BOTTOMLEFT", 0, -subGap)
    subText:SetWidth(width - (insetX * 2))
    subText:SetJustifyH("LEFT")
    subText:SetText(subtext or "")
    styleBodyText(subText)

    if options.subFontSize then
        subText:SetFont("Fonts\\FRIZQT__.TTF", options.subFontSize, "")
        styleBodyText(subText)
    end

    if subText.SetSpacing and options.subSpacing then
        subText:SetSpacing(options.subSpacing)
    end

    return card
end

local function createSectionTitle(parent, text, x, y)
    local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
    title:SetText(text)
    styleTitleText(title, 22)
    return title
end

function createButton(parent, text, width, height, point, relativeTo, relativePoint, x, y, onClick)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate")
    button:SetSize(width, height)
    button:SetPoint(point, relativeTo, relativePoint, x, y)

    if not button:GetFontString() then
        local label = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("CENTER", button, "CENTER", 0, 0)
        label:SetJustifyH("CENTER")
        label:SetJustifyV("MIDDLE")
        button._gh3Label = label
        button.GetFontString = function(self)
            return self._gh3Label
        end
        button.SetText = function(self, value)
            if self._gh3Label then
                self._gh3Label:SetText(value)
            end
        end
    end

    button:SetText(text)
    button:SetScript("OnClick", onClick)
    styleButton(button)
    return button
end

createSecureMacroButton = function(parent, text, width, height, point, relativeTo, relativePoint, x, y, onClick)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate,SecureActionButtonTemplate")
    button:SetSize(width, height)
    button:SetPoint(point, relativeTo, relativePoint, x, y)
    button:RegisterForClicks("LeftButtonUp")

    if not button:GetFontString() then
        local label = button:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("CENTER", button, "CENTER", 0, 0)
        label:SetJustifyH("CENTER")
        label:SetJustifyV("MIDDLE")
        button._gh3Label = label
        button.GetFontString = function(self)
            return self._gh3Label
        end
        button.SetText = function(self, value)
            if self._gh3Label then
                self._gh3Label:SetText(value)
            end
        end
    end

    button:SetText(text)

    if type(onClick) == "function" then
        button:HookScript("PostClick", onClick)
    end

    styleButton(button)
    return button
end

local function requestGuildRosterSnapshot()
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        return C_GuildInfo.GuildRoster()
    end

    if GuildRoster then
        return GuildRoster()
    end

    return nil
end

function createCloseButton(parent, point, relativeTo, relativePoint, x, y, onClick)
    local button = createButton(parent, "X", 26, 26, point, relativeTo, relativePoint, x, y, onClick)
    setButtonTone(button, { 0.16, 0.08, 0.08, 0.98 }, { 0.98, 0.84, 0.72, 1 })

    if button:GetFontString() then
        button:GetFontString():SetFont(BUTTON_FONT_PATH, 16, "OUTLINE")
    end

    return button
end

local function attachHoverNote(widget, titleSource, bodySource, anchor)
    if not widget then
        return
    end

    widget:SetScript("OnEnter", function(control)
        local titleText = type(titleSource) == "function" and titleSource(control) or titleSource
        local bodyText = type(bodySource) == "function" and bodySource(control) or bodySource

        GameTooltip:SetOwner(control, anchor or "ANCHOR_RIGHT")
        GameTooltip:SetText(titleText or "GH3", 0.93, 0.84, 0.63, 1, true)

        if type(bodyText) == "table" then
            for _, line in ipairs(bodyText) do
                if tostring(line or "") ~= "" then
                    GameTooltip:AddLine(tostring(line), 0.82, 0.82, 0.82, true)
                end
            end
        elseif tostring(bodyText or "") ~= "" then
            GameTooltip:AddLine(tostring(bodyText), 0.82, 0.82, 0.82, true)
        end

        GameTooltip:Show()
    end)

    widget:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

local function createTitleBarStateButton(parent, point, relativeTo, relativePoint, x, y, buttonType, onClick)
    local button = createButton(parent, "", 26, 26, point, relativeTo, relativePoint, x, y, onClick)
    setButtonTone(button, { 0.16, 0.08, 0.08, 0.98 }, { 0.98, 0.84, 0.72, 1 })

    if button:GetFontString() then
        button:GetFontString():SetText("")
    end

    local function ensureArrowPart(partKey)
        button._gh3ArrowParts = button._gh3ArrowParts or {}

        if not button._gh3ArrowParts[partKey] then
            local part = button:CreateTexture(nil, "OVERLAY")
            part:SetTexture("Interface\\Buttons\\WHITE8X8")
            part:SetVertexColor(0.98, 0.84, 0.72, 1)
            button._gh3ArrowParts[partKey] = part
        end

        return button._gh3ArrowParts[partKey]
    end

    local shaft = ensureArrowPart("shaft")
    local headA = ensureArrowPart("headA")
    local headB = ensureArrowPart("headB")

    if buttonType == "maximize" then
        shaft:SetSize(11, 2)
        shaft:ClearAllPoints()
        shaft:SetPoint("CENTER", button, "CENTER", 1, 1)
        shaft:SetRotation(math.pi / 4)

        headA:SetSize(5, 2)
        headA:ClearAllPoints()
        headA:SetPoint("CENTER", button, "CENTER", 5, 7)
        headA:SetRotation(0)

        headB:SetSize(2, 5)
        headB:ClearAllPoints()
        headB:SetPoint("CENTER", button, "CENTER", 7, 5)
        headB:SetRotation(0)
    else
        shaft:SetSize(11, 2)
        shaft:ClearAllPoints()
        shaft:SetPoint("CENTER", button, "CENTER", -1, -1)
        shaft:SetRotation(math.pi / 4)

        headA:SetSize(5, 2)
        headA:ClearAllPoints()
        headA:SetPoint("CENTER", button, "CENTER", -5, -7)
        headA:SetRotation(0)

        headB:SetSize(2, 5)
        headB:ClearAllPoints()
        headB:SetPoint("CENTER", button, "CENTER", -7, -5)
        headB:SetRotation(0)
    end

    return button
end

createInput = function(parent, width, height, point, relativeTo, relativePoint, x, y)
    local input = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
    input:SetSize(width, height)
    input:SetPoint(point, relativeTo, relativePoint, x, y)
    input:SetAutoFocus(false)
    styleInputText(input)
    return input
end

local function createCheckbox(parent, labelText, point, relativeTo, relativePoint, x, y, checked, onClick)
    local checkbox = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
    checkbox:SetPoint(point, relativeTo, relativePoint, x, y)
    checkbox:SetChecked(checked and true or false)

    if checkbox.text then
        checkbox.text:SetText(labelText)
        styleBodyText(checkbox.text)
    end

    checkbox:SetScript("OnClick", function(self)
        onClick(self:GetChecked() and true or false)
    end)

    return checkbox
end

local function createDropdown(parent, width, point, relativeTo, relativePoint, x, y)
    local dropdown = CreateFrame("Frame", nil, parent, "UIDropDownMenuTemplate")
    dropdown:SetPoint(point, relativeTo, relativePoint, x, y)
    UIDropDownMenu_SetWidth(dropdown, width)

    if not dropdown._gh3Chrome then
        local chrome = CreateFrame("Frame", nil, dropdown, "BackdropTemplate")
        chrome:SetPoint("TOPLEFT", dropdown, "TOPLEFT", 18, -2)
        chrome:SetPoint("BOTTOMRIGHT", dropdown, "BOTTOMRIGHT", -24, 8)
        chrome:SetFrameLevel(math.max(dropdown:GetFrameLevel() - 1, 0))
        applySurfaceStyle(chrome, THEME.input)
        dropdown._gh3Chrome = chrome
    end

    if dropdown.Left then
        dropdown.Left:SetAlpha(0)
    end

    if dropdown.Middle then
        dropdown.Middle:SetAlpha(0)
    end

    if dropdown.Right then
        dropdown.Right:SetAlpha(0)
    end

    styleDropdownText(dropdown)
    return dropdown
end

local function arrayContains(values, expected)
    for _, value in ipairs(values or {}) do
        if value == expected then
            return true
        end
    end

    return false
end

local function removeArrayValue(values, expected)
    for index, value in ipairs(values or {}) do
        if value == expected then
            table.remove(values, index)
            return
        end
    end
end

local function setDropdownText(dropdown, text)
    UIDropDownMenu_SetText(dropdown, text and text ~= "" and text or "Select")
    styleDropdownText(dropdown)
end

local function createMenuButton(parent, text, width, height, point, relativeTo, relativePoint, x, y)
    local button = createButton(parent, text, width, height, point, relativeTo, relativePoint, x, y, nil)
    button._gh3MenuWidth = math.max(width or 0, 160)

    return button
end

local function setMenuButtonText(button, text)
    if button and button.SetText then
        button:SetText(text and text ~= "" and text or "Select")
    end
end

local activeMenuRootFrame

local function hideCustomMenuFrame(frame)
    if not frame then
        return
    end

    if frame.childMenu then
        hideCustomMenuFrame(frame.childMenu)
    end

    frame:Hide()
end

local function hideActiveMenuRoot()
    if activeMenuRootFrame then
        hideCustomMenuFrame(activeMenuRootFrame)
        activeMenuRootFrame = nil
    end
end

local function ensureCustomMenuFrame(button)
    if button._gh3MenuFrame then
        return button._gh3MenuFrame
    end

    local frame = createPanel(UIParent)
    frame:SetFrameStrata("TOOLTIP")
    frame:SetToplevel(true)
    frame:SetClampedToScreen(true)
    frame:Hide()
    frame.itemButtons = {}
    frame:SetScript("OnHide", function(self)
        if self.childMenu then
            hideCustomMenuFrame(self.childMenu)
        end
    end)
    button._gh3MenuFrame = frame
    return frame
end

local function ensureCustomMenuItem(frame, index)
    local item = frame.itemButtons[index]

    if item then
        return item
    end

    item = createButton(frame, "", math.max((frame:GetWidth() or 180) - 16, 120), 22, "TOPLEFT", frame, "TOPLEFT", 8, -8, nil)

    if item.GetFontString and item:GetFontString() then
        local label = item:GetFontString()
        label:ClearAllPoints()
        label:SetPoint("LEFT", item, "LEFT", 8, 0)
        label:SetPoint("RIGHT", item, "RIGHT", -8, 0)
        label:SetJustifyH("LEFT")
    end

    setButtonTone(item, THEME.button.normal, THEME.button.text)
    frame.itemButtons[index] = item
    return item
end

local function measureMenuOptionWidth(button, options)
    local width = math.max((button and button._gh3MenuWidth) or (button and button:GetWidth()) or 0, 160)

    for _, option in ipairs(options or {}) do
        local text = option.text or option.label or tostring(option.value or "")
        local prefix = option.notCheckable and "" or ((option.checked and "[x] ") or "[ ] ")
        local suffix = option.hasArrow and "  >" or ""
        local estimatedWidth = (#tostring(prefix .. text .. suffix) * 7) + 28

        if estimatedWidth > width then
            width = estimatedWidth
        end
    end

    return math.min(width, 320)
end

local function formatMenuOptionLabel(option)
    local text = option.text or option.label or tostring(option.value or "")
    local prefix = option.notCheckable and "" or ((option.checked and "[x] ") or "[ ] ")
    local suffix = option.hasArrow and "  >" or ""
    return prefix .. text .. suffix
end

local function populateCustomMenuFrame(frame, button, buildMenu, level, menuList)
    local options = buildMenu(level, menuList) or {}

    if #options == 0 then
        options = {
            {
                text = "No options",
                disabled = true,
                notCheckable = true,
            },
        }
    end

    local width = measureMenuOptionWidth(button, options)
    frame:SetWidth(width)

    for index, option in ipairs(options) do
        local item = ensureCustomMenuItem(frame, index)
        item:ClearAllPoints()
        item:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8 - ((index - 1) * 24))
        item:SetSize(width - 16, 22)
        item:SetText(formatMenuOptionLabel(option))
        item._gh3Option = option

        if option.disabled then
            item:Disable()
        else
            item:Enable()
        end

        item:SetScript("OnEnter", function(self)
            if self._gh3Option and self._gh3Option.hasArrow then
                frame.childMenu = frame.childMenu or createPanel(UIParent)
                frame.childMenu:SetFrameStrata("TOOLTIP")
                frame.childMenu:SetToplevel(true)
                frame.childMenu:SetClampedToScreen(true)
                frame.childMenu.itemButtons = frame.childMenu.itemButtons or {}
                frame.childMenu:ClearAllPoints()
                frame.childMenu:SetPoint("TOPLEFT", self, "TOPRIGHT", 4, 0)
                populateCustomMenuFrame(frame.childMenu, button, buildMenu, (level or 1) + 1, self._gh3Option.menuList)
                frame.childMenu:Show()
            elseif frame.childMenu then
                hideCustomMenuFrame(frame.childMenu)
            end
        end)

        item:SetScript("OnClick", function(self)
            local selectedOption = self._gh3Option or {}

            if selectedOption.disabled then
                return
            end

            if selectedOption.hasArrow then
                frame.childMenu = frame.childMenu or createPanel(UIParent)
                frame.childMenu:SetFrameStrata("TOOLTIP")
                frame.childMenu:SetToplevel(true)
                frame.childMenu:SetClampedToScreen(true)
                frame.childMenu.itemButtons = frame.childMenu.itemButtons or {}
                frame.childMenu:ClearAllPoints()
                frame.childMenu:SetPoint("TOPLEFT", self, "TOPRIGHT", 4, 0)
                populateCustomMenuFrame(frame.childMenu, button, buildMenu, (level or 1) + 1, selectedOption.menuList)
                frame.childMenu:Show()
                return
            end

            if type(selectedOption.onClick) == "function" then
                selectedOption.onClick(selectedOption.value, selectedOption, menuList, level)
            end

            if selectedOption.keepShownOnClick then
                populateCustomMenuFrame(frame, button, buildMenu, level, menuList)
                frame:Show()
            else
                hideActiveMenuRoot()
            end
        end)

        item:Show()
    end

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

    frame:SetHeight((#options * 24) + 16)
end

local function showMenuButton(button, buildMenu)
    if not button or type(buildMenu) ~= "function" then
        return
    end

    local frame = ensureCustomMenuFrame(button)

    if activeMenuRootFrame == frame and frame:IsShown() then
        hideActiveMenuRoot()
        return
    end

    hideActiveMenuRoot()
    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", button, "BOTTOMLEFT", 0, -4)
    populateCustomMenuFrame(frame, button, buildMenu, 1, nil)
    frame:Show()
    activeMenuRootFrame = frame
end

local function initializeSingleSelectMenuButton(button, options, selectedValue, formatValue, onChanged)
    local formatter = formatValue or tostring
    button.selectedValue = selectedValue
    setMenuButtonText(button, button.selectedValue and formatter(button.selectedValue) or nil)

    button:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(options or {}) do
                menuOptions[#menuOptions + 1] = {
                    text = formatter(option),
                    value = option,
                    checked = button.selectedValue == option,
                    onClick = function(value)
                        button.selectedValue = value
                        setMenuButtonText(button, formatter(value))

                        if type(onChanged) == "function" then
                            onChanged(value, option)
                        end
                    end,
                }
            end

            return menuOptions
        end)
    end)
end

local MINIMAP_QUICK_LINKS = {
    { label = "Preview UI", visible = function() return true end, onClick = function() if addon.OpenNewUIPreview then addon:OpenNewUIPreview() end end },
    { label = "Invites", view = "maintenance", maintenanceTab = "invites", visible = function() return true end },
    { label = "Mythic+", view = "mythics", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("mythics") end },
    { label = "Groups", view = "groups", visible = function() return addon.IsFeatureEnabled and addon:IsFeatureEnabled("groups") end },
    { label = "My Toons", view = "mytoons", visible = function() return true end },
    { label = "Roster", view = "roster", visible = function() return true end },
}

local function buildMinimapQuickLinkOptions()
    local options = {}
    local currentView = addon.Router and addon.Router.GetCurrentView and addon.Router:GetCurrentView() or "dashboard"
    local mainFrame = addon.MainFrame

    for _, link in ipairs(MINIMAP_QUICK_LINKS) do
        local isVisible = true

        if type(link.visible) == "function" then
            isVisible = link.visible() ~= false
        end

        if isVisible then
            if link.onClick then
                options[#options + 1] = {
                    text = link.label,
                    checked = false,
                    onClick = link.onClick,
                }
            else
                local isChecked = currentView == link.view

                if isChecked and link.view == "maintenance" and link.maintenanceTab then
                    mainFrame:EnsureState()
                    isChecked = mainFrame.state.maintenanceTab == link.maintenanceTab
                end

                options[#options + 1] = {
                    text = link.label,
                    checked = isChecked,
                    onClick = function()
                        mainFrame:OpenQuickLink(link.view, link.maintenanceTab)
                    end,
                }
            end
        end
    end

    return options
end

local function initializeMultiSelectMenuButton(button, options, selectedValues, formatValue, onChanged)
    local formatter = formatValue or tostring
    button.selectedValues = {}

    for _, value in ipairs(selectedValues or {}) do
        button.selectedValues[#button.selectedValues + 1] = value
    end

    local function refreshLabel()
        local labels = {}

        for index, value in ipairs(button.selectedValues) do
            labels[index] = formatter(value)
        end

        setMenuButtonText(button, (#labels > 0 and table.concat(labels, ", ") or ""))
    end

    refreshLabel()

    button:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(options or {}) do
                menuOptions[#menuOptions + 1] = {
                    text = formatter(option),
                    value = option,
                    checked = arrayContains(button.selectedValues, option),
                    isNotRadio = true,
                    keepShownOnClick = true,
                    onClick = function(value)
                        if arrayContains(button.selectedValues, value) then
                            removeArrayValue(button.selectedValues, value)
                        else
                            button.selectedValues[#button.selectedValues + 1] = value
                        end

                        refreshLabel()

                        if type(onChanged) == "function" then
                            onChanged(button.selectedValues, value)
                        end
                    end,
                }
            end

            return menuOptions
        end)
    end)
end

function addon.MainFrame:ShowBISJournalItemPickerDialog(titleText, pickerState, onSelect)
    local service = addon.BISService
    local frame = self.bisJournalItemPickerFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3BISJournalItemPickerFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(215)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(860)
        helper:SetJustifyH("LEFT")
        helper:SetText("Choose an item row, then GH3 will copy the item, source, boss, and track information back into the gear selector. PvP gear is entered by item ID or Wowhead URL from the main BIS editor because it is not listed in Encounter Journal.")
        styleBodyText(helper, true)
        frame.helperText = helper

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        local searchLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        searchLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -72)
        searchLabel:SetText("Search")
        styleBodyText(searchLabel, true)

        frame.searchInput = createInput(frame, 220, 22, "LEFT", searchLabel, "RIGHT", 8, 0)
        frame.searchInput:SetNumeric(false)

        local resultsLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        resultsLabel:SetPoint("LEFT", frame.searchInput, "RIGHT", 14, 0)
        resultsLabel:SetText("")
        styleBodyText(resultsLabel, true)
        frame.resultsLabel = resultsLabel

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -98)
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
        frame.scrollFrame = scrollFrame

        local content = CreateFrame("Frame", nil, scrollFrame)
        content:SetSize(860, 100)
        scrollFrame:SetScrollChild(content)
        frame.scrollContent = content

        function frame:RenderOptions()
            clearChildren(self.scrollContent)

            local rows = service:ListJournalLootOptions(self.state.toonId, self.state.specId, self.state.slotKey, {
                currentSeasonOnly = true,
                sourceType = self.state.sourceType,
            })
            local searchText = lowerText(self.searchInput:GetText())
            local tableRows = {}

            for _, row in ipairs(rows) do
                local haystack = lowerText(table.concat({ row.itemName, row.sourceName, row.sourceTarget, row.trackLabel, row.difficultyLabel }, " "))

                if searchText == "" or string.find(haystack, searchText, 1, true) then
                    tableRows[#tableRows + 1] = {
                        values = {
                            cropText(row.itemName, 28),
                            row.trackLabel ~= "" and row.trackLabel or "-",
                            row.difficultyLabel ~= "" and row.difficultyLabel or row.sourceTypeLabel,
                            cropText(row.sourceName, 18),
                            cropText(row.sourceTarget ~= "" and row.sourceTarget or "-", 18),
                        },
                        onClick = function()
                            if type(self.onSelect) == "function" then
                                self.onSelect(row)
                            end
                            self:Hide()
                        end,
                        tooltipTitle = row.itemName,
                        tooltipLines = {
                            (row.trackLabel ~= "" and ("Track: " .. row.trackLabel) or "Track: Unknown"),
                            "Source: " .. (row.difficultyLabel ~= "" and row.difficultyLabel .. " " or "") .. row.sourceName,
                            "Boss: " .. (row.sourceTarget ~= "" and row.sourceTarget or "-"),
                            row.currentSeason and "Current Season" or "Legacy Content",
                        },
                    }
                end
            end

            self.resultsLabel:SetText(string.format("%d result%s", #tableRows, #tableRows == 1 and "" or "s"))

            if #tableRows <= 0 then
                local emptyText = self.scrollContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                emptyText:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 0, 0)
                emptyText:SetWidth(860)
                emptyText:SetJustifyH("LEFT")
                if self.state.sourceType == "pvp" then
                    emptyText:SetText("PvP gear is not listed in Encounter Journal. Set Source to PvP in the BIS editor row, then paste a Blizzard item ID or Wowhead URL there.")
                else
                    emptyText:SetText("No journal items match the current slot and source filter.")
                end
                styleBodyText(emptyText, true)
                self.scrollContent:SetSize(860, 120)
                return
            end

            self.scrollContent:SetSize(860, math.max(140, 24 + (#tableRows * 22)))
            addon.TableView:Render(self.scrollContent, {
                headers = { "Item", "Track", "Type", "Instance", "Boss" },
                rows = tableRows,
                headerHeight = 22,
                rowHeight = 20,
                columnWidths = { 260, 110, 90, 200, 200 },
            })
        end

        frame.searchInput:SetScript("OnTextChanged", function()
            frame:RenderOptions()
        end)

        createButton(frame, "Close", 120, 24, "BOTTOM", frame, "BOTTOM", 0, 14, function()
            frame:Hide()
        end)

        self.bisJournalItemPickerFrame = frame
    end

    frame.state = type(pickerState) == "table" and pickerState or {}
    frame.onSelect = onSelect
    frame:SetSize(900, 520)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "Select BIS Item")
    frame.searchInput:SetText("")
    frame:RenderOptions()
    frame:Raise()
    frame:Show()
end

function addon.MainFrame:ShowBISDropReviewDialog()
    local service = addon.BISService
    local frame = self.bisDropReviewFrame

    if InCombatLockdown and InCombatLockdown() then
        self.pendingBISDropReviewAfterCombat = true
        return
    end

    self.pendingBISDropReviewAfterCombat = false

    local function attachTooltip(widget, titleText, bodyText)
        if not widget then
            return
        end

        widget:SetScript("OnEnter", function(control)
            GameTooltip:SetOwner(control, "ANCHOR_RIGHT")
            GameTooltip:SetText(titleText or "GH3", 0.93, 0.84, 0.63, 1, true)

            if tostring(bodyText or "") ~= "" then
                GameTooltip:AddLine(tostring(bodyText), 0.82, 0.82, 0.82, true)
            end

            GameTooltip:Show()
        end)
        widget:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
    end

    local function buildBadgeStyle(kind, targetId)
        if kind == "offer" then
            if safeTrim(targetId) ~= "" then
                return "CLAIMED", { 0.09, 0.18, 0.3, 0.98 }, { 0.74, 0.88, 1, 1 }
            end

            return "OFFER", { 0.28, 0.19, 0.06, 0.98 }, { 0.98, 0.9, 0.62, 1 }
        end

        if kind == "self-offer" then
            return "OFFER", { 0.28, 0.19, 0.06, 0.98 }, { 0.98, 0.9, 0.62, 1 }
        end

        return "YOUR BIS", { 0.12, 0.24, 0.12, 0.98 }, { 0.8, 0.98, 0.8, 1 }
    end

    local function buildStatusBadgeStyle(rowData)
        if rowData and rowData.leftGroup then
            return "LEFT", { 0.24, 0.1, 0.1, 0.98 }, { 0.98, 0.84, 0.8, 1 }
        end

        local highestRoll = 0

        for _, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
            highestRoll = math.max(highestRoll, tonumber(rollValue) or 0)
        end

        if highestRoll > 0 then
            return "ROLL", { 0.18, 0.08, 0.24, 0.98 }, { 0.94, 0.8, 1, 1 }
        end

        return nil, nil, nil
    end

    local function playOpenSound()
        if service:GetLootHelperSettings().playPopupSound == false or not PlaySound or type(SOUNDKIT) ~= "table" then
            return
        end

        local candidateKeys = {
            "IG_MAINMENU_OPEN",
            "UI_QUEST_LOG_OPEN",
            "IG_CHARACTER_INFO_OPEN",
        }

        for _, key in ipairs(candidateKeys) do
            local soundKitId = SOUNDKIT[key]

            if soundKitId then
                PlaySound(soundKitId)
                return
            end
        end
    end

    local function buildInterestSummary(rowData)
        local claimantNames = {}
        local rollEntries = {}
        local highestRoll = 0

        for _, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
            highestRoll = math.max(highestRoll, tonumber(rollValue) or 0)
        end

        for claimantId in pairs(type(rowData and rowData.claimantIds) == "table" and rowData.claimantIds or {}) do
            claimantNames[#claimantNames + 1] = normalizeDisplayName(claimantId)
        end

        table.sort(claimantNames, function(left, right)
            return lowerText(left) < lowerText(right)
        end)

        for rollerId, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
            local resolvedRoll = tonumber(rollValue) or 0
            local prefix = resolvedRoll == highestRoll and highestRoll > 0 and "Winner " or ""
            rollEntries[#rollEntries + 1] = string.format("%s%s %d", prefix, normalizeDisplayName(rollerId), resolvedRoll)
        end

        table.sort(rollEntries, function(left, right)
            return lowerText(left) < lowerText(right)
        end)

        local parts = {}

        if #claimantNames > 0 then
            parts[#parts + 1] = "Need: " .. table.concat(claimantNames, ", ")
        end

        if #rollEntries > 0 then
            parts[#parts + 1] = "Rolls: " .. table.concat(rollEntries, ", ")
        end

        return table.concat(parts, " | ")
    end

    local function savePopupPosition(dialog)
        if not dialog then
            return
        end

        local settings = service:GetLootHelperSettings()
        local compact = settings.compactPopup and true or false

        local left = dialog.GetLeft and dialog:GetLeft() or nil
        local top = dialog.GetTop and dialog:GetTop() or nil
        local parentLeft = UIParent and UIParent.GetLeft and UIParent:GetLeft() or 0
        local parentTop = UIParent and UIParent.GetTop and UIParent:GetTop() or 0

        if not left or not top then
            return
        end

        local offsetX = math.floor(left - parentLeft)
        local offsetY = math.floor(top - parentTop)

        dialog._sessionPopupOffsets = dialog._sessionPopupOffsets or {}
        dialog._sessionPopupOffsets[compact and "compact" or "full"] = {
            x = offsetX,
            y = offsetY,
        }

        if not settings.pinPopupPosition then
            return
        end

        if compact then
            service:UpdateLootHelperSettings({
                compactPopupOffsetX = offsetX,
                compactPopupOffsetY = offsetY,
                compactPopupPositionSaved = true,
            })
        else
            service:UpdateLootHelperSettings({
                popupOffsetX = offsetX,
                popupOffsetY = offsetY,
                popupPositionSaved = true,
            })
        end
    end

    local function countTableKeys(values)
        local count = 0

        for _ in pairs(type(values) == "table" and values or {}) do
            count = count + 1
        end

        return count
    end

    local function buildCompactInterestSummary(rowData)
        local claimCount = countTableKeys(rowData and rowData.claimantIds)
        local highestRoll = 0
        local highestRollName = ""

        for rollerId, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
            local resolvedRoll = tonumber(rollValue) or 0

            if resolvedRoll > highestRoll then
                highestRoll = resolvedRoll
                highestRollName = normalizeDisplayName(rollerId)
            end
        end

        local parts = {}

        if claimCount > 0 then
            parts[#parts + 1] = string.format("%d need", claimCount)
        end

        if highestRoll > 0 then
            parts[#parts + 1] = string.format("Win %s %d", highestRollName ~= "" and highestRollName or "?", highestRoll)
        end

        return table.concat(parts, " | ")
    end

    local function clampPopupOffsets(dialog, offsetX, offsetY)
        local resolvedX = tonumber(offsetX) or 0
        local resolvedY = tonumber(offsetY) or 0
        local dialogWidth = dialog and dialog.GetWidth and dialog:GetWidth() or 0
        local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 0
        local parentHeight = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 0
        local visibleMargin = 40

        if dialogWidth > 0 and parentWidth > 0 then
            local minX = math.min(0, visibleMargin - dialogWidth)
            local maxX = math.max(0, parentWidth - visibleMargin)
            resolvedX = math.max(minX, math.min(maxX, resolvedX))
        end

        if parentHeight > 0 then
            local minY = math.min(0, visibleMargin - parentHeight)
            local maxY = 0
            resolvedY = math.max(minY, math.min(maxY, resolvedY))
        end

        return math.floor(resolvedX), math.floor(resolvedY)
    end

    local function applyPopupLayout(dialog)
        if not dialog then
            return
        end

        local settings = service:GetLootHelperSettings()
        local compact = settings.compactPopup and true or false
        local sessionOffsets = dialog._sessionPopupOffsets and dialog._sessionPopupOffsets[compact and "compact" or "full"] or nil
        local headerWidth = compact and 372 or 900
        local ownerWidth = compact and 0 or 150
        local itemLeft = compact and 86 or 166
        local itemWidth = compact and 256 or 430
        local levelLeft = compact and 0 or 610
        local actionsLeft = compact and 0 or 674
        local anchoredOffsetX = nil
        local anchoredOffsetY = nil

        dialog:SetSize(compact and 420 or 960, compact and 168 or 520)
        dialog:ClearAllPoints()

        if settings.pinPopupPosition and compact and settings.compactPopupPositionSaved then
            anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(dialog, settings.compactPopupOffsetX, settings.compactPopupOffsetY)
        elseif settings.pinPopupPosition and (not compact) and settings.popupPositionSaved then
            anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(dialog, settings.popupOffsetX, settings.popupOffsetY)
        elseif sessionOffsets and sessionOffsets.x ~= nil and sessionOffsets.y ~= nil then
            anchoredOffsetX, anchoredOffsetY = clampPopupOffsets(dialog, sessionOffsets.x, sessionOffsets.y)
        end

        if anchoredOffsetX ~= nil and anchoredOffsetY ~= nil then
            dialog:SetPoint("TOPLEFT", UIParent, "TOPLEFT", anchoredOffsetX, anchoredOffsetY)
        else
            dialog:SetPoint(compact and "RIGHT" or "CENTER", UIParent, compact and "RIGHT" or "CENTER", compact and -24 or 0, 0)
        end

        if dialog.helperText then
            dialog.helperText:SetShown(not compact)
        end

        if dialog.footerHint then
            dialog.footerHint:SetShown(not compact)
        end

        if dialog.filterButton then
            dialog.filterButton:SetShown(not compact)
            if not compact then
                dialog.filterButton:ClearAllPoints()
                dialog.filterButton:SetPoint("TOPRIGHT", dialog.offerMenuButton or dialog, "TOPLEFT", -8, 0)
            end
        end

        if dialog.sortButton then
            dialog.sortButton:SetShown(not compact)
            if not compact then
                dialog.sortButton:ClearAllPoints()
                dialog.sortButton:SetPoint("RIGHT", dialog.filterButton, "LEFT", -8, 0)
            end
        end

        if dialog.tradeMenuButton then
            dialog.tradeMenuButton:SetShown(not compact)
            if not compact then
                dialog.tradeMenuButton:ClearAllPoints()
                dialog.tradeMenuButton:SetPoint("RIGHT", dialog.dismissAllButton, "LEFT", -8, 0)
            end
        end

        if dialog.offerMenuButton then
            dialog.offerMenuButton:SetShown(not compact)
            if not compact then
                dialog.offerMenuButton:ClearAllPoints()
                dialog.offerMenuButton:SetPoint("RIGHT", dialog.tradeMenuButton, "LEFT", -8, 0)
            end
        end

        if dialog.headerRow then
            dialog.headerRow:SetShown(not compact)
            if not compact then
                dialog.headerRow:SetWidth(headerWidth)
                dialog.headerRow:ClearAllPoints()
                dialog.headerRow:SetPoint("TOPLEFT", dialog, "TOPLEFT", 16, -72)
            end
        end

        if dialog.scrollFrame then
            dialog.scrollFrame:ClearAllPoints()
            dialog.scrollFrame:SetPoint("TOPLEFT", dialog, "TOPLEFT", 16, compact and -48 or -100)
            dialog.scrollFrame:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -34, compact and 38 or 52)
        end

        if dialog.scrollContent then
            dialog.scrollContent:SetWidth(headerWidth)
        end

        if dialog.ownerHeader then
            dialog.ownerHeader:SetShown(not compact)
            dialog.ownerHeader:SetWidth(ownerWidth)
        end

        if dialog.itemHeader then
            dialog.itemHeader:SetShown(not compact)
            if not compact then
                dialog.itemHeader:ClearAllPoints()
                dialog.itemHeader:SetPoint("LEFT", dialog.headerRow, "LEFT", itemLeft, 0)
                dialog.itemHeader:SetWidth(itemWidth)
            end
        end

        if dialog.levelHeader then
            dialog.levelHeader:SetShown(not compact)
            if not compact then
                dialog.levelHeader:ClearAllPoints()
                dialog.levelHeader:SetPoint("LEFT", dialog.headerRow, "LEFT", levelLeft, 0)
            end
        end

        if dialog.actionsHeader then
            dialog.actionsHeader:SetShown(not compact)
            if not compact then
                dialog.actionsHeader:ClearAllPoints()
                dialog.actionsHeader:SetPoint("LEFT", dialog.headerRow, "LEFT", actionsLeft, 0)
            end
        end

        if dialog.countLabel then
            dialog.countLabel:ClearAllPoints()
            dialog.countLabel:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 16, 16)
        end

        if dialog.minimizeButton then
            dialog.minimizeButton:SetShown(not compact)
            dialog.minimizeButton:SetEnabled(not compact)
            dialog.minimizeButton:ClearAllPoints()
            if dialog.closeButton then
                dialog.minimizeButton:SetPoint("TOPRIGHT", dialog.closeButton, "TOPLEFT", -6, 0)
            else
                dialog.minimizeButton:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -40, -8)
            end
        end

        if dialog.maximizeButton then
            dialog.maximizeButton:SetShown(compact)
            dialog.maximizeButton:SetEnabled(compact)
            dialog.maximizeButton:ClearAllPoints()
            if dialog.closeButton then
                dialog.maximizeButton:SetPoint("TOPRIGHT", dialog.closeButton, "TOPLEFT", -6, 0)
            else
                dialog.maximizeButton:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -40, -8)
            end
        end

        if dialog.pinButton then
            dialog.pinButton:SetShown(not compact)
            dialog.pinButton:SetAlpha(settings.pinPopupPosition and 1 or 0.55)
        end

        if dialog.dismissAllButton then
            dialog.dismissAllButton:SetShown(not compact)
            dialog.dismissAllButton:SetAlpha(1)
        end

        if dialog.bottomCloseButton then
            dialog.bottomCloseButton:SetShown(not compact)
        end

        if dialog.closeButton then
            dialog.closeButton:SetShown(true)
        end

        dialog._layout = {
            compact = compact,
            headerWidth = headerWidth,
            ownerWidth = ownerWidth,
            itemLeft = itemLeft,
            itemWidth = itemWidth,
            levelLeft = levelLeft,
            actionsLeft = actionsLeft,
        }
    end

    if not frame then
        frame = CreateFrame("Frame", "GH3BISDropReviewFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(220)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
            savePopupPosition(dialog)
        end)
        frame:SetScript("OnMouseUp", function(dialog, button)
            if button ~= "RightButton" or service:GetLootHelperSettings().compactPopup ~= true then
                return
            end

            service:UpdateLootHelperSettings({ compactPopup = false })
            applyPopupLayout(dialog)
            dialog:RenderRows()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        title:SetText("GH3 BIS Drops")
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(440)
        helper:SetJustifyH("LEFT")
        helper:SetText("W whisper, C claim, O offer, R roll, T trade, D dismiss.")
        styleBodyText(helper, true)
        frame.helperText = helper

        frame.filterButton = createMenuButton(frame, "All Rows", 126, 24, "TOPRIGHT", frame, "TOPRIGHT", -208, -40)
        frame.sortButton = createMenuButton(frame, "Newest", 110, 24, "TOPRIGHT", frame, "TOPRIGHT", -342, -40)
        frame.offerMenuButton = createMenuButton(frame, "O", 24, 24, "TOPRIGHT", frame, "TOPRIGHT", -176, -40)
        frame.tradeMenuButton = createMenuButton(frame, "T", 24, 24, "TOPRIGHT", frame, "TOPRIGHT", -144, -40)
        attachTooltip(frame.offerMenuButton, "Offer", "Choose one of your active tradable loot rows and GH3 will offer it to the current party or raid.")
        attachTooltip(frame.tradeMenuButton, "Trade", "Choose any current party or raid member from a dropdown and GH3 will try to open trade immediately.")

        frame.pinButton = createButton(frame, "P", 24, 24, "TOPRIGHT", frame, "TOPRIGHT", -80, -40, function()
            local settings = service:GetLootHelperSettings()
            service:UpdateLootHelperSettings({ pinPopupPosition = settings.pinPopupPosition ~= true })
            savePopupPosition(frame)
            applyPopupLayout(frame)
        end)
        attachTooltip(frame.pinButton, "Pin Position", "Remember the popup's dragged position instead of reopening in the default layout slot.")

        frame.dismissAllButton = createButton(frame, "D", 24, 24, "TOPRIGHT", frame, "TOPRIGHT", -112, -40, function()
            service:ClearDropReviewRows()
            frame:Hide()
        end)
        setButtonTone(frame.dismissAllButton, { 0.24, 0.1, 0.1, 0.98 }, { 0.98, 0.84, 0.8, 1 })
        attachTooltip(frame.dismissAllButton, "Dismiss All", "Clear all active BIS Drops rows and close the popup.")

        frame.minimizeButton = createTitleBarStateButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -40, -8, "minimize", function()
            service:UpdateLootHelperSettings({ compactPopup = true })
            applyPopupLayout(frame)
            frame:RenderRows()
        end)
        attachTooltip(frame.minimizeButton, "Compact Window", "Switch the BIS Drops popup into a smaller side-docked layout.")

        frame.maximizeButton = createTitleBarStateButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -40, -8, "maximize", function()
            service:UpdateLootHelperSettings({ compactPopup = false })
            applyPopupLayout(frame)
            frame:RenderRows()
        end)
        attachTooltip(frame.maximizeButton, "Full Window", "Restore the full BIS Drops popup layout.")

        frame.closeButton = createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            if frame.HideAfterCombatSafe then
                frame:HideAfterCombatSafe()
            else
                frame:Hide()
            end
        end)

        local headerRow = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        headerRow:SetSize(760, 24)
        headerRow:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -72)
        applySurfaceStyle(headerRow, THEME.card)
        frame.headerRow = headerRow

        local function createHeader(text, width, point, relativeTo, relativePoint, x, y)
            local label = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            label:SetPoint(point, relativeTo, relativePoint, x, y)
            label:SetWidth(width)
            label:SetJustifyH("LEFT")
            label:SetText(text)
            styleHighlightText(label)
            return label
        end

        frame.ownerHeader = createHeader("Owner", 150, "LEFT", headerRow, "LEFT", 8, 0)
        frame.itemHeader = createHeader("Item", 408, "LEFT", headerRow, "LEFT", 166, 0)
        frame.levelHeader = createHeader("ilvl", 54, "LEFT", headerRow, "LEFT", 582, 0)
        frame.actionsHeader = createHeader("Actions", 220, "LEFT", headerRow, "LEFT", 624, 0)

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -100)
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
        frame.scrollFrame = scrollFrame

        local content = CreateFrame("Frame", nil, scrollFrame)
        content:SetSize(760, 100)
        scrollFrame:SetScrollChild(content)
        frame.scrollContent = content

        local countLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        countLabel:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 16)
        countLabel:SetText("")
        styleHighlightText(countLabel)
        frame.countLabel = countLabel

        local footerHint = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        footerHint:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -150, 18)
        footerHint:SetWidth(400)
        footerHint:SetJustifyH("RIGHT")
        footerHint:SetText("Burst loots are batched before the popup opens.")
        styleBodyText(footerHint, true)
        frame.footerHint = footerHint

        function frame:RefreshTradeTargets()
            if self.offerMenuButton then
                local offerRows = service:ListOfferableDropReviewRows()

                if #offerRows > 0 then
                    self.offerMenuButton:Enable()
                    self.offerMenuButton:SetAlpha(1)
                else
                    self.offerMenuButton:Disable()
                    self.offerMenuButton:SetAlpha(0.55)
                end
            end

            if self.tradeMenuButton then
                local targets = service:ListCurrentGroupTradeTargets()

                if #targets > 0 then
                    self.tradeMenuButton:Enable()
                    self.tradeMenuButton:SetAlpha(1)
                else
                    self.tradeMenuButton:Disable()
                    self.tradeMenuButton:SetAlpha(0.55)
                end
            end
        end

        function frame:RenderRows()
            clearChildren(self.scrollContent)
            self:RefreshTradeTargets()

            local settings = service:GetLootHelperSettings()
            local layout = self._layout or {}
            local compact = layout.compact and true or false
            local rows = {}
            local allRows = service:ListActiveDropReviewRows()

            for _, rowData in ipairs(allRows) do
                if settings.reviewFilter == "all" or safeTrim(rowData.kind) == settings.reviewFilter then
                    rows[#rows + 1] = rowData
                end
            end

            table.sort(rows, function(left, right)
                if settings.reviewSort == "owner" then
                    local leftOwner = lowerText(normalizeDisplayName(left.ownerId or ""))
                    local rightOwner = lowerText(normalizeDisplayName(right.ownerId or ""))

                    if leftOwner == rightOwner then
                        return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
                    end

                    return leftOwner < rightOwner
                end

                if settings.reviewSort == "ilevel" then
                    local leftLevel = tonumber(left.itemLevel) or 0
                    local rightLevel = tonumber(right.itemLevel) or 0

                    if leftLevel == rightLevel then
                        return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
                    end

                    return leftLevel > rightLevel
                end

                return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
            end)

            self.countLabel:SetText(string.format("Showing %d of %d active row(s)", #rows, #allRows))

            if #rows <= 0 then
                local empty = self.scrollContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                empty:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 8, -10)
                empty:SetWidth((layout.headerWidth or 760) - 16)
                empty:SetJustifyH("LEFT")
                empty:SetText(settings.reviewFilter == "all" and "No active BIS drop reviews right now." or "No active rows match the current filter.")
                styleBodyText(empty)
                self.scrollContent:SetHeight(80)
                return
            end

            if compact then
                local compactRows = {}
                local maxCompactRows = math.min(#rows, 2)

                for index = 1, maxCompactRows do
                    compactRows[#compactRows + 1] = rows[index]
                end

                self.countLabel:SetText(#rows > 2 and string.format("%d active | %d more", #rows, #rows - 2) or string.format("%d active drop(s)", #rows))

                local compactY = 0
                for _, rowData in ipairs(compactRows) do
                    local compactSummary = buildCompactInterestSummary(rowData)
                    local badgeText = select(1, buildBadgeStyle(safeTrim(rowData.kind), rowData.targetId))
                    local statusBadgeText = select(1, buildStatusBadgeStyle(rowData))
                    local compactTags = badgeText

                    if safeTrim(statusBadgeText) ~= "" then
                        compactTags = string.format("%s | %s", compactTags, statusBadgeText)
                    end

                    local row = CreateFrame("Frame", nil, self.scrollContent, "BackdropTemplate")
                    row:SetSize(layout.headerWidth or 372, 40)
                    row:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 0, -compactY)
                    compactY = compactY + 42

                    local tagLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                    tagLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -4)
                    tagLabel:SetWidth((layout.headerWidth or 372) - 56)
                    tagLabel:SetJustifyH("LEFT")
                    tagLabel:SetText(compactTags)
                    styleBodyText(tagLabel, true)

                    local itemLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                    itemLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -17)
                    itemLabel:SetWidth((layout.headerWidth or 372) - 56)
                    itemLabel:SetJustifyH("LEFT")
                    itemLabel:SetText(safeTrim(rowData.itemLink) ~= "" and rowData.itemLink or rowData.itemName)
                    styleBodyText(itemLabel)

                    local itemHotspot = CreateFrame("Button", nil, row)
                    itemHotspot:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -16)
                    itemHotspot:SetSize((layout.headerWidth or 372) - 52, 16)
                    attachItemHyperlinkTooltip(itemHotspot, rowData.itemLink, rowData.itemName)

                    local metaLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                    metaLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -30)
                    metaLabel:SetWidth((layout.headerWidth or 372) - 56)
                    metaLabel:SetJustifyH("LEFT")
                    metaLabel:SetText(string.format("%s%s%s",
                        normalizeDisplayName(rowData.ownerId),
                        (tonumber(rowData.itemLevel) or 0) > 0 and (" | " .. tostring(tonumber(rowData.itemLevel) or 0)) or "",
                        compactSummary ~= "" and (" | " .. compactSummary) or ""))
                    styleBodyText(metaLabel, true)

                    attachTooltip(row, safeTrim(rowData.itemName) ~= "" and rowData.itemName or "BIS Review", string.format("Owner: %s\nTarget: %s\n%s%s",
                        safeTrim(rowData.ownerId) ~= "" and normalizeDisplayName(rowData.ownerId) or "Unknown",
                        safeTrim(rowData.targetId) ~= "" and normalizeDisplayName(rowData.targetId) or "Unknown",
                        safeTrim(rowData.metaText) ~= "" and safeTrim(rowData.metaText) or "Tracked GH3 BIS review row.",
                        buildInterestSummary(rowData) ~= "" and ("\n" .. buildInterestSummary(rowData)) or ""))
                end

                self.scrollContent:SetHeight(math.max(44, compactY - 4))
                return
            end

            local ownerWidth = layout.ownerWidth or 150
            local itemLeft = layout.itemLeft or 166
            local itemWidth = layout.itemWidth or 408
            local levelLeft = layout.levelLeft or 582
            local actionsLeft = layout.actionsLeft or 624
            local yOffset = 0

            for index, rowData in ipairs(rows) do
                local interestSummary = buildInterestSummary(rowData)
                local compactInterestSummary = buildCompactInterestSummary(rowData)
                local showInterestLine = not compact and interestSummary ~= ""
                local rowHeight = compact and 40 or 56
                local rowPitch = rowHeight + 6
                local row = CreateFrame("Frame", nil, self.scrollContent, "BackdropTemplate")
                row:SetSize(layout.headerWidth or 760, rowHeight)
                row:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 0, -yOffset)
                applySurfaceStyle(row, THEME.panel)

                local badgeText, badgeTone, badgeTextColor = buildBadgeStyle(safeTrim(rowData.kind), rowData.targetId)
                local badge = CreateFrame("Frame", nil, row, "BackdropTemplate")
                badge:SetSize(74, 18)
                badge:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -7)
                applySurfaceStyle(badge, THEME.banner)
                badge:SetBackdropColor(unpack(badgeTone))
                badge:SetBackdropBorderColor(badgeTone[1] + 0.12, badgeTone[2] + 0.12, badgeTone[3] + 0.12, 1)

                local badgeLabel = badge:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                badgeLabel:SetPoint("CENTER", badge, "CENTER", 0, 0)
                badgeLabel:SetText(badgeText)
                badgeLabel:SetFont(BUTTON_FONT_PATH, 10, "OUTLINE")
                badgeLabel:SetTextColor(unpack(badgeTextColor))

                local statusBadgeText, statusBadgeTone, statusBadgeTextColor = buildStatusBadgeStyle(rowData)

                if statusBadgeText then
                    local statusBadge = CreateFrame("Frame", nil, row, "BackdropTemplate")
                    statusBadge:SetSize(58, 18)
                    statusBadge:SetPoint("TOPLEFT", badge, "TOPRIGHT", 6, 0)
                    applySurfaceStyle(statusBadge, THEME.banner)
                    statusBadge:SetBackdropColor(unpack(statusBadgeTone))
                    statusBadge:SetBackdropBorderColor(statusBadgeTone[1] + 0.12, statusBadgeTone[2] + 0.12, statusBadgeTone[3] + 0.12, 1)

                    local statusBadgeLabel = statusBadge:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                    statusBadgeLabel:SetPoint("CENTER", statusBadge, "CENTER", 0, 0)
                    statusBadgeLabel:SetText(statusBadgeText)
                    statusBadgeLabel:SetFont(BUTTON_FONT_PATH, 10, "OUTLINE")
                    statusBadgeLabel:SetTextColor(unpack(statusBadgeTextColor))
                end

                local ownerLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                ownerLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, compact and -18 or -28)
                ownerLabel:SetWidth(ownerWidth)
                ownerLabel:SetJustifyH("LEFT")
                ownerLabel:SetText(normalizeDisplayName(rowData.ownerId))
                styleBodyText(ownerLabel)
                local ownerMeta = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                ownerMeta:SetPoint("TOPLEFT", row, "TOPLEFT", 8, compact and -20 or -42)
                ownerMeta:SetWidth(ownerWidth)
                ownerMeta:SetJustifyH("LEFT")
                ownerMeta:SetText(
                    safeTrim(rowData.kind) == "self-offer" and (safeTrim(rowData.targetId) ~= "" and ("Trade to " .. normalizeDisplayName(rowData.targetId)) or "Choose trade target from T")
                    or (safeTrim(rowData.kind) == "offer" and safeTrim(rowData.targetId) ~= "" and ("To " .. normalizeDisplayName(rowData.targetId))
                    or (safeTrim(rowData.targetId) ~= "" and ("For " .. normalizeDisplayName(rowData.targetId)) or (rowData.leftGroup and "Left group" or "Loot holder"))))
                styleBodyText(ownerMeta, true)
                ownerMeta:SetShown(not compact)

                local itemLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                itemLabel:SetPoint("TOPLEFT", row, "TOPLEFT", itemLeft, compact and -11 or -9)
                itemLabel:SetWidth(itemWidth)
                itemLabel:SetJustifyH("LEFT")
                itemLabel:SetWordWrap(true)
                itemLabel:SetText(safeTrim(rowData.itemLink) ~= "" and rowData.itemLink or rowData.itemName)
                styleBodyText(itemLabel)

                local itemHotspot = CreateFrame("Button", nil, row)
                itemHotspot:SetPoint("TOPLEFT", row, "TOPLEFT", itemLeft - 2, -4)
                itemHotspot:SetSize(itemWidth + 4, compact and 16 or 18)
                attachItemHyperlinkTooltip(itemHotspot, rowData.itemLink, rowData.itemName)

                local metaLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                metaLabel:SetPoint("TOPLEFT", itemLabel, "BOTTOMLEFT", 0, -2)
                metaLabel:SetWidth(itemWidth)
                metaLabel:SetJustifyH("LEFT")
                metaLabel:SetWordWrap(true)
                metaLabel:SetText(((safeTrim(rowData.metaText) ~= "" and rowData.metaText) or "BIS review") .. (rowData.leftGroup and " | Left group" or ""))
                styleBodyText(metaLabel, true)
                metaLabel:SetShown(not compact)

                local interestLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                interestLabel:SetPoint("TOPLEFT", metaLabel, "BOTTOMLEFT", 0, -2)
                interestLabel:SetWidth(itemWidth)
                interestLabel:SetJustifyH("LEFT")
                interestLabel:SetWordWrap(true)
                interestLabel:SetText(interestSummary)
                styleBodyText(interestLabel, true)
                interestLabel:SetShown(showInterestLine)

                local slotMeta = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                slotMeta:SetPoint("TOPLEFT", showInterestLine and interestLabel or metaLabel, "BOTTOMLEFT", 0, -2)
                slotMeta:SetWidth(itemWidth)
                slotMeta:SetJustifyH("LEFT")
                slotMeta:SetWordWrap(true)
                slotMeta:SetText(string.format("%s%s%s",
                    safeTrim(rowData.slotLabel) ~= "" and rowData.slotLabel or "Tracked slot",
                    safeTrim(rowData.lootSpecId) ~= "" and (" | " .. safeTrim(rowData.lootSpecId)) or "",
                    compact and compactInterestSummary ~= "" and (" | " .. compactInterestSummary) or ""))
                styleBodyText(slotMeta, true)
                slotMeta:SetShown(not compact or compactInterestSummary ~= "")

                if not compact then
                    local itemHeight = math.max(16, math.ceil(itemLabel:GetStringHeight() or 0))
                    local metaHeight = metaLabel:IsShown() and math.max(12, math.ceil(metaLabel:GetStringHeight() or 0)) or 0
                    local interestHeight = interestLabel:IsShown() and math.max(12, math.ceil(interestLabel:GetStringHeight() or 0)) or 0
                    local slotHeight = slotMeta:IsShown() and math.max(12, math.ceil(slotMeta:GetStringHeight() or 0)) or 0
                    local stackHeight = 9 + itemHeight + 2 + metaHeight + ((interestLabel:IsShown() and 2 or 0) + interestHeight) + 2 + slotHeight + 8

                    rowHeight = math.max(56, stackHeight)
                    rowPitch = rowHeight + 6
                    row:SetHeight(rowHeight)
                    itemHotspot:SetHeight(math.max(16, itemHeight))
                end

                local levelLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                levelLabel:SetPoint("TOPLEFT", row, "TOPLEFT", levelLeft, compact and -11 or -18)
                levelLabel:SetWidth(54)
                levelLabel:SetJustifyH("LEFT")
                levelLabel:SetText((tonumber(rowData.itemLevel) or 0) > 0 and tostring(tonumber(rowData.itemLevel) or 0) or "-")
                styleBodyText(levelLabel)

                local actionLane = CreateFrame("Frame", nil, row)
                actionLane:SetSize(200, 24)
                actionLane:SetPoint("TOPLEFT", row, "TOPLEFT", actionsLeft, compact and -9 or -16)

                local function runAction(actionKey)
                    if service:HandleDropReviewAction(rowData.id, actionKey) then
                        frame:RenderRows()
                    end
                end

                local nextActionX = 0
                local function addActionButton(label, actionKey, fillTone, textTone, tooltipTitle, tooltipBody)
                    local button = createButton(actionLane, label, 28, 22, "LEFT", actionLane, "LEFT", nextActionX, 0, function()
                        if actionKey == "T" and safeTrim(rowData.kind) == "self-offer" then
                            local targets = service:ListCurrentGroupTradeTargets()

                            showMenuButton(button, function()
                                local menuOptions = {}

                                if #targets <= 0 then
                                    menuOptions[#menuOptions + 1] = {
                                        text = "No active group targets",
                                        value = "",
                                        disabled = true,
                                    }
                                end

                                for _, targetId in ipairs(targets) do
                                    menuOptions[#menuOptions + 1] = {
                                        text = normalizeDisplayName(targetId),
                                        value = targetId,
                                        checked = safeTrim(rowData.targetId) == safeTrim(targetId),
                                        onClick = function(value)
                                            service:SetDropReviewRowTarget(rowData.id, value)
                                            runAction(actionKey)
                                        end,
                                    }
                                end

                                return menuOptions
                            end)
                            return
                        end

                        runAction(actionKey)
                    end)
                    setButtonTone(button, fillTone, textTone)
                    attachTooltip(button, tooltipTitle, tooltipBody)

                    if actionKey == "T" and rowData.leftGroup then
                        button:Disable()
                        button:SetAlpha(0.45)
                        attachTooltip(button, "Trade Unavailable", "This row is marked Left group. Whisper can still work, but trade cannot start until they rejoin your current party or raid.")
                    end

                    nextActionX = nextActionX + 36
                end

                if rowData.canWhisper then
                    addActionButton("W", "W", { 0.08, 0.22, 0.14, 0.98 }, { 0.78, 0.96, 0.82, 1 }, "Whisper", "Ask the holder for the item with a prefilled GH3 whisper.")
                end

                if rowData.canClaim then
                    addActionButton("C", "C", { 0.17, 0.12, 0.28, 0.98 }, { 0.9, 0.84, 1, 1 }, "Claim", "Tell the owner you want the item and send a GH3 claim to their addon.")
                end

                if rowData.canOffer then
                    addActionButton("O", "O", { 0.26, 0.18, 0.06, 0.98 }, { 0.98, 0.9, 0.62, 1 }, "Offer", "Post a group chat message asking if anyone needs this item and share it to GH3 users.")
                end

                if rowData.canRoll then
                    addActionButton("R", "R", { 0.18, 0.08, 0.24, 0.98 }, { 0.94, 0.8, 1, 1 }, "Roll", "Submit a GH3 1-100 roll for this item, post the result to group chat, and sync it to GH3 popups.")
                end

                if rowData.canTrade then
                    addActionButton("T", "T", { 0.12, 0.16, 0.28, 0.98 }, { 0.82, 0.88, 1, 1 }, "Trade", safeTrim(rowData.kind) == "self-offer" and "Choose an active party or raid member from a dropdown, then start trade." or "Target the relevant player and attempt to open trade if they are nearby.")
                end

                local dismissButton = createButton(actionLane, "D", 28, 22, "LEFT", actionLane, "LEFT", nextActionX, 0, function()
                    runAction("D")
                end)
                setButtonTone(dismissButton, { 0.24, 0.1, 0.1, 0.98 }, { 0.98, 0.84, 0.8, 1 })
                attachTooltip(dismissButton, "Dismiss", "Remove this row from the review popup without sending chat or opening trade.")

                yOffset = yOffset + rowPitch

                attachTooltip(row, safeTrim(rowData.itemName) ~= "" and rowData.itemName or "BIS Review", string.format("Owner: %s\nTarget: %s\n%s%s",
                    safeTrim(rowData.ownerId) ~= "" and normalizeDisplayName(rowData.ownerId) or "Unknown",
                    safeTrim(rowData.targetId) ~= "" and normalizeDisplayName(rowData.targetId) or "Unknown",
                    safeTrim(rowData.metaText) ~= "" and safeTrim(rowData.metaText) or "Tracked GH3 BIS review row.",
                    interestSummary ~= "" and ("\n" .. interestSummary) or ""))
            end

            self.scrollContent:SetHeight(math.max(80, yOffset - 6))
        end

        frame:SetScript("OnShow", function(dialog)
            dialog._refreshElapsed = 0
            playOpenSound()
            dialog:RenderRows()
        end)
        frame:SetScript("OnUpdate", function(dialog, elapsed)
            if InCombatLockdown and InCombatLockdown() then
                addon.MainFrame.pendingBISDropReviewAfterCombat = true
                return
            end

            dialog._refreshElapsed = (tonumber(dialog._refreshElapsed) or 0) + (tonumber(elapsed) or 0)

            if dialog._refreshElapsed < 1 then
                return
            end

            dialog._refreshElapsed = 0
            dialog:RenderRows()

            if #service:ListActiveDropReviewRows() <= 0 then
                dialog:Hide()
            end
        end)

        frame.bottomCloseButton = createButton(frame, "Close", 120, 24, "BOTTOMRIGHT", frame, "BOTTOMRIGHT", -16, 14, function()
            frame:Hide()
        end)

        self.bisDropReviewFrame = frame
    end

    frame.filterButton:SetScript("OnClick", function(buttonSelf)
        local settings = service:GetLootHelperSettings()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(service:GetLootReviewFilterOptions()) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = settings.reviewFilter == option.id,
                    onClick = function(value)
                        service:UpdateLootHelperSettings({ reviewFilter = value })
                        setMenuButtonText(frame.filterButton, option.label)
                        frame:RenderRows()
                    end,
                }
            end

            return menuOptions
        end)
    end)

    frame.sortButton:SetScript("OnClick", function(buttonSelf)
        local settings = service:GetLootHelperSettings()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(service:GetLootReviewSortOptions()) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = settings.reviewSort == option.id,
                    onClick = function(value)
                        service:UpdateLootHelperSettings({ reviewSort = value })
                        setMenuButtonText(frame.sortButton, option.label)
                        frame:RenderRows()
                    end,
                }
            end

            return menuOptions
        end)
    end)

    frame.tradeMenuButton:SetScript("OnClick", function(buttonSelf)
        local targets = service:ListCurrentGroupTradeTargets()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            if #targets <= 0 then
                menuOptions[#menuOptions + 1] = {
                    text = "No active group targets",
                    value = "",
                    disabled = true,
                }
            end

            for _, targetId in ipairs(targets) do
                menuOptions[#menuOptions + 1] = {
                    text = normalizeDisplayName(targetId),
                    value = targetId,
                    onClick = function(value)
                        service:StartTradeWithPlayer(value)
                    end,
                }
            end

            return menuOptions
        end)
    end)

    frame.offerMenuButton:SetScript("OnClick", function(buttonSelf)
        local offerRows = service:ListOfferableDropReviewRows()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            if #offerRows <= 0 then
                menuOptions[#menuOptions + 1] = {
                    text = "No offerable loot rows",
                    value = "",
                    disabled = true,
                }
            end

            for _, rowData in ipairs(offerRows) do
                local itemLabel = safeTrim(rowData.itemName) ~= "" and safeTrim(rowData.itemName) or safeTrim(rowData.itemLink)
                local menuLabel = cropText(string.format("%s%s",
                    itemLabel ~= "" and itemLabel or "Tradable Loot",
                    (tonumber(rowData.itemLevel) or 0) > 0 and (" (" .. tostring(tonumber(rowData.itemLevel) or 0) .. ")") or ""), 32)

                menuOptions[#menuOptions + 1] = {
                    text = menuLabel,
                    value = rowData.id,
                    onClick = function(value)
                        if service:HandleDropReviewAction(value, "O") then
                            frame:RenderRows()
                        end
                    end,
                }
            end

            return menuOptions
        end)
    end)

    if #service:ListActiveDropReviewRows() <= 0 then
        if frame:IsShown() then
            frame:Hide()
        end
        return
    end

    applyPopupLayout(frame)
    do
        local settings = service:GetLootHelperSettings()
        local filterLabel = "All Rows"
        local sortLabel = "Newest"

        for _, option in ipairs(service:GetLootReviewFilterOptions()) do
            if option.id == settings.reviewFilter then
                filterLabel = option.label
                break
            end
        end

        for _, option in ipairs(service:GetLootReviewSortOptions()) do
            if option.id == settings.reviewSort then
                sortLabel = option.label
                break
            end
        end

        setMenuButtonText(frame.filterButton, filterLabel)
        setMenuButtonText(frame.sortButton, sortLabel)
    end
    local wasShown = frame:IsShown()
    frame:Raise()
    if not wasShown then
        frame:Show()
        return
    end

    frame:RenderRows()
end

function addon.MainFrame:ShowBISLootHelperDialog(titleText)
    local service = addon.BISService
    local frame = self.bisLootHelperFrame

    if not frame then
        frame = CreateFrame("Frame", "GH3BISLootHelperFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(218)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        frame:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(540)
        helper:SetJustifyH("LEFT")
        helper:SetText("Configure how GH3 builds the BIS Drops review queue. 'All Tradeable Loot' can run in a strict tooltip-based tradable mode or a broader best-effort review mode for party loot, while manual offers, claims, and rolls all sync through GH3 so tradable drops can be resolved from the popup instead of chat alone.")
        styleBodyText(helper, true)
        frame.helperText = helper

        createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:Hide()
        end)

        local modeLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        modeLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -96)
        modeLabel:SetText("Queue Mode")
        styleBodyText(modeLabel, true)

        frame.modeButton = createMenuButton(frame, "BIS Only", 220, 24, "TOPLEFT", frame, "TOPLEFT", 16, -118)

        local detectionLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        detectionLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 256, -96)
        detectionLabel:SetText("Detection")
        styleBodyText(detectionLabel, true)

        frame.detectionButton = createMenuButton(frame, "Strict Tradable Detection", 304, 24, "TOPLEFT", frame, "TOPLEFT", 256, -118)

        local optionsPanel = createPanel(frame)
        optionsPanel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -158)
        optionsPanel:SetSize(544, 170)

        local optionsTitle = optionsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        optionsTitle:SetPoint("TOPLEFT", optionsPanel, "TOPLEFT", 12, -12)
        optionsTitle:SetText("Popup Options")
        styleTitleText(optionsTitle, 15)

        frame.autoOpenCheckbox = createCheckbox(optionsPanel, "Auto open the BIS Drops popup when new rows arrive", "TOPLEFT", optionsPanel, "TOPLEFT", 12, -38, true, function(checked)
            service:UpdateLootHelperSettings({ autoOpenPopup = checked })
            frame:RefreshState()
        end)

        frame.soundCheckbox = createCheckbox(optionsPanel, "Play a sound when the BIS Drops popup opens", "TOPLEFT", optionsPanel, "TOPLEFT", 12, -64, true, function(checked)
            service:UpdateLootHelperSettings({ playPopupSound = checked })
            frame:RefreshState()
        end)

        frame.tradeWhisperCheckbox = createCheckbox(optionsPanel, "Whisper the other player when GH3 sees a successful trade close", "TOPLEFT", optionsPanel, "TOPLEFT", 12, -90, true, function(checked)
            service:UpdateLootHelperSettings({ whisperTradeCompletion = checked })
            frame:RefreshState()
        end)

        frame.pinPopupCheckbox = createCheckbox(optionsPanel, "Pin the BIS Drops popup and remember its dragged position", "TOPLEFT", optionsPanel, "TOPLEFT", 12, -116, true, function(checked)
            service:UpdateLootHelperSettings({
                pinPopupPosition = checked,
                popupPositionSaved = checked,
                compactPopupPositionSaved = checked,
            })

            if checked and addon.MainFrame and addon.MainFrame.bisDropReviewFrame then
                local reviewFrame = addon.MainFrame.bisDropReviewFrame
                local left = reviewFrame.GetLeft and reviewFrame:GetLeft() or nil
                local top = reviewFrame.GetTop and reviewFrame:GetTop() or nil
                local parentLeft = UIParent and UIParent.GetLeft and UIParent:GetLeft() or 0
                local parentTop = UIParent and UIParent.GetTop and UIParent:GetTop() or 0

                if left and top then
                    local offsetX = math.floor(left - parentLeft)
                    local offsetY = math.floor(top - parentTop)
                    local currentSettings = service:GetLootHelperSettings()

                    if currentSettings.compactPopup then
                        service:UpdateLootHelperSettings({
                            compactPopupOffsetX = offsetX,
                            compactPopupOffsetY = offsetY,
                        })
                    else
                        service:UpdateLootHelperSettings({
                            popupOffsetX = offsetX,
                            popupOffsetY = offsetY,
                        })
                    end
                end
            end

            frame:RefreshState()
        end)

        frame.keepRowsCheckbox = createCheckbox(optionsPanel, "Keep active rows until dismissed instead of auto-expiring them", "TOPLEFT", optionsPanel, "TOPLEFT", 12, -142, true, function(checked)
            service:UpdateLootHelperSettings({ keepRowsUntilDismissed = checked })
            frame:RefreshState()
        end)

        local queuePanel = createPanel(frame)
        queuePanel:SetPoint("TOPLEFT", optionsPanel, "BOTTOMLEFT", 0, -14)
        queuePanel:SetSize(544, 92)

        local queueTitle = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        queueTitle:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 12, -12)
        queueTitle:SetText("Queue")
        styleTitleText(queueTitle, 15)

        frame.openActiveDropsButton = createButton(queuePanel, "Open Active Drops", 138, 24, "TOPRIGHT", queuePanel, "TOPRIGHT", -12, -10, function()
            addon.MainFrame:ShowBISDropReviewDialog()
        end)

        frame.queueCloseButton = createButton(frame, "Close", 88, 24, "BOTTOMRIGHT", frame, "BOTTOMRIGHT", -16, 10, function()
            frame:Hide()
        end)

        frame.queueSummary = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.queueSummary:SetPoint("TOPLEFT", queueTitle, "BOTTOMLEFT", 0, -6)
        frame.queueSummary:SetWidth(360)
        frame.queueSummary:SetJustifyH("LEFT")
        styleBodyText(frame.queueSummary, true)

        local historyPanel = createPanel(frame)
        historyPanel:SetPoint("TOPLEFT", queuePanel, "BOTTOMLEFT", 0, -14)
        historyPanel:SetSize(544, 162)

        local historyTitle = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        historyTitle:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 12, -12)
        historyTitle:SetText("Loot History")
        styleTitleText(historyTitle, 15)

        frame.historyPolicy = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.historyPolicy:SetPoint("TOPLEFT", historyTitle, "BOTTOMLEFT", 0, -4)
        frame.historyPolicy:SetWidth(510)
        frame.historyPolicy:SetJustifyH("LEFT")
        styleBodyText(frame.historyPolicy, true)

        local historyScrollFrame = CreateFrame("ScrollFrame", nil, historyPanel, "UIPanelScrollFrameTemplate")
        historyScrollFrame:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 12, -46)
        historyScrollFrame:SetPoint("BOTTOMRIGHT", historyPanel, "BOTTOMRIGHT", -30, 12)

        local historyContent = CreateFrame("Frame", nil, historyScrollFrame)
        historyContent:SetSize(500, 100)
        historyScrollFrame:SetScrollChild(historyContent)
        frame.historyContent = historyContent

        local historyRowsHost = CreateFrame("Frame", nil, historyContent)
        historyRowsHost:SetPoint("TOPLEFT", historyContent, "TOPLEFT", 0, 0)
        historyRowsHost:SetSize(500, 100)
        frame.historyRowsHost = historyRowsHost

        function frame:RefreshState()
            local settings = service:GetLootHelperSettings()
            local activeRows = service:ListActiveDropReviewRows()
            local historyRows = service:ListLootHistory()
            local modeLabelText = settings.mode == "all-tradeable" and "All Tradeable Loot" or "BIS Only"
            local detectionLabelText = settings.detectionMode == "broad" and "Broad Best-Effort Detection" or "Strict Tradable Detection"

            setMenuButtonText(self.modeButton, modeLabelText)
            self.modeButton.selectedValue = settings.mode
            setMenuButtonText(self.detectionButton, detectionLabelText)
            self.detectionButton.selectedValue = settings.detectionMode
            self.autoOpenCheckbox:SetChecked(settings.autoOpenPopup and true or false)
            self.soundCheckbox:SetChecked(settings.playPopupSound and true or false)
            self.tradeWhisperCheckbox:SetChecked(settings.whisperTradeCompletion and true or false)
            self.pinPopupCheckbox:SetChecked(settings.pinPopupPosition and true or false)
            self.keepRowsCheckbox:SetChecked(settings.keepRowsUntilDismissed and true or false)
            self.queueSummary:SetText(string.format("%d active review row(s). Mode: %s. Detection: %s. Strict mode only shows rows when tooltips look tradable, while broad mode also keeps best-effort party loot review rows. Pinning remembers popup position, and keep-until-dismissed keeps the list available until the run is over.", #activeRows, modeLabelText, detectionLabelText))
            self.historyPolicy:SetText(service:GetLootHistoryPolicyText())

            clearChildren(self.historyRowsHost)

            if #historyRows <= 0 then
                local emptyRow = CreateFrame("Frame", nil, self.historyRowsHost)
                emptyRow:SetSize(500, 20)
                emptyRow:SetPoint("TOPLEFT", self.historyRowsHost, "TOPLEFT", 0, 0)
                local empty = emptyRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                empty:SetPoint("TOPLEFT", emptyRow, "TOPLEFT", 0, 0)
                empty:SetWidth(500)
                empty:SetJustifyH("LEFT")
                empty:SetText("No recent loot-helper history yet.")
                styleBodyText(empty, true)
                self.historyContent:SetHeight(40)
            else
                local maxShown = math.min(#historyRows, 8)

                for index = 1, maxShown do
                    local entry = historyRows[index]
                    local row = CreateFrame("Frame", nil, self.historyRowsHost)
                    row:SetSize(500, 18)
                    row:SetPoint("TOPLEFT", self.historyRowsHost, "TOPLEFT", 0, -((index - 1) * 18))
                    local line = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                    line:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
                    line:SetWidth(500)
                    line:SetJustifyH("LEFT")
                    line:SetText(string.format("%s | %s | %s | %s",
                        formatTimestampText(entry.at),
                        safeTrim(entry.outcome) ~= "" and safeTrim(entry.outcome) or "event",
                        safeTrim(entry.itemLink) ~= "" and safeTrim(entry.itemLink) or (safeTrim(entry.itemName) ~= "" and safeTrim(entry.itemName) or "Item"),
                        safeTrim(entry.metaText) ~= "" and safeTrim(entry.metaText) or "Loot helper"))
                    styleBodyText(line, true)
                end

                self.historyContent:SetHeight(math.max(40, maxShown * 18))
            end
        end

        self.bisLootHelperFrame = frame
    end

    frame:SetSize(580, 646)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "GH3 Loot Helper")

    local settings = service:GetLootHelperSettings()
    frame.modeButton:SetScript("OnClick", function(buttonSelf)
        local currentSettings = service:GetLootHelperSettings()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(service:GetLootHelperModeOptions()) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = currentSettings.mode == option.id,
                    onClick = function(value)
                        service:UpdateLootHelperSettings({ mode = value })
                        frame:RefreshState()
                    end,
                }
            end

            return menuOptions
        end)
    end)

    frame.detectionButton:SetScript("OnClick", function(buttonSelf)
        local currentSettings = service:GetLootHelperSettings()

        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(service:GetLootHelperDetectionOptions()) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = currentSettings.detectionMode == option.id,
                    onClick = function(value)
                        service:UpdateLootHelperSettings({ detectionMode = value })
                        frame:RefreshState()
                    end,
                }
            end

            return menuOptions
        end)
    end)

    frame:RefreshState()
    frame:Raise()
    frame:Show()
end

function addon.MainFrame:ShowBISGearSelectorDialog(titleText, toonId)
    local service = addon.BISService
    local frame = self.bisGearSelectorFrame
    local newUIAssets = addon.NewUIAssets or {}
    local newUITextures = newUIAssets.Textures or {}
    local newUIMetrics = newUIAssets.Metrics or {}
    local gearRowVisuals = {
        ["below-champion"] = { textColor = { 0.90, 0.90, 0.92, 1 } },
        ["champion-bis"] = { textColor = { 0.88, 0.93, 1, 1 } },
        ["empty"] = { textColor = { 0.90, 0.84, 0.72, 1 } },
        ["heroic-bis"] = { textColor = { 0.91, 0.90, 1, 1 } },
        ["missing"] = { textColor = { 0.98, 0.94, 0.78, 1 } },
        ["mythic-bis"] = { textColor = { 0.97, 0.90, 1, 1 } },
        ["optional"] = { textColor = { 0.90, 0.98, 0.90, 1 } },
    }

    local function buildBlankRows()
        local rows = {}

        for _, slot in ipairs(service:GetSlotOptions()) do
            rows[#rows + 1] = {
                itemId = "",
                itemName = "",
                notes = "",
                priority = "bis",
                slotKey = slot.key,
                slotLabel = slot.label,
                sourceName = "",
                sourceTarget = "",
                sourceType = "all",
            }
        end

        return rows
    end

    local function applyGearTexture(texture, textureKey, fallbackColor)
        local texturePath = newUITextures[textureKey]
        local metrics = newUIMetrics[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.08, 0.09, 0.12, 0.96 }))
    end

    local function skinGearSurface(target, textureKey, fallbackColor)
        if not target then
            return nil
        end

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

        local background = target._gh3GearBackground or target:CreateTexture(nil, "BACKGROUND")
        target._gh3GearBackground = background
        background:SetAllPoints(target)
        applyGearTexture(background, textureKey, fallbackColor)
        return background
    end

    local function skinGearButton(button, textureKey)
        if not button then
            return
        end

        local background = skinGearSurface(button, textureKey or "MyToonsButtonBackground", { 0.10, 0.11, 0.14, 0.96 })
        if background then
            background:SetDrawLayer("BACKGROUND", 0)
        end

        if button.GetFontString and button:GetFontString() then
            button:GetFontString():SetTextColor(0.96, 0.82, 0.46, 1)
        end
    end

    local function skinGearInput(input)
        if not input then
            return
        end

        if input.Left then input.Left:SetAlpha(0) end
        if input.Middle then input.Middle:SetAlpha(0) end
        if input.Right then input.Right:SetAlpha(0) end

        local chrome = input._gh3GearChrome or CreateFrame("Frame", nil, input:GetParent(), "BackdropTemplate")
        input._gh3GearChrome = chrome
        chrome:SetFrameLevel(math.max(input:GetFrameLevel() - 1, 0))
        chrome:SetPoint("TOPLEFT", input, "TOPLEFT", -3, 2)
        chrome:SetPoint("BOTTOMRIGHT", input, "BOTTOMRIGHT", 3, -2)
        chrome:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        chrome:SetBackdropColor(0.07, 0.08, 0.11, 0.96)
        chrome:SetBackdropBorderColor(0.46, 0.36, 0.16, 0.86)
        styleInputText(input)
    end

    local function applyGearTextColor(target, color)
        if not target or type(color) ~= "table" then
            return
        end

        if target.SetTextColor then
            target:SetTextColor(color[1], color[2], color[3], color[4] or 1)
        end
    end

    local function getGearRowTextColor(row)
        local visual = gearRowVisuals[type(row) == "table" and row.rowVisualState or nil] or gearRowVisuals.missing
        return visual and visual.textColor or { 0.96, 0.82, 0.46, 1 }
    end

    if not frame then
        frame = CreateFrame("Frame", "GH3BISGearSelectorFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(205)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", function(dialog)
            dialog:StopMovingOrSizing()
        end)
        skinGearSurface(frame, "MyToonsPanelBackground", { 0.02, 0.03, 0.05, 0.985 })
        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.62, 0.48, 0.18, 0.85)

        local headerBand = frame:CreateTexture(nil, "ARTWORK")
        headerBand:SetPoint("TOPLEFT", frame, "TOPLEFT", 40, -16)
        headerBand:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -40, -16)
        headerBand:SetHeight(30)
        applyGearTexture(headerBand, "HomePanelHeader", { 0.08, 0.08, 0.10, 0.82 })
        frame.headerBand = headerBand

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("CENTER", headerBand, "CENTER", 0, -1)
        title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
        title:SetTextColor(0.93, 0.84, 0.63, 1)
        frame.title = title

        local helper = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helper:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helper:SetWidth(1088)
        helper:SetJustifyH("LEFT")
        helper:SetText("Pick journal items by slot, add alternates with +, or paste a Blizzard item ID or Wowhead URL. For PvP gear, set Source to PvP and use the item ID or Wowhead URL field because Blizzard does not expose PvP vendor stock through Encounter Journal.")
        styleBodyText(helper, true)
        frame.helperText = helper

        frame.closeButton = createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -10, -10, function()
            frame:Hide()
        end)
        skinGearButton(frame.closeButton, "MyToonsButtonBackground")

        frame.specDropdown = createMenuButton(frame, "Spec", 188, 24, "TOPLEFT", frame, "TOPLEFT", 16, -72)
        skinGearButton(frame.specDropdown, "MyToonsButtonBackground")

        local specLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        specLabel:SetPoint("BOTTOMLEFT", frame.specDropdown, "TOPLEFT", 0, 4)
        specLabel:SetText("Spec")
        styleBodyText(specLabel, true)
        frame.specLabel = specLabel

        frame.lootSpecDropdown = createMenuButton(frame, "Loot Spec", 188, 24, "TOPLEFT", frame, "TOPLEFT", 224, -72)
        skinGearButton(frame.lootSpecDropdown, "MyToonsButtonBackground")

        local lootSpecLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        lootSpecLabel:SetPoint("BOTTOMLEFT", frame.lootSpecDropdown, "TOPLEFT", 0, 4)
        lootSpecLabel:SetText("Loot Spec")
        styleBodyText(lootSpecLabel, true)
        frame.lootSpecLabel = lootSpecLabel

        frame.clearButton = createButton(frame, "Clear All", 82, 24, "TOPRIGHT", frame, "TOPRIGHT", -146, -72, function()
            frame.rowsData = buildBlankRows()
            frame:RenderRows()
        end)
        skinGearButton(frame.clearButton, "MyToonsButtonBackground")

        frame.debugButton = createButton(frame, "Debug", 62, 24, "LEFT", frame.clearButton, "RIGHT", 8, 0, function()
            addon.MainFrame:ShowCsvExportDialog("BIS Journal Debug", service:BuildSelectedEntryJournalDebugReport(frame.toonId))
        end)
        skinGearButton(frame.debugButton, "MyToonsButtonBackground")

        local header = CreateFrame("Frame", nil, frame)
        header:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -110)
        header:SetSize(1090, 18)
        frame.header = header
        skinGearSurface(header, "HomePanelHeader", { 0.08, 0.08, 0.10, 0.72 })

        local headerLabels = {
            { text = "Slot", x = 0, width = 60 },
            { text = "Journal Item", x = 68, width = 240 },
            { text = "ItemID / URL", x = 316, width = 130 },
            { text = "Source", x = 454, width = 88 },
            { text = "Instance", x = 550, width = 150 },
            { text = "Boss", x = 708, width = 140 },
            { text = "Notes", x = 856, width = 180 },
        }

        for _, info in ipairs(headerLabels) do
            local label = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            label:SetPoint("TOPLEFT", header, "TOPLEFT", info.x, 0)
            label:SetWidth(info.width)
            label:SetJustifyH("LEFT")
            label:SetText(info.text)
            styleBodyText(label, true)
        end

        local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -130)
        scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
        frame.scrollFrame = scrollFrame

        local content = CreateFrame("Frame", nil, scrollFrame)
        content:SetSize(1088, 100)
        scrollFrame:SetScrollChild(content)
        frame.scrollContent = content

        function frame:ApplyPreview(row, options)
            local resolvedOptions = type(options) == "table" and options or {}
            local journalSpecId = (type(self.selectedLootSpecId) == "string" and self.selectedLootSpecId ~= "") and self.selectedLootSpecId or self.selectedSpecId
            local existingItemId = row.itemId
            local existingItemName = row.itemName
            local existingSourceType = row.sourceType
            local existingSourceName = row.sourceName
            local existingSourceTarget = row.sourceTarget
            local previewPayload = {
                itemId = row.itemId,
                itemName = row.itemName,
                notes = row.notes,
                priority = row.priority,
                sourceName = row.sourceName,
                sourceTarget = row.sourceTarget,
                sourceType = row.sourceType,
                __options = {
                    currentSeasonOnly = true,
                    forceSourceAutofill = resolvedOptions.forceSourceAutofill and true or false,
                },
            }
            local preview = service:BuildGearEntryPreview(self.toonId, journalSpecId, row.slotKey, previewPayload)

            if (tonumber(preview.itemId) or 0) > 0 then
                row.itemId = tostring(preview.itemId)
            else
                row.itemId = existingItemId
            end

            if type(preview.itemName) == "string" and preview.itemName ~= "" then
                row.itemName = preview.itemName
            else
                row.itemName = existingItemName
            end

            if resolvedOptions.forceSourceAutofill then
                row.sourceType = (type(preview.sourceType) == "string" and preview.sourceType ~= "" and preview.sourceType ~= "all") and preview.sourceType or "other"
            else
                if type(preview.sourceType) == "string" and preview.sourceType ~= "" and preview.sourceType ~= "all" then
                    row.sourceType = preview.sourceType
                else
                    row.sourceType = existingSourceType
                end
            end

            if resolvedOptions.forceSourceAutofill then
                row.sourceName = type(preview.sourceName) == "string" and preview.sourceName or ""
            else
                if type(preview.sourceName) == "string" and preview.sourceName ~= "" then
                    row.sourceName = preview.sourceName
                else
                    row.sourceName = existingSourceName
                end
            end

            if resolvedOptions.forceSourceAutofill then
                row.sourceTarget = type(preview.sourceTarget) == "string" and preview.sourceTarget or ""
            else
                if type(preview.sourceTarget) == "string" and preview.sourceTarget ~= "" then
                    row.sourceTarget = preview.sourceTarget
                else
                    row.sourceTarget = existingSourceTarget
                end
            end
        end

        function frame:EnsureSlotHasRow(slotKey)
            for _, row in ipairs(self.rowsData or {}) do
                if row.slotKey == slotKey then
                    return
                end
            end

            self.rowsData[#self.rowsData + 1] = {
                itemId = "",
                itemName = "",
                notes = "",
                priority = "bis",
                slotKey = slotKey,
                slotLabel = service:GetSlotOptions()[1] and slotKey or slotKey,
                sourceName = "",
                sourceTarget = "",
                sourceType = "all",
            }
        end

        function frame:RenderRows()
            clearChildren(self.scrollContent)

            local rows = self.rowsData or {}
            local rowHeight = 34
            self.scrollContent:SetSize(1088, math.max(120, (#rows * rowHeight) + 8))

            for index, row in ipairs(rows) do
                local y = -((index - 1) * rowHeight)
                local rowTextColor = getGearRowTextColor(row)
                local rowFrame = CreateFrame("Frame", nil, self.scrollContent, "BackdropTemplate")
                rowFrame:SetPoint("TOPLEFT", self.scrollContent, "TOPLEFT", 0, y)
                rowFrame:SetSize(1068, 30)
                skinGearSurface(rowFrame, "MyToonsRowBackground", { 0.08, 0.09, 0.12, 0.94 })
                if rowFrame.SetBackdrop then
                    rowFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
                    rowFrame:SetBackdropColor(0, 0, 0, 0)
                    rowFrame:SetBackdropBorderColor(0.40, 0.30, 0.12, index % 2 == 0 and 0.18 or 0.10)
                end

                local slotText = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                slotText:SetPoint("LEFT", rowFrame, "LEFT", 0, 0)
                slotText:SetWidth(60)
                slotText:SetJustifyH("LEFT")
                slotText:SetText(row.slotLabel or row.slotKey or "Slot")
                styleBodyText(slotText, true)
                applyGearTextColor(slotText, rowTextColor)

                local itemButton = createButton(rowFrame, row.itemName ~= "" and cropText(row.itemName, 28) or "Select Item", 252, 22, "TOPLEFT", rowFrame, "TOPLEFT", 50, -1, function()
                    addon.MainFrame:ShowBISJournalItemPickerDialog(string.format("%s Items", row.slotLabel or "BIS"), {
                        currentSeasonOnly = true,
                        slotKey = row.slotKey,
                        sourceType = row.sourceType,
                        specId = (type(self.selectedLootSpecId) == "string" and self.selectedLootSpecId ~= "") and self.selectedLootSpecId or self.selectedSpecId,
                        toonId = self.toonId,
                    }, function(option)
                        row.itemId = (tonumber(option.itemId) or 0) > 0 and tostring(option.itemId) or ""
                        row.itemName = option.itemName or ""
                        row.sourceType = option.sourceType or row.sourceType
                        row.sourceName = option.sourceName or ""
                        row.sourceTarget = option.sourceTarget or ""
                        self:RenderRows()
                    end)
                end)
                skinGearButton(itemButton, "MyToonsButtonBackground")
                if itemButton.GetFontString then
                    applyGearTextColor(itemButton:GetFontString(), rowTextColor)
                end

                local itemIdInput = createInput(rowFrame, 130, 22, "TOPLEFT", rowFrame, "TOPLEFT", 316, -1)
                skinGearInput(itemIdInput)
                itemIdInput:SetText(row.itemId or "")
                applyGearTextColor(itemIdInput, rowTextColor)
                itemIdInput:SetNumeric(false)
                itemIdInput:SetScript("OnEditFocusGained", function(edit)
                    edit._gh3OriginalText = edit:GetText()
                end)
                itemIdInput:SetScript("OnTextChanged", function(edit)
                    row.itemId = edit:GetText()
                end)
                itemIdInput:SetScript("OnEditFocusLost", function(edit)
                    local originalText = tostring(edit._gh3OriginalText or "")
                    local updatedText = tostring(edit:GetText() or "")

                    row.itemId = edit:GetText()
                    self:ApplyPreview(row, {
                        forceSourceAutofill = safeTrim(originalText) ~= safeTrim(updatedText),
                    })
                    self:RenderRows()
                    edit._gh3OriginalText = updatedText
                end)

                local sourceButton = createMenuButton(rowFrame, service:GetSourceTypeLabel(row.sourceType), 104, 22, "TOPLEFT", rowFrame, "TOPLEFT", 444, -1)
                skinGearButton(sourceButton, "MyToonsButtonBackground")
                if sourceButton.GetFontString then
                    applyGearTextColor(sourceButton:GetFontString(), rowTextColor)
                end
                sourceButton:SetScript("OnClick", function(buttonSelf)
                    showMenuButton(buttonSelf, function()
                        local options = {}

                        for _, option in ipairs(service:GetSourceTypeOptions()) do
                            if option.id ~= "all" then
                                options[#options + 1] = {
                                    text = option.label,
                                    value = option.id,
                                    checked = row.sourceType == option.id,
                                    onClick = function(value)
                                        row.sourceType = value
                                        self:RenderRows()
                                    end,
                                }
                            end
                        end

                        return options
                    end)
                end)

                local sourceNameInput = createInput(rowFrame, 150, 22, "TOPLEFT", rowFrame, "TOPLEFT", 550, -1)
                skinGearInput(sourceNameInput)
                sourceNameInput:SetText(row.sourceName or "")
                applyGearTextColor(sourceNameInput, rowTextColor)
                sourceNameInput:SetScript("OnTextChanged", function(edit)
                    row.sourceName = edit:GetText()
                end)

                local sourceTargetInput = createInput(rowFrame, 140, 22, "TOPLEFT", rowFrame, "TOPLEFT", 708, -1)
                skinGearInput(sourceTargetInput)
                sourceTargetInput:SetText(row.sourceTarget or "")
                applyGearTextColor(sourceTargetInput, rowTextColor)
                sourceTargetInput:SetScript("OnTextChanged", function(edit)
                    row.sourceTarget = edit:GetText()
                end)

                local notesInput = createInput(rowFrame, 180, 22, "TOPLEFT", rowFrame, "TOPLEFT", 856, -1)
                skinGearInput(notesInput)
                notesInput:SetText(row.notes or "")
                applyGearTextColor(notesInput, rowTextColor)
                notesInput:SetScript("OnTextChanged", function(edit)
                    row.notes = edit:GetText()
                end)

                local addButton = createButton(rowFrame, "+", 22, 22, "TOPLEFT", rowFrame, "TOPLEFT", 1042, -1, function()
                    table.insert(self.rowsData, index + 1, {
                        itemId = "",
                        itemName = "",
                        notes = "",
                        priority = row.priority or "bis",
                        slotKey = row.slotKey,
                        slotLabel = row.slotLabel,
                        sourceName = "",
                        sourceTarget = "",
                        sourceType = row.sourceType ~= "" and row.sourceType or "all",
                    })
                    self:RenderRows()
                end)
                skinGearButton(addButton, "MyToonsButtonBackground")

                local removeButton = createButton(rowFrame, "-", 22, 22, "TOPLEFT", rowFrame, "TOPLEFT", 1066, -1, function()
                    table.remove(self.rowsData, index)

                    local hasSameSlot = false
                    for _, candidate in ipairs(self.rowsData) do
                        if candidate.slotKey == row.slotKey then
                            hasSameSlot = true
                            break
                        end
                    end

                    if not hasSameSlot then
                        self.rowsData[#self.rowsData + 1] = {
                            itemId = "",
                            itemName = "",
                            notes = "",
                            priority = row.priority or "bis",
                            slotKey = row.slotKey,
                            slotLabel = row.slotLabel,
                            sourceName = "",
                            sourceTarget = "",
                            sourceType = row.sourceType ~= "" and row.sourceType or "all",
                        }
                    end

                    self:RenderRows()
                end)
                skinGearButton(removeButton, "MyToonsButtonBackground")
            end
        end

        frame.saveButton = createButton(frame, "Save", 120, 24, "BOTTOMRIGHT", frame, "BOTTOM", -8, 14, function()
            service:SetSelectedSpecId(frame.toonId, frame.selectedSpecId)
            service:SetLootSpecId(frame.toonId, frame.selectedLootSpecId)
            local _, savedCount = service:SaveGearEditorRows(frame.toonId, frame.selectedSpecId, frame.rowsData)
            addon:Print(string.format("Saved %d BIS target%s.", savedCount, savedCount == 1 and "" or "s"))
            frame:Hide()
            addon.MainFrame:RefreshCurrentView(true)
        end)
        skinGearButton(frame.saveButton, "MyToonsButtonSelected")

        frame.cancelButton = createButton(frame, "Cancel", 120, 24, "BOTTOMLEFT", frame, "BOTTOM", 8, 14, function()
            frame:Hide()
        end)
        skinGearButton(frame.cancelButton, "MyToonsButtonBackground")

        self.bisGearSelectorFrame = frame
    end

    frame.toonId = toonId
    frame.selectedSpecId = service:GetSelectedSpecId(toonId)
    frame.selectedLootSpecId = service:GetLootSpecId(toonId)
    frame.rowsData = service:ListGearEditorRows(toonId, frame.selectedSpecId)
    frame:SetSize(1130, 640)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    frame.title:SetText(titleText or "BIS Gear Select")

    if frame.helperText then
        frame.helperText:Hide()
    end
    if frame.specDropdown then
        frame.specDropdown:Hide()
    end
    if frame.specLabel then
        frame.specLabel:Hide()
    end
    if frame.lootSpecDropdown then
        frame.lootSpecDropdown:Hide()
    end
    if frame.lootSpecLabel then
        frame.lootSpecLabel:Hide()
    end
    if frame.clearButton then
        frame.clearButton:Hide()
    end
    if frame.debugButton then
        frame.debugButton:Hide()
    end
    if frame.header then
        frame.header:ClearAllPoints()
        frame.header:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -58)
    end
    if frame.scrollFrame then
        frame.scrollFrame:ClearAllPoints()
        frame.scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -78)
        frame.scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 52)
    end

    local specOptions = service:ListSpecOptions(toonId)
    frame.specDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local options = {}

            for _, option in ipairs(specOptions) do
                options[#options + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = frame.selectedSpecId == option.id,
                    onClick = function(value)
                        frame.selectedSpecId = value
                        service:SetSelectedSpecId(frame.toonId, value)
                        frame.rowsData = service:ListGearEditorRows(frame.toonId, frame.selectedSpecId)
                        setMenuButtonText(frame.specDropdown, option.label)
                        frame:RenderRows()
                    end,
                }
            end

            return options
        end)
    end)

    frame.lootSpecDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local options = {}

            for _, option in ipairs(specOptions) do
                options[#options + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = frame.selectedLootSpecId == option.id,
                    onClick = function(value)
                        frame.selectedLootSpecId = value
                        setMenuButtonText(frame.lootSpecDropdown, option.label)
                    end,
                }
            end

            return options
        end)
    end)

    do
        local selectedSpecLabel = frame.selectedSpecId or "Spec"
        local selectedLootSpecLabel = frame.selectedLootSpecId or "Loot Spec"

        for _, option in ipairs(specOptions) do
            if option.id == frame.selectedSpecId then
                selectedSpecLabel = option.label
            end

            if option.id == frame.selectedLootSpecId then
                selectedLootSpecLabel = option.label
            end
        end

        setMenuButtonText(frame.specDropdown, selectedSpecLabel)
        setMenuButtonText(frame.lootSpecDropdown, selectedLootSpecLabel)
    end

    frame:RenderRows()
    frame:Raise()
    frame:Show()
end

local function joinList(values)
    if not values or #values == 0 then
        return ""
    end

    return table.concat(values, ", ")
end

local function formatDropdownOption(value)
    local normalized = string.lower(tostring(value or ""))
    local labels = {
        dps = "DPS",
        pvp = "PvP",
        ["mythic+"] = "Mythic+",
    }

    if labels[normalized] then
        return labels[normalized]
    end

    return normalized:gsub("^%l", string.upper)
end

local function initializeSingleSelectDropdown(dropdown, options, selectedValue, formatValue)
    local formatter = formatValue or tostring
    dropdown.selectedValue = selectedValue

    UIDropDownMenu_Initialize(dropdown, function(self, level)
        for _, option in ipairs(options or {}) do
            local info = UIDropDownMenu_CreateInfo()
            info.text = formatter(option)
            info.value = option
            info.func = function(buttonSelf)
                dropdown.selectedValue = buttonSelf.value
                setDropdownText(dropdown, formatter(buttonSelf.value))
                CloseDropDownMenus()
            end
            info.checked = dropdown.selectedValue == option
            UIDropDownMenu_AddButton(info, level)
        end
    end)

    setDropdownText(dropdown, dropdown.selectedValue and formatter(dropdown.selectedValue) or nil)
end

local function initializeMultiSelectDropdown(dropdown, options, selectedValues, formatValue)
    local formatter = formatValue or tostring
    dropdown.selectedValues = {}

    for _, value in ipairs(selectedValues or {}) do
        dropdown.selectedValues[#dropdown.selectedValues + 1] = value
    end

    UIDropDownMenu_Initialize(dropdown, function(self, level)
        for _, option in ipairs(options or {}) do
            local info = UIDropDownMenu_CreateInfo()
            info.text = formatter(option)
            info.value = option
            info.isNotRadio = true
            info.keepShownOnClick = true
            info.checked = arrayContains(dropdown.selectedValues, option)
            info.func = function(buttonSelf)
                if arrayContains(dropdown.selectedValues, buttonSelf.value) then
                    removeArrayValue(dropdown.selectedValues, buttonSelf.value)
                else
                    dropdown.selectedValues[#dropdown.selectedValues + 1] = buttonSelf.value
                end

                local labels = {}

                for index, value in ipairs(dropdown.selectedValues) do
                    labels[index] = formatter(value)
                end

                setDropdownText(dropdown, joinList(labels))
            end
            UIDropDownMenu_AddButton(info, level)
        end
    end)

    local labels = {}

    for index, value in ipairs(dropdown.selectedValues) do
        labels[index] = formatter(value)
    end

    setDropdownText(dropdown, joinList(labels))
end

local function createMultilineInput(parent, width, height, point, relativeTo, relativePoint, x, y)
    local container = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    container:SetSize(width, height)
    container:SetPoint(point, relativeTo, relativePoint, x, y)
    applySurfaceStyle(container, THEME.input)
    container:EnableMouse(true)
    container._gh3DefaultBorder = { unpack(THEME.input.border) }
    container._gh3FocusBorder = { 0.9, 0.76, 0.28, 1 }

    local scrollFrame = CreateFrame("ScrollFrame", nil, container, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", container, "TOPLEFT", 10, -10)
    scrollFrame:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -30, 10)
    scrollFrame:EnableMouseWheel(true)
    scrollFrame:EnableMouse(true)

    local input = CreateFrame("EditBox", nil, scrollFrame)
    input:SetMultiLine(true)
    input:SetAutoFocus(false)
    input:SetFontObject("ChatFontNormal")
    input:SetPoint("TOPLEFT", scrollFrame, "TOPLEFT", 0, 0)
    input:SetWidth(math.max(width - 50, 20))
    input:SetHeight(math.max(height - 24, 40))
    input:SetJustifyH("LEFT")
    input:SetJustifyV("TOP")
    input:SetMaxLetters(0)
    input:EnableMouse(true)
    styleInputText(input)

    local measureText = container:CreateFontString(nil, "ARTWORK", "ChatFontNormal")
    measureText:SetPoint("TOPLEFT", container, "TOPLEFT", -10000, -10000)
    measureText:SetWidth(math.max(width - 50, 20))
    measureText:SetJustifyH("LEFT")
    measureText:SetJustifyV("TOP")
    measureText:SetTextColor(0, 0, 0, 0)

    if input.SetTextInsets then
        input:SetTextInsets(0, 0, 2, 2)
    end

    local function focusInput()
        input:SetFocus()
    end

    local function setFocusedVisualState(isFocused)
        local border = isFocused and container._gh3FocusBorder or container._gh3DefaultBorder

        if border and container.SetBackdropBorderColor then
            container:SetBackdropBorderColor(unpack(border))
        end
    end

    container:SetScript("OnMouseDown", focusInput)
    scrollFrame:SetScript("OnMouseDown", focusInput)
    input:SetScript("OnMouseDown", focusInput)

    input:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)

    input:SetScript("OnEditFocusGained", function(self)
        setFocusedVisualState(true)

        if self:GetCursorPosition() == 0 and tostring(self:GetText() or "") ~= "" then
            self:SetCursorPosition(#tostring(self:GetText() or ""))
        end
    end)

    input:SetScript("OnEditFocusLost", function()
        setFocusedVisualState(false)
    end)

    input:SetScript("OnCursorChanged", function(self, _, y, _, cursorHeight)
        local cursorTop = math.max(tonumber(y) or 0, 0)
        local cursorBottom = cursorTop + math.max(tonumber(cursorHeight) or 0, 0)
        local scrollTop = scrollFrame:GetVerticalScroll() or 0
        local scrollBottom = scrollTop + (scrollFrame:GetHeight() or 0)

        if cursorBottom > scrollBottom then
            scrollFrame:SetVerticalScroll(cursorBottom - (scrollFrame:GetHeight() or 0))
        elseif cursorTop < scrollTop then
            scrollFrame:SetVerticalScroll(cursorTop)
        end
    end)

    input:SetScript("OnTextChanged", function(self)
        local text = tostring(self:GetText() or "")
        local explicitLineCount = 1

        for _ in text:gmatch("\n") do
            explicitLineCount = explicitLineCount + 1
        end

        local _, fontHeight = self:GetFont()
        local lineHeight = math.max(tonumber(fontHeight) or 14, 14)
        local fontPath, resolvedFontHeight, fontFlags = self:GetFont()

        if fontPath and measureText.SetFont then
            measureText:SetFont(fontPath, resolvedFontHeight or fontHeight or 14, fontFlags or "")
        end

        measureText:SetText(text ~= "" and text or " ")
        local measuredHeight = math.max(tonumber(measureText:GetStringHeight()) or 0, lineHeight)
        local desiredHeight = math.max(math.ceil(measuredHeight) + 12, height - 24, 40)

        self:SetHeight(desiredHeight)

        if scrollFrame.UpdateScrollChildRect then
            scrollFrame:UpdateScrollChildRect()
        end
    end)

    scrollFrame:SetScript("OnMouseWheel", function(self, delta)
        local current = self:GetVerticalScroll() or 0
        local range = self:GetVerticalScrollRange() or 0
        local nextValue = math.max(0, math.min(range, current - (delta * 20)))
        self:SetVerticalScroll(nextValue)
    end)

    scrollFrame:SetScrollChild(input)
    input:Show()
    input:SetScript("OnShow", input:GetScript("OnTextChanged"))
    input:GetScript("OnTextChanged")(input)
    setFocusedVisualState(false)

    return input, container
end

local function createReadOnlyMultiline(parent, width, height, point, relativeTo, relativePoint, x, y, text)
    local container = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    container:SetSize(width, height)
    container:SetPoint(point, relativeTo, relativePoint, x, y)
    applySurfaceStyle(container, THEME.input)

    local scrollFrame = CreateFrame("ScrollFrame", nil, container, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", container, "TOPLEFT", 8, -8)
    scrollFrame:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -30, 8)

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetWidth(width - 40)
    scrollFrame:SetScrollChild(content)

    local bodyText = content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    bodyText:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0)
    bodyText:SetWidth(width - 40)
    bodyText:SetJustifyH("LEFT")
    bodyText:SetJustifyV("TOP")
    bodyText:SetText(text or "")
    styleBodyText(bodyText)

    content:SetHeight(math.max(bodyText:GetStringHeight() + 8, height - 20))
    return bodyText, container
end

local function getCleanupKickScopeLabel(scope)
    if scope == "mains" then
        return "Mains"
    end

    if scope == "alts" then
        return "Alts"
    end

    return "All"
end

local function getCleanupLinkedAltLabel(record)
    if type(record) ~= "table" then
        return nil
    end

    local linkedMemberId = safeTrim(record.includedViaSearch or record.includedViaMain)

    if linkedMemberId == "" then
        return nil
    end

    local shortName = addon.Store:SplitFullName(linkedMemberId, GetRealmName() or "Unknown") or linkedMemberId

    if record.manualKick then
        return string.format("Family Of %s", shortName)
    end

    return string.format("Linked Alt Of %s", shortName)
end

local function getPromotionRankButtonText(prefix, rankName)
    local value = tostring(rankName or "")

    if value == "" then
        value = "-"
    end

    return string.format("%s: %s", prefix, value)
end

local function getPromotionTenureOptionLabel(days)
    local value = tonumber(days) or 0

    if value <= 0 then
        return "Any"
    end

    if value == 365 then
        return "1 Year"
    end

    if value == 730 then
        return "2 Years"
    end

    return string.format("%d Days", value)
end

local function getPromotionTenureButtonText(days)
    return "Tenure: " .. getPromotionTenureOptionLabel(days)
end

function addon.MainFrame:ShowGuildCleanupDialog(initialMode)
    if InCombatLockdown and InCombatLockdown() then
        addon:Print("GH3 Helper popup is unavailable during combat.")
        return
    end

    local service = addon.MaintenanceService

    if not (service and service.CanGuildRemoveMembers and service:CanGuildRemoveMembers()) then
        addon:Print("GH3 Helper is only available when you can remove guild members.")
        return
    end

    local frame = self.guildCleanupFrame

    local function attachTooltip(widget, titleText, bodyText)
        if not widget then
            return
        end

        widget:SetScript("OnEnter", function(control)
            GameTooltip:SetOwner(control, "ANCHOR_RIGHT")
            GameTooltip:SetText(titleText or "GH3", 0.93, 0.84, 0.63, 1, true)

            if tostring(bodyText or "") ~= "" then
                GameTooltip:AddLine(tostring(bodyText), 0.82, 0.82, 0.82, true)
            end

            GameTooltip:Show()
        end)
        widget:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
    end

    if not frame then
        frame = CreateFrame("Frame", "GH3GuildCleanupFrame", UIParent, "BackdropTemplate")
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(210)
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
        frame:SetSize(760, 664)
        frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
        applySurfaceStyle(frame, THEME.window)
        frame.queuePageSize = 5
        frame.queuePages = {
            kick = 1,
            promote = 1,
        }

        local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
        title:SetText("GH3 Helper")
        styleTitleText(title, 18)
        frame.title = title

        local helperText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helperText:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -40)
        helperText:SetWidth(420)
        helperText:SetJustifyH("LEFT")
        helperText:SetText("Kick and promotion batches for the current guild roster. Macros remain previews until you explicitly save or run them.")
        styleBodyText(helperText, true)
        frame.helperText = helperText

        frame.closeButton = createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
            frame:HideAfterCombatSafe()
        end)

        frame.tabRow = CreateFrame("Frame", nil, frame)
        frame.tabRow:SetSize(232, 24)
        frame.tabRow:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -16, -38)

        frame.kickTabButton = createButton(frame.tabRow, "Kick", 108, 24, "TOPLEFT", frame.tabRow, "TOPLEFT", 0, 0, function()
            frame.mode = "kick"
            frame.queuePages.kick = tonumber(frame.queuePages.kick) or 1
            frame:RefreshData()
        end)

        frame.promoteTabButton = createButton(frame.tabRow, "Promote", 108, 24, "TOPLEFT", frame.tabRow, "TOPLEFT", 116, 0, function()
            frame.mode = "promote"
            frame.queuePages.promote = tonumber(frame.queuePages.promote) or 1
            frame:RefreshData()
        end)

        frame.scanButton = createButton(frame, "Run Scan", 90, 24, "TOPLEFT", frame, "TOPLEFT", 16, -72, function()
            frame:ResetKickSearchState(true)
            service:ScanRoster()
            frame:RefreshData()
        end)

        frame.buildMacroButton = createButton(frame, "Build Macro", 104, 24, "LEFT", frame.scanButton, "RIGHT", 8, 0, function()
            if frame.mode == "promote" then
                local memberIds = service:GetEffectivePromotionMacroMemberIds()
                frame.macroPayload = service:BuildPromotionMacro(memberIds)
            else
                local memberIds = service:GetEffectiveCleanupMacroMemberIds()
                frame.macroPayload = service:BuildCleanupMacro(memberIds)
            end
            frame:RefreshData(true)
        end)
        attachTooltip(frame.buildMacroButton, "Build Helper Macro", "Build a /gremove or /gpromote macro preview from the current GH3 Helper queue without saving it yet.")

        frame.runBatchButton = createButton(frame, "Batch Run", 104, 24, "LEFT", frame.buildMacroButton, "RIGHT", 8, 0, function()
            if frame and frame.ShowBatchRunDialog then
                frame:ShowBatchRunDialog()
            end
        end)
        attachTooltip(frame.runBatchButton, "Batch Run", "Open a confirmation popup that shows the exact macro GH3 Helper will run for the current batch.")

        local filterPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        filterPanel:SetSize(728, 92)
        filterPanel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -104)
        applySurfaceStyle(filterPanel, THEME.panel)
        frame.filterPanel = filterPanel

        frame.selectAllButton = createButton(frame, "Select All", 96, 22, "TOPRIGHT", frame, "TOPRIGHT", -124, -194, function()
            if frame.mode == "promote" then
                service:SelectAllVisiblePromotionMembers()
            else
                service:SelectAllVisibleCleanupMembers()
            end

            frame.macroPayload = nil
            frame:RefreshData(false)
        end)

        frame.clearSelectionButton = createButton(frame, "Select None", 96, 22, "TOPRIGHT", frame, "TOPRIGHT", -20, -194, function()
            service:ClearSelectedMembers(frame.mode)
            frame.macroPayload = nil
            frame:RefreshData(false)
        end)

        local inactiveDaysLabel = filterPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        inactiveDaysLabel:SetPoint("TOPLEFT", filterPanel, "TOPLEFT", 12, -10)
        inactiveDaysLabel:SetText("Inactive Days")
        styleHighlightText(inactiveDaysLabel)
        frame.inactiveDaysLabel = inactiveDaysLabel

        frame.inactiveDaysInput = createInput(filterPanel, 52, 22, "TOPLEFT", filterPanel, "TOPLEFT", 12, -26)
        frame.inactiveDaysInput:SetNumeric(true)
        frame.inactiveDaysInput:SetMaxLetters(3)

        frame.applyFiltersButton = createButton(filterPanel, "Apply", 64, 22, "LEFT", frame.inactiveDaysInput, "RIGHT", 8, 0, function()
            frame:ResetKickSearchState(true)
            service:UpdateSettings({
                staleAfterDays = frame.inactiveDaysInput:GetText(),
            })
            frame.macroPayload = nil
            frame:RefreshData(false)
        end)

        frame.scopeButton = createMenuButton(filterPanel, "Scope: All", 108, 22, "LEFT", frame.applyFiltersButton, "RIGHT", 12, 0)
        frame.scopeButton:SetScript("OnClick", function(buttonSelf)
            local settings = service:GetSettings()
            local options = { "all", "mains", "alts" }

            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                for _, value in ipairs(options) do
                    menuOptions[#menuOptions + 1] = {
                        text = getCleanupKickScopeLabel(value),
                        value = value,
                        checked = value == settings.kickScope,
                        onClick = function(selectedValue)
                            frame:ResetKickSearchState(true)
                            service:UpdateSettings({ kickScope = selectedValue })
                            frame.macroPayload = nil
                            frame:RefreshData(false)
                        end,
                    }
                end

                return menuOptions
            end)
        end)
        attachTooltip(frame.scopeButton, "Kick Scope", "Choose whether GH3 Helper builds the kick queue from all guildies, mains only, or alts only before rebuilding the queue and macro preview.")

        frame.linkedAltCheckbox = createCheckbox(filterPanel, "Linked Alts", "LEFT", frame.scopeButton, "RIGHT", 14, 0, false, function(checked)
            frame:ResetKickSearchState(true)
            service:UpdateSettings({ kickLinkedAltsWithMain = checked })
            frame.macroPayload = nil
            frame:RefreshData(false)
        end)
        attachTooltip(frame.linkedAltCheckbox, "Linked Alt Expansion", "When enabled, GH3 Helper includes the selected toon's linked family when you queue a kick search and also expands stale queued mains to their linked alts.")

        frame.kickSearchButton = createMenuButton(filterPanel, "Queue Match", 114, 22, "TOPRIGHT", filterPanel, "TOPRIGHT", -12, -26)
        frame.kickSearchButton:SetScript("OnClick", function(buttonSelf)
            frame:ShowKickSearchMenu(buttonSelf)
        end)
        attachTooltip(frame.kickSearchButton, "Kick Search", "Type part of a toon name, then choose a current-guild match to queue it for the kick batch. Linked Alts also queues the rest of that linked family.")

        frame.kickSearchInput = createInput(filterPanel, 126, 22, "RIGHT", frame.kickSearchButton, "LEFT", -8, 0)
        frame.kickSearchInput:SetNumeric(false)
        frame.kickSearchInput:SetMaxLetters(48)
        frame.kickSearchInput:SetScript("OnEnterPressed", function(edit)
            edit:ClearFocus()
            frame:ShowKickSearchMenu(frame.kickSearchButton)
        end)
        frame.kickSearchInput:SetScript("OnEscapePressed", function(edit)
            edit:ClearFocus()
        end)

        local kickSearchLabel = filterPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        kickSearchLabel:SetPoint("RIGHT", frame.kickSearchInput, "LEFT", -8, 0)
        kickSearchLabel:SetText("Find Toon")
        styleHighlightText(kickSearchLabel)
        frame.kickSearchLabel = kickSearchLabel

        local fromRankLabel = filterPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        fromRankLabel:SetPoint("TOPLEFT", filterPanel, "TOPLEFT", 12, -10)
        fromRankLabel:SetText("Promote From")
        styleHighlightText(fromRankLabel)
        frame.fromRankLabel = fromRankLabel

        frame.fromRankButton = createMenuButton(filterPanel, "From: -", 132, 22, "TOPLEFT", filterPanel, "TOPLEFT", 12, -26)
        frame.fromRankButton:SetScript("OnClick", function(buttonSelf)
            local settings = service:GetSettings()
            local ranks = service:GetGuildRankNames()

            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                if #ranks <= 0 then
                    menuOptions[#menuOptions + 1] = {
                        text = "No guild ranks",
                        value = "",
                        disabled = true,
                        notCheckable = true,
                    }
                end

                for _, value in ipairs(ranks) do
                    menuOptions[#menuOptions + 1] = {
                        text = value,
                        value = value,
                        checked = value == settings.promoteFromRank,
                        onClick = function(selectedValue)
                            service:UpdateSettings({ promoteFromRank = selectedValue })
                            frame:RefreshData(true)
                        end,
                    }
                end

                return menuOptions
            end)
        end)

        frame.toRankButton = createMenuButton(filterPanel, "To: -", 132, 22, "LEFT", frame.fromRankButton, "RIGHT", 8, 0)
        frame.toRankButton:SetScript("OnClick", function(buttonSelf)
            local settings = service:GetSettings()
            local ranks = service:GetGuildRankNames()

            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                if #ranks <= 0 then
                    menuOptions[#menuOptions + 1] = {
                        text = "No guild ranks",
                        value = "",
                        disabled = true,
                        notCheckable = true,
                    }
                end

                for _, value in ipairs(ranks) do
                    menuOptions[#menuOptions + 1] = {
                        text = value,
                        value = value,
                        checked = value == settings.promoteToRank,
                        onClick = function(selectedValue)
                            service:UpdateSettings({ promoteToRank = selectedValue })
                            frame:RefreshData(true)
                        end,
                    }
                end

                return menuOptions
            end)
        end)
        attachTooltip(frame.fromRankButton, "Promotion Source Rank", "Choose the guild rank GH3 Helper should use as the source for promotion candidates.")
        attachTooltip(frame.toRankButton, "Promotion Target Rank", "Choose the destination guild rank. GH3 Helper repeats /gpromote lines when the target is more than one rank above the source.")

        frame.promotionTenureButton = createMenuButton(filterPanel, "Tenure: Any", 132, 22, "LEFT", frame.toRankButton, "RIGHT", 8, 0)
        frame.promotionTenureButton:SetScript("OnClick", function(buttonSelf)
            local settings = service:GetSettings()
            local options = { 0, 30, 90, 180, 365, 730 }

            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                for _, value in ipairs(options) do
                    menuOptions[#menuOptions + 1] = {
                        text = getPromotionTenureOptionLabel(value),
                        value = value,
                        checked = value == (tonumber(settings.promoteMinJoinDays) or 0),
                        onClick = function(selectedValue)
                            service:UpdateSettings({ promoteMinJoinDays = selectedValue })
                            frame:RefreshData(true)
                        end,
                    }
                end

                return menuOptions
            end)
        end)
        attachTooltip(frame.promotionTenureButton, "Promotion Tenure Filter", "Choose the minimum guild tenure required before a member appears in the batch-promotion queue.")

        local summaryLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        summaryLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -174)
        summaryLabel:SetWidth(720)
        summaryLabel:SetJustifyH("LEFT")
        styleHighlightText(summaryLabel)
        frame.summaryLabel = summaryLabel

        local queueHeader = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        queueHeader:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -198)
        queueHeader:SetText("Queue")
        styleTitleText(queueHeader, 15)

        frame.prevQueuePageButton = createButton(frame, "< Prev", 72, 22, "LEFT", queueHeader, "RIGHT", 18, 0, function()
            local mode = frame.mode == "promote" and "promote" or "kick"
            local currentPage = tonumber(frame.queuePages[mode]) or 1
            frame.queuePages[mode] = math.max(1, currentPage - 1)
            frame:RefreshData(true)
        end)

        frame.queuePageLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        frame.queuePageLabel:SetPoint("LEFT", frame.prevQueuePageButton, "RIGHT", 8, 0)
        frame.queuePageLabel:SetWidth(96)
        frame.queuePageLabel:SetJustifyH("CENTER")
        styleBodyText(frame.queuePageLabel, true)

        frame.nextQueuePageButton = createButton(frame, "Next >", 72, 22, "LEFT", frame.queuePageLabel, "RIGHT", 8, 0, function()
            local mode = frame.mode == "promote" and "promote" or "kick"
            local currentPage = tonumber(frame.queuePages[mode]) or 1
            local totalPages = tonumber(frame.queueTotalPages) or 1
            frame.queuePages[mode] = math.min(totalPages, currentPage + 1)
            frame:RefreshData(true)
        end)

        local queueContainer = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        queueContainer:SetSize(728, 176)
        queueContainer:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -222)
        applySurfaceStyle(queueContainer, THEME.input)
        frame.queueContainer = queueContainer

        local queueScroll = CreateFrame("ScrollFrame", nil, queueContainer, "UIPanelScrollFrameTemplate")
        queueScroll:SetPoint("TOPLEFT", queueContainer, "TOPLEFT", 8, -8)
        queueScroll:SetPoint("BOTTOMRIGHT", queueContainer, "BOTTOMRIGHT", -30, 8)
        frame.queueScroll = queueScroll

        local queueContent = CreateFrame("Frame", nil, queueScroll)
        queueContent:SetWidth(688)
        queueScroll:SetScrollChild(queueContent)
        frame.queueContent = queueContent

        local queueBody = queueContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        queueBody:SetPoint("TOPLEFT", queueContent, "TOPLEFT", 0, 0)
        queueBody:SetWidth(688)
        queueBody:SetJustifyH("LEFT")
        queueBody:SetJustifyV("TOP")
        styleBodyText(queueBody)
        frame.queueBody = queueBody
        frame.queueRows = {}

        local macroHeader = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        macroHeader:SetPoint("TOPLEFT", queueContainer, "BOTTOMLEFT", 0, -10)
        macroHeader:SetText("Macro Preview")
        styleTitleText(macroHeader, 15)
        frame.macroHeader = macroHeader

        local macroContainer = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        macroContainer:SetSize(728, 90)
        macroContainer:SetPoint("TOPLEFT", queueContainer, "BOTTOMLEFT", 0, -30)
        applySurfaceStyle(macroContainer, THEME.input)
        frame.macroContainer = macroContainer

        local macroScroll = CreateFrame("ScrollFrame", nil, macroContainer, "UIPanelScrollFrameTemplate")
        macroScroll:SetPoint("TOPLEFT", macroContainer, "TOPLEFT", 8, -8)
        macroScroll:SetPoint("BOTTOMRIGHT", macroContainer, "BOTTOMRIGHT", -30, 8)

        local macroContent = CreateFrame("Frame", nil, macroScroll)
        macroContent:SetWidth(688)
        macroScroll:SetScrollChild(macroContent)
        frame.macroContent = macroContent

        local macroBody = macroContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        macroBody:SetPoint("TOPLEFT", macroContent, "TOPLEFT", 0, 0)
        macroBody:SetWidth(688)
        macroBody:SetJustifyH("LEFT")
        macroBody:SetJustifyV("TOP")
        styleBodyText(macroBody)
        frame.macroBody = macroBody

        local statusText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        statusText:SetPoint("TOPLEFT", macroContainer, "BOTTOMLEFT", 0, -10)
        statusText:SetWidth(728)
        statusText:SetJustifyH("LEFT")
        styleBodyText(statusText, true)
        frame.statusText = statusText

        frame.pendingBatchRefreshAfterCombat = false
        frame.pendingBatchRunDialogHideAfterCombat = false
        frame.pendingBatchRunDialogShowAfterCombat = false
        frame.pendingCloseAfterCombat = false

        function frame:ResetKickSearchState(clearQueuedMembers)
            if clearQueuedMembers and service and service.ClearManualKickMembers then
                service:ClearManualKickMembers()
            end

            if self.kickSearchInput then
                self.kickSearchInput:SetText("")
                self.kickSearchInput:ClearFocus()
            end
        end

        function frame:HideAfterCombatSafe()
            if InCombatLockdown and InCombatLockdown() then
                self.pendingCloseAfterCombat = true
                self.pendingBatchRefreshAfterCombat = false
                self.pendingBatchRunDialogHideAfterCombat = false
                self.pendingBatchRunDialogShowAfterCombat = false
                addon:Print("GH3 Helper cannot close during combat. It will close after combat ends.")
                return false
            end

            self.pendingCloseAfterCombat = false
            self.pendingBatchRefreshAfterCombat = false
            self.pendingBatchRunDialogHideAfterCombat = false
            self.pendingBatchRunDialogShowAfterCombat = false

            if self.batchRunDialog and self.batchRunDialog:IsShown() then
                self.batchRunDialog:Hide()
            end

            self:ResetKickSearchState(true)
            self:Hide()
            return true
        end

        function frame:HideBatchRunDialogAfterCombatSafe(queueAfterCombat)
            local dialog = self.batchRunDialog

            if not dialog then
                return true
            end

            if InCombatLockdown and InCombatLockdown() then
                if queueAfterCombat then
                    self.pendingBatchRunDialogHideAfterCombat = true
                end

                return false
            end

            self.pendingBatchRunDialogHideAfterCombat = false
            dialog:Hide()
            return true
        end

        function frame:HandleBatchRunPostClick()
            if self.mode == "kick"
                and service
                and service.MarkPendingKickBatch
                and type(self.macroPayload) == "table"
                and type(self.macroPayload.includedMemberIds) == "table"
                and #self.macroPayload.includedMemberIds > 0 then
                service:MarkPendingKickBatch(self.macroPayload.includedMemberIds, "gh3-helper")
            end

            if InCombatLockdown and InCombatLockdown() then
                self.pendingBatchRunDialogHideAfterCombat = true
                self.pendingBatchRefreshAfterCombat = true

                if self.statusText then
                    self.statusText:SetText("Batch executed. GH3 Helper will refresh the roster and resume the next batch step after combat.")
                end

                return
            end

            self:HideBatchRunDialogAfterCombatSafe(true)
            self:ScheduleBatchRefresh()
        end

        function frame:ProcessDeferredCombatActions()
            if InCombatLockdown and InCombatLockdown() then
                return
            end

            if self.pendingCloseAfterCombat then
                self.pendingCloseAfterCombat = false
                self.pendingBatchRefreshAfterCombat = false
                self.pendingBatchRunDialogShowAfterCombat = false
                self.pendingBatchRunDialogHideAfterCombat = false

                if self.batchRunDialog and self.batchRunDialog:IsShown() then
                    self.batchRunDialog:Hide()
                end

                self:ResetKickSearchState(true)
                self:Hide()
                return
            end

            if self.pendingBatchRunDialogHideAfterCombat then
                self.pendingBatchRunDialogHideAfterCombat = false

                if self.batchRunDialog and self.batchRunDialog:IsShown() then
                    self.batchRunDialog:Hide()
                end
            end

            if self.pendingBatchRefreshAfterCombat then
                self.pendingBatchRefreshAfterCombat = false
                self:ScheduleBatchRefresh()
                return
            end

            if self.pendingBatchRunDialogShowAfterCombat and self:IsShown() then
                self.pendingBatchRunDialogShowAfterCombat = false
                self:ShowBatchRunDialog()
            end
        end

        function frame:ShowKickSearchMenu(anchorButton)
            local anchor = anchorButton or self.kickSearchButton

            if not anchor then
                return
            end

            local searchText = self.kickSearchInput and self.kickSearchInput:GetText() or ""
            local includeLinkedAlts = self.linkedAltCheckbox and self.linkedAltCheckbox:GetChecked() and true or false

            showMenuButton(anchor, function()
                local options = {}
                local matches = service:ListKickSearchMatches(searchText, 12)

                if safeTrim(searchText) == "" then
                    options[#options + 1] = {
                        text = "Type a toon name",
                        value = "",
                        disabled = true,
                        notCheckable = true,
                    }

                    return options
                end

                if #matches <= 0 then
                    options[#options + 1] = {
                        text = "No current-guild matches",
                        value = "",
                        disabled = true,
                        notCheckable = true,
                    }

                    return options
                end

                for _, match in ipairs(matches) do
                    local familySize = tonumber(match.familySize) or 0
                    local familySuffix = includeLinkedAlts and familySize > 1 and string.format(" | Family %d", familySize) or ""
                    local queueSuffix = match.alreadyQueued and " | queued" or ""

                    options[#options + 1] = {
                        text = string.format("%s%s%s", match.displayName or match.memberId or "Unknown", familySuffix, queueSuffix),
                        value = match.memberId,
                        notCheckable = true,
                        onClick = function(selectedValue)
                            local queuedMemberIds, errorMessage = service:QueueManualKickMember(selectedValue, includeLinkedAlts)

                            if not queuedMemberIds then
                                addon:Print(errorMessage or "No guild members matched that kick search.")
                                return
                            end

                            self.queuePages.kick = 1
                            self.macroPayload = nil
                            self:RefreshData(false)
                            addon:Print(string.format("GH3 Helper queued %d kick target(s) from search.", #queuedMemberIds))
                        end,
                    }
                end

                return options
            end)
        end

        function frame:EnsureBatchRunDialog()
            if self.batchRunDialog then
                return self.batchRunDialog
            end

            local dialog = CreateFrame("Frame", nil, self, "BackdropTemplate")
            dialog:SetFrameStrata("FULLSCREEN_DIALOG")
            dialog:SetFrameLevel(self:GetFrameLevel() + 20)
            dialog:SetSize(520, 300)
            dialog:SetPoint("CENTER", self, "CENTER", 0, 0)
            applySurfaceStyle(dialog, THEME.window)
            dialog:Hide()

            local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -14)
            title:SetText("Confirm Batch Run")
            styleTitleText(title, 16)
            dialog.title = title

            local body = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            body:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
            body:SetWidth(490)
            body:SetJustifyH("LEFT")
            body:SetText("Review the macro below, then click Run Macro to execute this batch.")
            styleBodyText(body, true)
            dialog.body = body

            local previewHost = CreateFrame("Frame", nil, dialog, "BackdropTemplate")
            previewHost:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -56)
            previewHost:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -14, 54)
            applySurfaceStyle(previewHost, THEME.input)

            local previewScroll = CreateFrame("ScrollFrame", nil, previewHost, "UIPanelScrollFrameTemplate")
            previewScroll:SetPoint("TOPLEFT", previewHost, "TOPLEFT", 8, -8)
            previewScroll:SetPoint("BOTTOMRIGHT", previewHost, "BOTTOMRIGHT", -30, 8)

            local previewContent = CreateFrame("Frame", nil, previewScroll)
            previewContent:SetWidth(450)
            previewScroll:SetScrollChild(previewContent)

            local previewText = previewContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            previewText:SetPoint("TOPLEFT", previewContent, "TOPLEFT", 0, 0)
            previewText:SetWidth(450)
            previewText:SetJustifyH("LEFT")
            previewText:SetJustifyV("TOP")
            styleBodyText(previewText)
            dialog.previewText = previewText
            dialog.previewContent = previewContent

            local runMacroButton = createSecureMacroButton(dialog, "Run Macro", 108, 24, "BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -14, 14, function()
                if frame and frame.mode == "promote" then
                    local payload = frame.macroPayload
                    local requestedSteps = type(payload) == "table" and math.max(tonumber(payload.requestedPromoteSteps) or 1, 1) or 1

                    if type(frame.pendingPromotionChain) == "table" then
                        frame.pendingPromotionChain.remainingRuns = math.max((tonumber(frame.pendingPromotionChain.remainingRuns) or 0) - 1, 0)
                    elseif requestedSteps > 1 then
                        frame.pendingPromotionChain = {
                            memberIds = type(payload.includedMemberIds) == "table" and payload.includedMemberIds or {},
                            remainingRuns = requestedSteps - 1,
                        }
                    else
                        frame.pendingPromotionChain = nil
                    end
                else
                    frame.pendingPromotionChain = nil
                end

                if C_Timer and C_Timer.After then
                    C_Timer.After(0, function()
                        if frame and frame.HandleBatchRunPostClick then
                            frame:HandleBatchRunPostClick()
                        end
                    end)
                elseif frame and frame.HandleBatchRunPostClick then
                    frame:HandleBatchRunPostClick()
                end
            end)
            dialog.runMacroButton = runMacroButton

            createButton(dialog, "Close", 88, 24, "RIGHT", runMacroButton, "LEFT", -8, 0, function()
                if frame then
                    frame.pendingPromotionChain = nil
                end

                if frame and frame.HideBatchRunDialogAfterCombatSafe then
                    frame:HideBatchRunDialogAfterCombatSafe(true)
                else
                    dialog:Hide()
                end
            end)

            self.batchRunDialog = dialog
            return dialog
        end

        function frame:ShowBatchRunDialog()
            local dialog = self:EnsureBatchRunDialog()
            local payload = self.macroPayload
            local macroText = type(payload) == "table" and tostring(payload.body or "") or ""
            local preparedMacro
            local errorMessage
            local isPromote = self.mode == "promote"
            local requestedSteps = type(payload) == "table" and math.max(tonumber(payload.requestedPromoteSteps) or 1, 1) or 1
            local remainingRuns = isPromote and type(self.pendingPromotionChain) == "table" and math.max(tonumber(self.pendingPromotionChain.remainingRuns) or 0, 0) or 0
            local currentStep = 1

            if isPromote and type(self.pendingPromotionChain) == "table" then
                currentStep = math.min(requestedSteps, math.max((requestedSteps - remainingRuns) + 1, 1))
            end

            if macroText == "" then
                addon:Print("No batch macro is ready to run.")
                return
            end

            if InCombatLockdown and InCombatLockdown() then
                self.pendingBatchRunDialogShowAfterCombat = true

                if self.statusText then
                    self.statusText:SetText("GH3 Helper will reopen the batch confirmation after combat.")
                end

                return
            end

            preparedMacro, errorMessage = service:PrepareExecutionMacro(macroText)

            if not preparedMacro then
                addon:Print(errorMessage or "Unable to prepare the batch macro.")
                return
            end

            if isPromote and requestedSteps > 1 then
                dialog.title:SetText(string.format("Confirm Promote Step %d of %d", currentStep, requestedSteps))
                dialog.body:SetText("WoW only allows one protected guild-rank promotion per click. GH3 will keep this same selected batch moving until the chosen target rank is reached or you close the popup.")
                dialog.runMacroButton:SetText(currentStep < requestedSteps and string.format("Run Step %d", currentStep) or "Run Final Step")
            else
                dialog.title:SetText("Confirm Batch Run")
                dialog.body:SetText("Review the macro below, then click Run Macro to execute this batch.")
                dialog.runMacroButton:SetText("Run Macro")
            end

            dialog.previewText:SetText(macroText)
            dialog.previewContent:SetHeight(math.max(dialog.previewText:GetStringHeight() + 8, 40))

            if not (InCombatLockdown and InCombatLockdown()) then
                dialog.runMacroButton:SetAttribute("useOnKeyDown", false)
                dialog.runMacroButton:SetAttribute("type", "macro")
                dialog.runMacroButton:SetAttribute("macro", preparedMacro.macroName)
                dialog.runMacroButton:SetAttribute("type1", "macro")
                dialog.runMacroButton:SetAttribute("macro1", preparedMacro.macroName)
            end

            dialog.runMacroButton:SetEnabled(not (InCombatLockdown and InCombatLockdown()))
            self.pendingBatchRunDialogShowAfterCombat = false
            dialog:Show()
        end

        function frame:ApplyModeLayout(mode)
            local isPromote = mode == "promote"
            local summaryY = -174
            local queueHeaderY = -198
            local queueTopY = -222

            self.filterPanel:SetHeight(54)
            self.scanButton:SetShown(not isPromote)
            self.inactiveDaysLabel:SetShown(not isPromote)
            self.inactiveDaysInput:SetShown(not isPromote)
            self.applyFiltersButton:SetShown(not isPromote)
            self.scopeButton:SetShown(not isPromote)
            self.linkedAltCheckbox:SetShown(not isPromote)
            self.kickSearchLabel:SetShown(not isPromote)
            self.kickSearchInput:SetShown(not isPromote)
            self.kickSearchButton:SetShown(not isPromote)
            self.fromRankLabel:SetShown(isPromote)
            self.fromRankButton:SetShown(isPromote)
            self.toRankButton:SetShown(isPromote)
            self.promotionTenureButton:SetShown(isPromote)
            self.selectAllButton:SetShown(true)
            self.clearSelectionButton:SetShown(true)

            if isPromote then
                self.fromRankButton:ClearAllPoints()
                self.fromRankButton:SetPoint("TOPLEFT", self.filterPanel, "TOPLEFT", 12, -26)
            end

            self.summaryLabel:ClearAllPoints()
            self.summaryLabel:SetPoint("TOPLEFT", self, "TOPLEFT", 16, summaryY)

            queueHeader:ClearAllPoints()
            queueHeader:SetPoint("TOPLEFT", self, "TOPLEFT", 16, queueHeaderY)

            self.queueContainer:ClearAllPoints()
            self.queueContainer:SetPoint("TOPLEFT", self, "TOPLEFT", 16, queueTopY)

            self.macroHeader:ClearAllPoints()
            self.macroHeader:SetPoint("TOPLEFT", self.queueContainer, "BOTTOMLEFT", 0, -12)

            self.macroContainer:ClearAllPoints()
            self.macroContainer:SetPoint("TOPLEFT", self.queueContainer, "BOTTOMLEFT", 0, -30)
        end

        function frame:ClearQueueRows()
            if not self.queueRows then
                self.queueRows = {}
            end

            for _, row in ipairs(self.queueRows) do
                row:Hide()
                row:SetParent(nil)
            end

            self.queueRows = {}
        end

        function frame:AddQueueRow(index, mode, entry, settings)
            local row = CreateFrame("Frame", nil, self.queueContent, "BackdropTemplate")
            local rowHeight = 28
            row:SetSize(668, rowHeight)
            row:SetPoint("TOPLEFT", self.queueContent, "TOPLEFT", 0, -((index - 1) * (rowHeight + 6)))
            applySurfaceStyle(row, THEME.panel)
            row:Show()

            local info = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            info:SetPoint("LEFT", row, "LEFT", 8, 0)
            info:SetWidth(mode == "kick" and 470 or 548)
            info:SetJustifyH("LEFT")
            info:SetJustifyV("MIDDLE")
            styleBodyText(info, true)

            if mode == "promote" then
                info:SetText(string.format(
                    "%s | %s -> %s | %s",
                    entry.displayName or entry.memberId or "Unknown",
                    tostring(settings.promoteFromRank or "-"),
                    tostring(settings.promoteToRank or "-"),
                    (tonumber(entry.joinedDays) or 0) > 0 and string.format("%d days joined", tonumber(entry.joinedDays) or 0) or "no join date"
                ))
            else
                local linkedAltLabel = getCleanupLinkedAltLabel(entry)
                local familyPreview = (tonumber(entry.linkedKickAltCount) or 0) > 0 and string.format(" | Family +%d", tonumber(entry.linkedKickAltCount) or 0) or ""
                info:SetText(string.format(
                    "%s | %d days | %s | %s%s%s",
                    entry.displayName or entry.memberId or "Unknown",
                    tonumber(entry.daysOld) or 0,
                    entry.guildName or "Unknown",
                    entry.statusLabel or entry.reason or "Review",
                    linkedAltLabel and (" | " .. linkedAltLabel) or "",
                    familyPreview
                ))
            end

            info:Show()

            if mode == "kick" then
                createButton(row, service:IsMemberSelected(mode, entry.memberId) and "Selected" or "Select", 72, 20, "RIGHT", row, "RIGHT", -86, 0, function()
                    service:ToggleSelectedMember(mode, entry.memberId)
                    frame.macroPayload = nil
                    frame:RefreshData(false)
                end)

                createButton(row, entry.manualKick and "Remove" or "Exclude", 72, 20, "RIGHT", row, "RIGHT", -8, 0, function()
                    if entry.manualKick then
                        service:RemoveManualKickMember(entry.memberId)
                    else
                        service:ExcludeMember(entry.memberId)
                    end
                    frame.macroPayload = nil
                    frame:RefreshData(false)
                end)
            else
                createButton(row, service:IsMemberSelected(mode, entry.memberId) and "Selected" or "Select", 72, 20, "RIGHT", row, "RIGHT", -8, 0, function()
                    service:ToggleSelectedMember(mode, entry.memberId)
                    frame.macroPayload = nil
                    frame:RefreshData(false)
                end)
            end

            self.queueRows[#self.queueRows + 1] = row
            return rowHeight + 6
        end

        function frame:ScheduleBatchRefresh()
            self._gh3BatchRefreshToken = (tonumber(self._gh3BatchRefreshToken) or 0) + 1
            local refreshToken = self._gh3BatchRefreshToken
            local mode = self.mode == "promote" and "promote" or "kick"

            local function applyRefresh()
                if not self or not self:IsShown() or self._gh3BatchRefreshToken ~= refreshToken then
                    return
                end
                requestGuildRosterSnapshot()

                if addon.RosterService and addon.RosterService.CaptureGuildRoster then
                    addon.RosterService:CaptureGuildRoster()
                end

                service:ScanRoster()

                if mode == "promote" and type(self.pendingPromotionChain) == "table" then
                    local chainMemberIds = type(self.pendingPromotionChain.memberIds) == "table" and self.pendingPromotionChain.memberIds or nil
                    local remainingRuns = tonumber(self.pendingPromotionChain.remainingRuns) or 0

                    if chainMemberIds and #chainMemberIds > 0 and remainingRuns > 0 then
                        self.macroPayload = service:BuildPromotionMacro(chainMemberIds)
                        self:RefreshData(true)

                        if self.macroPayload and (self.macroPayload.includedCount or 0) > 0 then
                            self.statusText:SetText(string.format(
                                "Promotion batch step complete. %d more confirmation run(s) remain for the same selected member(s).",
                                remainingRuns
                            ))

                            if C_Timer and C_Timer.After then
                                C_Timer.After(0.2, function()
                                    if self and self:IsShown() and self._gh3BatchRefreshToken == refreshToken and type(self.pendingPromotionChain) == "table" then
                                        self:ShowBatchRunDialog()
                                    end
                                end)
                            else
                                self:ShowBatchRunDialog()
                            end

                            return
                        end
                    end

                    self.pendingPromotionChain = nil
                end

                self.macroPayload = nil
                self:RefreshData(false)
            end

            self.statusText:SetText("Batch executed. Refreshing roster and rebuilding the next batch...")
            requestGuildRosterSnapshot()

            if C_Timer and C_Timer.After then
                C_Timer.After(1, function()
                    applyRefresh()
                end)

                if mode == "promote" then
                    C_Timer.After(2.5, function()
                        applyRefresh()
                    end)
                end
            else
                applyRefresh()
            end
        end

        function frame:RefreshData(preserveMacroPayload)
            service:ScanRoster()
            local mode = self.mode == "promote" and "promote" or "kick"
            self:ApplyModeLayout(mode)

            if not preserveMacroPayload or type(self.macroPayload) ~= "table" then
                if mode == "promote" then
                    local memberIds = service:GetEffectivePromotionMacroMemberIds()
                    self.macroPayload = service:BuildPromotionMacro(memberIds)
                else
                    local memberIds = service:GetEffectiveCleanupMacroMemberIds()
                    self.macroPayload = service:BuildCleanupMacro(memberIds)
                end
            end

            local entries = mode == "promote" and service:ListPromotionEntries() or service:ListCleanupEntries("kick")
            local summary = service:GetSummary()
            local settings = service:GetSettings()
            local queueLines = {}
            local macroText = ""
            local canRemove = service:CanGuildRemoveMembers()
            local selectedIds = service:GetSelectedMemberIds(mode)
            local selectedCount = #selectedIds
            local pageSize = tonumber(self.queuePageSize) or 5
            local totalPages = math.max(1, math.ceil(#entries / math.max(pageSize, 1)))
            local currentPage = math.min(math.max(tonumber((self.queuePages or {})[mode]) or 1, 1), totalPages)
            local pageStart = ((currentPage - 1) * pageSize) + 1
            local pageEnd = math.min(pageStart + pageSize - 1, #entries)
            local visibleEntries = {}

            self.queuePages = self.queuePages or {}
            self.queuePages[mode] = currentPage
            self.queueTotalPages = totalPages

            if #entries > 0 then
                for index = pageStart, pageEnd do
                    visibleEntries[#visibleEntries + 1] = entries[index]
                end
            end

            self.kickTabButton:SetText(mode == "kick" and "Kick *" or "Kick")
            self.promoteTabButton:SetText(mode == "promote" and "Promote *" or "Promote")
            self.inactiveDaysInput:SetText(tostring(settings.staleAfterDays or summary.staleAfterDays or 30))
            setMenuButtonText(self.scopeButton, "Scope: " .. getCleanupKickScopeLabel(settings.kickScope))
            self.linkedAltCheckbox:SetChecked(settings.kickLinkedAltsWithMain and true or false)
            setMenuButtonText(self.fromRankButton, getPromotionRankButtonText("From", settings.promoteFromRank))
            setMenuButtonText(self.toRankButton, getPromotionRankButtonText("To", settings.promoteToRank))
            setMenuButtonText(self.promotionTenureButton, getPromotionTenureButtonText(settings.promoteMinJoinDays))
            self.summaryLabel:SetText(mode == "kick"
                and string.format("%d kick candidate(s) are currently queued. Inactive scan: %d days. Selected: %d. Scope: %s. Manual search: %d. Family blocks: %d.%s", #entries, summary.staleAfterDays, selectedCount, getCleanupKickScopeLabel(summary.kickScope), summary.manualKickCount or 0, summary.familyBlockedCount or 0, summary.kickLinkedAltsWithMain and " Linked alt expansion is on for queue families." or "")
                or string.format("%d promotion candidate(s) are currently queued from %s to %s. %s.", #entries, tostring(settings.promoteFromRank or "-"), tostring(settings.promoteToRank or "-"), getPromotionTenureButtonText(settings.promoteMinJoinDays)))

            self.selectAllButton:SetEnabled(#entries > 0)
            self.clearSelectionButton:SetEnabled(selectedCount > 0)
            self.clearSelectionButton:SetText("Select None")
            self.queuePageLabel:SetText(string.format("Page %d/%d", currentPage, totalPages))
            self.prevQueuePageButton:SetEnabled(currentPage > 1)
            self.nextQueuePageButton:SetEnabled(currentPage < totalPages)

            self:ClearQueueRows()
            if self.queueScroll and self.queueScroll.SetVerticalScroll then
                self.queueScroll:SetVerticalScroll(0)
            end

            if #entries <= 0 then
                self.queueBody:Show()
                if self.queueScroll then
                    self.queueScroll:Show()
                end
                queueLines[#queueLines + 1] = mode == "kick"
                    and "No inactive current-guild members are queued for kick review."
                    or "No guild members currently match the selected source rank for batch promotion."
            else
                self.queueBody:Hide()
                if self.queueScroll then
                    self.queueScroll:Show()
                end
                local totalHeight = 0
                for visibleIndex, entry in ipairs(visibleEntries) do
                    totalHeight = totalHeight + self:AddQueueRow(visibleIndex, mode, entry, settings)
                end

                self.queueContent:SetHeight(math.max(totalHeight, 40))
            end

            self.queueBody:SetText(table.concat(queueLines, "\n"))
            if #entries <= 0 then
                self.queueContent:SetHeight(math.max(self.queueBody:GetStringHeight() + 8, 40))
            end

            if mode == "kick" then
                macroText = (self.macroPayload and self.macroPayload.body ~= "") and self.macroPayload.body or (#entries > 0 and "No members are selected for this kick batch yet." or "No kick candidates fit in the macro body limit yet.")
            else
                macroText = (self.macroPayload and self.macroPayload.body ~= "") and self.macroPayload.body or (#entries > 0 and "No members are selected for this promotion batch yet." or "No promotion candidates fit in the macro body limit yet.")
            end

            self.macroBody:SetText(macroText)
            self.macroContent:SetHeight(math.max(self.macroBody:GetStringHeight() + 8, 90))

            if not (InCombatLockdown and InCombatLockdown()) then
                self.runBatchButton:SetAttribute("useOnKeyDown", false)

                if (mode == "kick" or mode == "promote") and self.macroPayload and tostring(self.macroPayload.body or "") ~= "" then
                    self.runBatchButton:SetAttribute("type", "macro")
                    self.runBatchButton:SetAttribute("macrotext", self.macroPayload.body)
                    self.runBatchButton:SetAttribute("type1", "macro")
                    self.runBatchButton:SetAttribute("macrotext1", self.macroPayload.body)
                else
                    self.runBatchButton:SetAttribute("type", nil)
                    self.runBatchButton:SetAttribute("macrotext", nil)
                    self.runBatchButton:SetAttribute("type1", nil)
                    self.runBatchButton:SetAttribute("macrotext1", nil)
                end
            end

            self.buildMacroButton:SetEnabled(true)
            self.runBatchButton:SetEnabled(canRemove and self.macroPayload and (self.macroPayload.includedCount or 0) > 0 and not (InCombatLockdown and InCombatLockdown()))
            self.scopeButton:SetEnabled(mode == "kick")
            self.linkedAltCheckbox:SetEnabled(mode == "kick")
            self.kickSearchInput:SetEnabled(mode == "kick")
            self.kickSearchButton:SetEnabled(mode == "kick")
            self.inactiveDaysInput:SetEnabled(mode == "kick")
            self.applyFiltersButton:SetEnabled(mode == "kick")
            self.fromRankButton:SetEnabled(mode == "promote")
            self.toRankButton:SetEnabled(mode == "promote")
            self.promotionTenureButton:SetEnabled(mode == "promote")

            if mode == "promote" then
                self.statusText:SetText(string.format(
                    "Promotion macro %s includes %d command(s) for %d member(s). GH3 now promotes one guild-rank step per member per batch run to avoid firing repeated /gpromote lines too quickly.",
                    service:GetPromotionMacroName(),
                    self.macroPayload and self.macroPayload.includedCount or 0,
                    self.macroPayload and self.macroPayload.totalCount or 0
                ))
            elseif not canRemove then
                self.statusText:SetText("You do not currently have permission to remove guild members. Macro preview remains available, but saving a kick macro is disabled.")
            else
                self.statusText:SetText(string.format(
                    "Macro %s includes %d of %d candidate(s). Click Batch Run to review and confirm the current macro. GH3 will then rebuild the next batch automatically. %d additional candidate(s) were left out because WoW macro bodies are limited to 255 characters. Linked-alt expansion is %s.",
                    service:GetCleanupMacroName(),
                    self.macroPayload and self.macroPayload.includedCount or 0,
                    self.macroPayload and self.macroPayload.totalCount or 0,
                    self.macroPayload and self.macroPayload.skippedCount or 0,
                    summary.kickLinkedAltsWithMain and "on" or "off"
                ))
            end
        end

        self.guildCleanupFrame = frame
    end

    if not frame:IsShown() and frame.ResetKickSearchState then
        frame:ResetKickSearchState(true)
    end

    frame.mode = initialMode == "promote" and "promote" or "kick"
    frame.macroPayload = nil
    frame:RefreshData()
    frame:Show()
end

function addon.MainFrame:TryAttachGuildCleanupLaunchers()
    local canUseCleanupLauncher = addon.MaintenanceService
        and addon.MaintenanceService.CanGuildRemoveMembers
        and addon.MaintenanceService:CanGuildRemoveMembers()

    local function applyLauncherTooltip(button)
        if not button or button.GH3TooltipConfigured then
            return
        end

        button.GH3TooltipConfigured = true
        button:SetScript("OnEnter", function(control)
            GameTooltip:SetOwner(control, "ANCHOR_RIGHT")
            GameTooltip:SetText("GH3 Helper", 0.93, 0.84, 0.63, 1, true)

            local canUseButton = addon.MaintenanceService
                and addon.MaintenanceService.CanGuildRemoveMembers
                and addon.MaintenanceService:CanGuildRemoveMembers()

            if canUseButton then
                GameTooltip:AddLine("Open GH3 Helper for cleanup review, batch-kick prep, and batch-promotion prep.", 0.82, 0.82, 0.82, true)
            else
                GameTooltip:AddLine("Visible for discoverability, but disabled until this character can remove guild members.", 0.82, 0.82, 0.82, true)
            end

            GameTooltip:Show()
        end)
        button:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
    end

    local function updateLauncherState(button)
        if not button then
            return
        end

        if InCombatLockdown and InCombatLockdown() then
            button:Disable()
        elseif not canUseCleanupLauncher then
            button:Disable()
        else
            button:Enable()
        end

        button:Show()
    end

    local function configureCommunitiesTopTab()
        local communitiesFrame = _G.CommunitiesFrame

        if not communitiesFrame then
            return false
        end

        local anchor = communitiesFrame.CloseButton or communitiesFrame.closeButton or communitiesFrame
        local parent = communitiesFrame
        local button = parent.GH3HelperTopTab

        if not button then
            button = createButton(parent, "GH3 Helper", 110, 22, "BOTTOMRIGHT", anchor, "TOPRIGHT", -58, 2, function()
                addon.MaintenanceService:ScanRoster()
                addon.MainFrame:ShowGuildCleanupDialog("kick")
            end)
            parent.GH3HelperTopTab = button
            styleHelperLauncherButton(button)
            button:SetFrameStrata(parent:GetFrameStrata())
            button:SetFrameLevel(parent:GetFrameLevel() + 40)
            applyLauncherTooltip(button)
        else
            button:ClearAllPoints()
            button:SetPoint("BOTTOMRIGHT", anchor, "TOPRIGHT", -58, 2)
            styleHelperLauncherButton(button)
        end

        updateLauncherState(button)
        return true
    end

    local function configureCommunitiesTitleLauncher()
        local communitiesFrame = _G.CommunitiesFrame

        if not communitiesFrame then
            return false
        end

        local rosterFrame = communitiesFrame.TitleContainer or communitiesFrame.PortraitOverlay
        local anchorButton = communitiesFrame.MaximizeMinimizeFrame and communitiesFrame.MaximizeMinimizeFrame.MinimizeButton or nil

        if not rosterFrame or not anchorButton then
            return false
        end

        local button = rosterFrame.GH3CleanupButton

        if not button then
            button = createButton(rosterFrame, "GH3 Helper", 98, 22, "RIGHT", anchorButton, "LEFT", -14, 0, function()
                addon.MaintenanceService:ScanRoster()
                addon.MainFrame:ShowGuildCleanupDialog("kick")
            end)
            rosterFrame.GH3CleanupButton = button
            styleHelperLauncherButton(button)
            button:SetFrameStrata(rosterFrame:GetFrameStrata())
            button:SetFrameLevel(anchorButton:GetFrameLevel() + 20)
            applyLauncherTooltip(button)
        else
            button:ClearAllPoints()
            button:SetPoint("RIGHT", anchorButton, "LEFT", -14, 0)
            styleHelperLauncherButton(button)
        end

        updateLauncherState(button)
        return true
    end

    local function configureFooterLauncher(anchorButton, key)
        if not anchorButton or not anchorButton.GetParent then
            return false
        end

        local parent = anchorButton:GetParent()

        if not parent then
            return false
        end

        local button = parent[key]

        if not button then
            button = createButton(parent, "GH3 Helper", 98, 22, "BOTTOMRIGHT", anchorButton, "TOPRIGHT", 0, 6, function()
                addon.MaintenanceService:ScanRoster()
                addon.MainFrame:ShowGuildCleanupDialog("kick")
            end)
            parent[key] = button
            styleHelperLauncherButton(button)

            button:SetFrameStrata(anchorButton:GetFrameStrata())
            button:SetFrameLevel(anchorButton:GetFrameLevel() + 20)
            applyLauncherTooltip(button)
        else
            button:ClearAllPoints()
            button:SetPoint("BOTTOMRIGHT", anchorButton, "TOPRIGHT", 0, 6)
            styleHelperLauncherButton(button)
        end

        updateLauncherState(button)
        return true
    end

    local function configureLauncher(parent, key)
        if not parent then
            return
        end

        local button = parent[key]

        if not button then
            local anchor = parent.CloseButton or parent.closeButton or parent
            local anchorPoint = (anchor == parent) and "TOPRIGHT" or "TOPLEFT"
            local relativePoint = (anchor == parent) and "TOPRIGHT" or "TOPRIGHT"
            local offsetX = (anchor == parent) and -18 or -116
            local offsetY = (anchor == parent) and -18 or 0

            if parent == _G.GuildFrame then
                local inviteButton = _G.GuildInviteButton
                local recruitmentButton = _G.GuildRecruitmentButton

                if inviteButton then
                    anchor = inviteButton
                    anchorPoint = "BOTTOMRIGHT"
                    relativePoint = "TOPRIGHT"
                    offsetX = 0
                    offsetY = 6
                elseif recruitmentButton then
                    anchor = recruitmentButton
                    anchorPoint = "BOTTOMLEFT"
                    relativePoint = "TOPLEFT"
                    offsetX = 0
                    offsetY = 6
                end
            end

            button = createButton(parent, "GH3 Helper", 98, 22, anchorPoint, anchor, relativePoint, offsetX, offsetY, function()
                addon.MaintenanceService:ScanRoster()
                addon.MainFrame:ShowGuildCleanupDialog("kick")
            end)
            parent[key] = button
            styleHelperLauncherButton(button)

            button:SetFrameStrata(parent:GetFrameStrata())
            button:SetFrameLevel(parent:GetFrameLevel() + 20)
            applyLauncherTooltip(button)
        else
            styleHelperLauncherButton(button)
        end

        updateLauncherState(button)
    end

    if configureCommunitiesTopTab() then
        return
    end

    if configureCommunitiesTitleLauncher() then
        return
    end

    if configureFooterLauncher(_G.GuildInviteButton, "GH3CleanupFooterButton") then
        return
    end

    if configureFooterLauncher(_G.GuildRecruitmentButton, "GH3CleanupFooterButton") then
        return
    end

    configureLauncher(_G.CommunitiesFrame, "GH3CleanupButton")
    configureLauncher(_G.GuildFrame, "GH3CleanupButton")
end

function addon.MainFrame:InitializeGuildCleanupLaunchers()
    if self.guildCleanupLauncherController then
        self:TryAttachGuildCleanupLaunchers()
        return
    end

    local controller = CreateFrame("Frame")
    controller:RegisterEvent("PLAYER_LOGIN")
    controller:RegisterEvent("PLAYER_REGEN_ENABLED")
    controller:RegisterEvent("ADDON_LOADED")
    controller:SetScript("OnEvent", function(_, event, addonName)
        if event == "ADDON_LOADED"
            and addonName ~= "Blizzard_Communities"
            and addonName ~= "Blizzard_GuildUI" then
            return
        end

        if addon.MainFrame and addon.MainFrame.TryAttachGuildCleanupLaunchers then
            addon.MainFrame:TryAttachGuildCleanupLaunchers()
        end
    end)

    self.guildCleanupLauncherController = controller
    self:TryAttachGuildCleanupLaunchers()
end

local function getNextOption(options, currentValue)
    for index, value in ipairs(options) do
        if value == currentValue then
            return options[(index % #options) + 1]
        end
    end

    return options[1]
end

local function createToggleButtons(parent, options, selectedValues, width, startX, startY, columns)
    local state = {}

    for _, value in ipairs(selectedValues or {}) do
        state[string.lower(value)] = true
    end

    for index, option in ipairs(options) do
        local button
        local row = math.floor((index - 1) / columns)
        local column = (index - 1) % columns
        local normalized = string.lower(option)

        button = createButton(parent, "", width, 22, "TOPLEFT", parent, "TOPLEFT", startX + (column * (width + 8)), startY - (row * 30), function()
            state[normalized] = not state[normalized]
            button:SetText((state[normalized] and "[x] " or "[ ] ") .. option)
        end)
        button:SetText((state[normalized] and "[x] " or "[ ] ") .. option)
    end

    return state
end

local function collectSelectedOptions(options, selectedState)
    local selected = {}

    for _, option in ipairs(options) do
        if selectedState[string.lower(option)] then
            selected[#selected + 1] = option
        end
    end

    return selected
end

local function formatConflictTime(timestamp)
    if not timestamp or timestamp <= 0 then
        return "-"
    end

    return date("%m-%d %H:%M", timestamp)
end

local function splitIsoDateParts(value)
    local year, month, day = string.match(value or "", "^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    return year or "", month or "", day or ""
end

local function buildIsoDate(year, month, day)
    local trimmedYear = tostring(year or ""):match("^%s*(.-)%s*$") or ""
    local trimmedMonth = tostring(month or ""):match("^%s*(.-)%s*$") or ""
    local trimmedDay = tostring(day or ""):match("^%s*(.-)%s*$") or ""

    if trimmedYear == "" and trimmedMonth == "" and trimmedDay == "" then
        return ""
    end

    local numericYear = tonumber(trimmedYear)
    local numericMonth = tonumber(trimmedMonth)
    local numericDay = tonumber(trimmedDay)

    if not numericYear or not numericMonth or not numericDay then
        return nil
    end

    if numericMonth < 1 or numericMonth > 12 or numericDay < 1 or numericDay > 31 then
        return nil
    end

    return string.format("%04d-%02d-%02d", numericYear, numericMonth, numericDay)
end

local function getValidOption(options, currentValue)
    for _, value in ipairs(options or {}) do
        if value == currentValue then
            return value
        end
    end

    return options and options[1] or currentValue
end

local function copyMap(source)
    local copy = {}

    for key, value in pairs(source or {}) do
        copy[key] = value
    end

    return copy
end

function lowerText(value)
    return string.lower(tostring(value or ""))
end

cropText = function(value, maxChars)
    local text = tostring(value or "")

    if #text <= maxChars then
        return text
    end

    if maxChars <= 3 then
        return string.sub(text, 1, maxChars)
    end

    return string.sub(text, 1, maxChars - 3) .. "..."
end

local function formatRosterCell(member, key)
    if key == "itemLevel" then
        local itemLevel = tonumber(member.itemLevel) or 0

        if itemLevel <= 0 then
            return "-"
        end

        return string.format("%.2f", itemLevel)
    end

    if key == "roles" then
        local values = {}

        if member.primaryRole and member.primaryRole ~= "" then
            values[#values + 1] = member.primaryRole
        end

        for _, role in ipairs(member.secondaryRoles or {}) do
            if role ~= member.primaryRole then
                values[#values + 1] = role
            end
        end

        return #values > 0 and joinList(values) or "-"
    end

    if key == "primaryRole" then
        return member.primaryRole or "dps"
    end

    if key == "online" then
        return member.online and "Yes" or "No"
    end

    local value = member[key]

    if type(value) == "table" then
        return joinList(value)
    end

    if value == nil or value == "" then
        return "-"
    end

    return tostring(value)
end

local function getRosterSortValue(member, key)
    if key == "itemLevel" then
        return tonumber(member.itemLevel) or 0
    end

    if key == "level" then
        return tonumber(member.level) or 0
    end

    if key == "online" then
        return member.online and 1 or 0
    end

    return lowerText(formatRosterCell(member, key))
end

local function normalizeRosterColumns(selectedColumns)
    if not selectedColumns then
        return copyMap(DEFAULT_ROSTER_COLUMNS)
    end

    if selectedColumns["Main Toon"] ~= nil or selectedColumns["BattleTag"] ~= nil then
        return selectedColumns
    end

    local normalized = copyMap(DEFAULT_ROSTER_COLUMNS)

    for label, key in pairs(LEGACY_ROSTER_COLUMN_KEY_MAP) do
        if selectedColumns[key] ~= nil then
            normalized[label] = selectedColumns[key]
        end
    end

    return normalized
end

local function countSelectedRosterColumns(selectedColumns)
    local count = 0

    for _, label in ipairs(LEGACY_ROSTER_COLUMN_ORDER) do
        if selectedColumns[label] then
            count = count + 1
        end
    end

    return count
end

local function buildLegacyRosterHeaders(selectedColumns)
    local headers = { "Level", "Toon" }

    for _, label in ipairs(LEGACY_ROSTER_COLUMN_ORDER) do
        if selectedColumns[label] then
            headers[#headers + 1] = label
        end
    end

    headers[#headers + 1] = "Actions"
    return headers
end

local function getLegacyRosterValue(member, header)
    if header == "Level" then
        local level = tonumber(member.level) or 0
        return level > 0 and tostring(level) or "NA"
    end

    if header == "Toon" then
        return member.fullName or "NA"
    end

    if header == "Actions" then
        return ""
    end

    local key = LEGACY_ROSTER_COLUMN_KEY_MAP[header]

    if not key then
        return "NA"
    end

    local value = formatRosterCell(member, key)
    return value == "-" and "NA" or value
end

local function getLegacyRosterSortValue(member, header)
    if header == "Level" then
        return tonumber(member.level) or 0
    end

    if header == "Item Level" then
        return tonumber(member.itemLevel) or 0
    end

    return lowerText(getLegacyRosterValue(member, header))
end

local function memberMatchesRosterSearch(member, searchText)
    if not searchText or searchText == "" then
        return true
    end

    local haystack = lowerText(table.concat({
        member.fullName or "",
        member.guildName or "",
        member.realm or "",
        member.faction or "",
        member.className or "",
        member.spec or "",
        member.mainCharacter or "",
        member.battleTag or "",
        member.birthdate or "",
        joinList(member.interests),
        joinList(member.professions),
        member.primaryRole or "",
        member.rank or "",
    }, " "))

    return string.find(haystack, searchText, 1, true) ~= nil
end

function addon.MainFrame:EnsureState()
    self.state = self.state or {
        boardDraft = nil,
        boardDraftDirty = false,
        boardIsEditing = false,
        boardFilterGuild = "all",
        boardFilterPinnedOnly = false,
        boardFilterText = "",
        boardFilterType = "all",
        celebrationDraft = "",
        celebrationDraftIndex = nil,
        celebrationDraftKind = "birthday",
        celebrationsScrollOffset = 0,
        celebrationsSelectedIndex = nil,
        celebrationsTimingTab = "today",
        celebrationsTab = "birthday",
        gratsUDraft = "",
        gratsUDraftIndex = nil,
        gratsUDraftTab = "achievement",
        gratsUSelectedIndex = nil,
        gratsUTab = "achievement",
        helpArticleKey = "overview",
        mythicsMembersScrollOffset = 0,
        mythicsPortalRailScrollOffset = 0,
        mythicsSummaryKeysScrollOffset = 0,
        maintenancePage = 1,
        maintenanceAuditPage = 1,
        maintenanceInvitePage = 1,
        maintenanceTab = "cleanup",
        maintenanceInviteDraftName = "",
        maintenanceInviteDraftNote = "",
        maintenanceInviteSelectedPending = nil,
        maintenanceInviteOverrideCooldown = false,
        maintenanceInviteUsePrimaryWhisper = true,
        groupsCreateType = "party",
        groupsCreateMythicLevel = 2,
        groupsDetailTemplateGroupId = nil,
        groupsDetailTemplateKey = "",
        groupsDetailMythicLevel = 2,
        groupsDetailScrollOffset = 0,
        groupsFilterType = "all",
        groupsListScrollOffset = 0,
        groupsSelectedOccurrenceGroupId = nil,
        groupsSelectedOccurrenceStart = nil,
        groupsSignupScrollOffset = 0,
        groupsSignupSearchText = "",
        groupsSortKey = "start",
        groupsTemplateKey = "mythic-plus-2",
        rosterColumns = copyMap(DEFAULT_ROSTER_COLUMNS),
        pendingBackgroundRefresh = false,
        rosterOnlineOnly = false,
        rosterPage = 1,
        rosterSearchText = "",
        rosterSortDescending = false,
        rosterSortKey = "Toon",
        raidBingoPrizePool = addon.RaidBingoService:GetState().ui.prizePool or "",
        selectedContentId = nil,
        selectedRosterMemberId = nil,
        selectedGroupId = nil,
        selectedSyncPeer = nil,
    }

    self.state.rosterColumns = normalizeRosterColumns(self.state.rosterColumns)
    self.state.rosterSortKey = self.state.rosterSortKey or "Toon"
    self.state.celebrationsTab = (
        self.state.celebrationsTab == "anniversary-month"
        or self.state.celebrationsTab == "anniversary-six-month"
        or self.state.celebrationsTab == "anniversary-year"
    ) and self.state.celebrationsTab or "birthday"
    self.state.celebrationsTimingTab = (
        self.state.celebrationsTimingTab == "upcoming"
        or self.state.celebrationsTimingTab == "recent"
    ) and self.state.celebrationsTimingTab or "today"
    self.state.gratsUTab = self.state.gratsUTab == "welcome" and "welcome" or "achievement"
    self.state.groupsCreateType = self.state.groupsCreateType == "raid" and "raid" or "party"
    self.state.groupsCreateMythicLevel = math.max(tonumber(self.state.groupsCreateMythicLevel) or 2, 2)
    self.state.groupsDetailMythicLevel = math.max(tonumber(self.state.groupsDetailMythicLevel) or 2, 2)
    self.state.groupsFilterType = self.state.groupsFilterType == "raid" and "raid" or (self.state.groupsFilterType == "party" and "party" or "all")
    self.state.groupsSignupSearchText = tostring(self.state.groupsSignupSearchText or "")
    self.state.groupsSortKey = self.state.groupsSortKey == "type" and "type" or "start"
    self.state.helpArticleKey = tostring(self.state.helpArticleKey or "overview")
    self.state.maintenancePage = math.max(1, math.floor(tonumber(self.state.maintenancePage) or 1))
    self.state.maintenanceAuditPage = math.max(1, math.floor(tonumber(self.state.maintenanceAuditPage) or 1))
    self.state.maintenanceInvitePage = math.max(1, math.floor(tonumber(self.state.maintenanceInvitePage) or 1))
    self.state.maintenanceTab = (
        self.state.maintenanceTab == "invites"
        or self.state.maintenanceTab == "audit"
    ) and self.state.maintenanceTab or "cleanup"
    self.state.maintenanceInviteDraftName = tostring(self.state.maintenanceInviteDraftName or "")
    self.state.maintenanceInviteDraftNote = tostring(self.state.maintenanceInviteDraftNote or "")
    self.state.maintenanceInviteSelectedPending = tostring(self.state.maintenanceInviteSelectedPending or "") ~= "" and tostring(self.state.maintenanceInviteSelectedPending) or nil
    self.state.maintenanceInviteOverrideCooldown = self.state.maintenanceInviteOverrideCooldown == true
    self.state.maintenanceInviteUsePrimaryWhisper = self.state.maintenanceInviteUsePrimaryWhisper ~= false

    if not getHelpArticleByKey(self.state.helpArticleKey) then
        self.state.helpArticleKey = "overview"
    end
end

function addon.MainFrame:SetSelectedSyncPeer(peer)
    self:EnsureState()

    if peer and peer ~= "" then
        self.state.selectedSyncPeer = peer
    else
        self.state.selectedSyncPeer = nil
    end
end

function addon.MainFrame:GetSelectedSyncPeer(peers)
    self:EnsureState()

    if self.state.selectedSyncPeer then
        for _, peer in ipairs(peers or {}) do
            if peer.sender == self.state.selectedSyncPeer then
                return self.state.selectedSyncPeer
            end
        end
    end

    self.state.selectedSyncPeer = peers and peers[1] and peers[1].sender or nil
    return self.state.selectedSyncPeer
end

function addon.MainFrame:MarkBackgroundRefreshPending()
    self:EnsureState()
    self.state.pendingBackgroundRefresh = true

    if self._backgroundRefreshScheduled then
        return
    end

    self._backgroundRefreshScheduled = true

    local function flushBackgroundRefresh()
        if addon.MainFrame ~= self then
            return
        end

        self._backgroundRefreshScheduled = false

        if not self.state or not self.state.pendingBackgroundRefresh then
            return
        end

        if not (self.frame and self.frame:IsShown()) then
            return
        end

        local now = GetTime and GetTime() or time()
        local lastRefreshAt = tonumber(self._lastBackgroundRefreshAt) or 0
        local remainingDelay = BACKGROUND_REFRESH_MIN_INTERVAL_SECONDS - (now - lastRefreshAt)

        if remainingDelay > 0 then
            self._backgroundRefreshScheduled = true

            if C_Timer and C_Timer.After then
                C_Timer.After(remainingDelay, flushBackgroundRefresh)
            else
                self._backgroundRefreshScheduled = false
            end

            return
        end

        self._lastBackgroundRefreshAt = now

        self:RefreshCurrentView(false)
    end

    if C_Timer and C_Timer.After then
        C_Timer.After(0.1, flushBackgroundRefresh)
    else
        flushBackgroundRefresh()
    end
end

function addon.MainFrame:OpenHelpArticle(articleKey)
    self:EnsureState()

    if getHelpArticleByKey(articleKey) then
        self.state.helpArticleKey = articleKey
    else
        self.state.helpArticleKey = "overview"
    end

    if addon.Router and addon.Router.Navigate then
        addon.Router:Navigate("help")
    else
        self:RefreshCurrentView(true)
    end
end

function addon.MainFrame:IsRaidBingoViewActive()
    return self.frame
        and self.frame:IsShown()
        and addon.Router
        and addon.Router.GetCurrentView
        and addon.Router:GetCurrentView() == "raidbingo"
end

function addon.MainFrame:RenderGratsU()
    local host = self:GetViewHost()
    local service = addon.GratsUService

    self.gratsUInputs = {}

    createSectionTitle(host, "GratsU", 18, -18)

    if not service:CanAccess() then
        local restricted = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        restricted:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        restricted:SetText("GratsU is only available to officers and the guild master.")
        styleBodyText(restricted)
        return
    end

    local state = service:GetState()
    local settings = service:GetSettings()
    local summary = service:GetSummary()
    local activeTab = self.state.gratsUTab == "welcome" and "welcome" or "achievement"
    local kindLabel = activeTab == "welcome" and "Welcome" or "Achievement"
    local messages = service:GetMessages(activeTab)
    local selectedIndex = tonumber(self.state.gratsUSelectedIndex)
    local activePeerNames = summary.activePeerNames or {}
    local peerSummaryText = #activePeerNames > 0 and table.concat(activePeerNames, "\n") or "No other active officer or guild master peers detected."
    local lastSentText = summary.lastSentAt and summary.lastSentAt > 0 and formatTimestampText(summary.lastSentAt) or "No automated messages sent yet"

    if not selectedIndex or selectedIndex < 1 or selectedIndex > #messages then
        selectedIndex = #messages > 0 and 1 or nil
        self.state.gratsUSelectedIndex = selectedIndex
    end

    if self.state.gratsUDraftTab ~= activeTab or self.state.gratsUDraftIndex ~= selectedIndex then
        self.state.gratsUDraft = selectedIndex and (messages[selectedIndex] or "") or ""
        self.state.gratsUDraftTab = activeTab
        self.state.gratsUDraftIndex = selectedIndex
    end

    local summaryCardOptions = {
        insetX = 12,
        subFontSize = 11,
        subGap = 8,
        subSpacing = -3,
        titleSize = 15,
        titleTop = 10,
        valueGap = 10,
        valueSize = 18,
    }

    createCard(host, 220, 94, "Access", summary.access, "Visible only to officers and the guild master", 10, -18, summaryCardOptions)
    createCard(host, 220, 94, "Peers", tostring(summary.activePeerCount or 0), "Coordinated senders active in the guild", 240, -18, summaryCardOptions)
    createCard(host, 220, 94, "Achievements", settings.enableAchievement == false and "Off" or "On", string.format("%d templates | %ds cooldown", summary.achievementCount or 0, settings.achievementCooldownSeconds or 120), 470, -18, summaryCardOptions)
    createCard(host, 220, 94, "Welcomes", settings.enableWelcome == false and "Off" or "On", string.format("%d templates | %ds offline gate", summary.welcomeCount or 0, settings.minOfflineSeconds or 120), 700, -18, summaryCardOptions)

    local settingsPanel = createPanel(host)
    settingsPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 14, -126)
    settingsPanel:SetSize(274, 502)

    local settingsTitle = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    settingsTitle:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -12)
    settingsTitle:SetText("Automation Settings")
    styleTitleText(settingsTitle, 16)

    local settingsMeta = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    settingsMeta:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -32)
    settingsMeta:SetWidth(246)
    settingsMeta:SetJustifyH("LEFT")
    settingsMeta:SetText("GratsU coordinates across active officer and GM clients so only one sender answers each event.")
    styleBodyText(settingsMeta, true)

    createCheckbox(settingsPanel, "Enable welcome messages", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -72, settings.enableWelcome ~= false, function(checked)
        service:UpdateSettings({ enableWelcome = checked })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createCheckbox(settingsPanel, "Enable achievement congrats", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -98, settings.enableAchievement ~= false, function(checked)
        service:UpdateSettings({ enableAchievement = checked })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createCheckbox(settingsPanel, "Ignore my own events", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -124, settings.ignoreSelf ~= false, function(checked)
        service:UpdateSettings({ ignoreSelf = checked })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local function createSettingRow(parent, labelText, value, y)
        local label = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y)
        label:SetWidth(134)
        label:SetJustifyH("LEFT")
        label:SetText(labelText)
        styleBodyText(label, true)

        local input = createInput(parent, 96, 22, "LEFT", label, "RIGHT", 10, 0)
        input:SetNumeric(false)
        input:SetText(tostring(value or 0))
        table.insert(self.gratsUInputs, input)
        return input
    end

    local antiSpamInput = createSettingRow(settingsPanel, "Anti-Spam (sec)", settings.antiSpamSeconds, -166)
    local minOfflineInput = createSettingRow(settingsPanel, "Offline Gate (sec)", settings.minOfflineSeconds, -196)
    local achievementCooldownInput = createSettingRow(settingsPanel, "Achieve CD (sec)", settings.achievementCooldownSeconds, -226)
    local welcomeDelayInput = createSettingRow(settingsPanel, "Welcome Delay (sec)", settings.welcomeDelaySeconds, -256)

    createButton(settingsPanel, "Save Settings", 116, 24, "TOPLEFT", settingsPanel, "TOPLEFT", 14, -290, function()
        service:UpdateSettings({
            antiSpamSeconds = antiSpamInput:GetText(),
            minOfflineSeconds = minOfflineInput:GetText(),
            achievementCooldownSeconds = achievementCooldownInput:GetText(),
            welcomeDelaySeconds = welcomeDelayInput:GetText(),
        })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(settingsPanel, "Restore All", 116, 24, "TOPLEFT", settingsPanel, "TOPLEFT", 142, -290, function()
        service:ResetAllDefaults()
        addon.MainFrame.state.gratsUSelectedIndex = 1
        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local statsLabel = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    statsLabel:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -338)
    statsLabel:SetText("Activity")
    styleTitleText(statsLabel, 15)

    local statsText = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    statsText:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -360)
    statsText:SetWidth(246)
    statsText:SetJustifyH("LEFT")
    statsText:SetText(string.format(
        "Welcomes sent: %d\nAchievement congrats sent: %d\nLast automated send: %s",
        summary.stats and summary.stats.welcomeSent or 0,
        summary.stats and summary.stats.achievementSent or 0,
        lastSentText
    ))
    styleBodyText(statsText, true)

    local peersLabel = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    peersLabel:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -414)
    peersLabel:SetText("Active Peers")
    styleTitleText(peersLabel, 15)

    createReadOnlyMultiline(settingsPanel, 246, 58, "TOPLEFT", settingsPanel, "TOPLEFT", 14, -436, peerSummaryText)

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", settingsPanel, "TOPRIGHT", 14, 0)
    listPanel:SetSize(288, 502)

    local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -12)
    listTitle:SetText("Message Library")
    styleTitleText(listTitle, 16)

    createButton(listPanel, activeTab == "achievement" and "Achievements *" or "Achievements", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 14, -36, function()
        addon.MainFrame.state.gratsUTab = "achievement"
        addon.MainFrame.state.gratsUSelectedIndex = 1
        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTab == "welcome" and "Welcomes *" or "Welcomes", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 146, -36, function()
        addon.MainFrame.state.gratsUTab = "welcome"
        addon.MainFrame.state.gratsUSelectedIndex = 1
        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local countLabel = listPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    countLabel:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -64)
    countLabel:SetWidth(260)
    countLabel:SetJustifyH("LEFT")
    countLabel:SetText(string.format("%s templates: %d / 50", kindLabel, #messages))
    styleBodyText(countLabel, true)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -88)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -30, 14)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(236, math.max(#messages * 30, 1))
    listScrollFrame:SetScrollChild(listContent)

    if #messages == 0 then
        local emptyText = listContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptyText:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
        emptyText:SetWidth(236)
        emptyText:SetJustifyH("LEFT")
        emptyText:SetText("No messages are configured for this tab.")
        styleBodyText(emptyText, true)
    end

    for index, messageText in ipairs(messages) do
        local row = CreateFrame("Button", nil, listContent, "BackdropTemplate")
        row:SetSize(236, 26)
        row:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 30))
        applySurfaceStyle(row, index == selectedIndex and THEME.card or THEME.panel)
        row:EnableMouse(true)
        row:SetScript("OnClick", function()
            addon.MainFrame.state.gratsUSelectedIndex = index
            addon.MainFrame.state.gratsUDraft = messageText
            addon.MainFrame.state.gratsUDraftTab = activeTab
            addon.MainFrame.state.gratsUDraftIndex = index
            addon.MainFrame:RefreshCurrentView(true)
        end)

        local rowText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        rowText:SetPoint("LEFT", row, "LEFT", 8, 0)
        rowText:SetWidth(220)
        rowText:SetJustifyH("LEFT")
        rowText:SetText(messageText)
        styleBodyText(rowText, true)
    end

    local editorPanel = createPanel(host)
    editorPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 14, 0)
    editorPanel:SetSize(312, 502)

    local editorTitle = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    editorTitle:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -12)
    editorTitle:SetText(kindLabel .. " Editor")
    styleTitleText(editorTitle, 16)

    local editorMeta = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    editorMeta:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -34)
    editorMeta:SetWidth(284)
    editorMeta:SetJustifyH("LEFT")
    editorMeta:SetText(selectedIndex and string.format("Editing %s message %d of %d", string.lower(kindLabel), selectedIndex, #messages) or string.format("Create a new %s message", string.lower(kindLabel)))
    styleBodyText(editorMeta, true)

    local editorInput = createMultilineInput(editorPanel, 284, 196, "TOPLEFT", editorPanel, "TOPLEFT", 14, -58)
    editorInput:SetText(self.state.gratsUDraft or "")
    table.insert(self.gratsUInputs, editorInput)

    local editorOnTextChanged = editorInput:GetScript("OnTextChanged")
    editorInput:SetScript("OnTextChanged", function(input)
        if editorOnTextChanged then
            editorOnTextChanged(input)
        end

        addon.MainFrame.state.gratsUDraft = input:GetText()
        addon.MainFrame.state.gratsUDraftTab = activeTab
        addon.MainFrame.state.gratsUDraftIndex = selectedIndex
    end)

    createButton(editorPanel, "Save Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 14, -264, function()
        local ok, errorMessage

        if selectedIndex then
            ok, errorMessage = service:UpdateMessage(activeTab, selectedIndex, addon.MainFrame.state.gratsUDraft)
        else
            ok, errorMessage = service:AddMessage(activeTab, addon.MainFrame.state.gratsUDraft)

            if ok then
                addon.MainFrame.state.gratsUSelectedIndex = #service:GetMessages(activeTab)
            end
        end

        if not ok then
            addon:Print(errorMessage)
            return
        end

        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "New Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 154, -264, function()
        addon.MainFrame.state.gratsUSelectedIndex = nil
        addon.MainFrame.state.gratsUDraft = ""
        addon.MainFrame.state.gratsUDraftTab = activeTab
        addon.MainFrame.state.gratsUDraftIndex = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "Delete Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 14, -296, function()
        local ok, errorMessage = service:DeleteMessage(activeTab, selectedIndex)

        if not ok then
            addon:Print(errorMessage)
            return
        end

        local remaining = service:GetMessages(activeTab)
        addon.MainFrame.state.gratsUSelectedIndex = math.min(selectedIndex or 1, #remaining)
        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "Restore Tab", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 154, -296, function()
        service:ResetMessages(activeTab)
        addon.MainFrame.state.gratsUSelectedIndex = 1
        addon.MainFrame.state.gratsUDraftTab = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local previewLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    previewLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -336)
    previewLabel:SetText("Guild Chat Preview")
    styleTitleText(previewLabel, 15)

    createReadOnlyMultiline(editorPanel, 284, 116, "TOPLEFT", editorPanel, "TOPLEFT", 14, -360, service:GetPreview(activeTab, self.state.gratsUDraft))
end

function addon.MainFrame:RenderToonCelebrations()
    local host = self:GetViewHost()
    local service = addon.ToonCelebrationService

    local paneScrollFrame = CreateFrame("ScrollFrame", nil, host, "UIPanelScrollFrameTemplate")
    paneScrollFrame:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 0)
    paneScrollFrame:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -24, 0)
    self.celebrationsScrollFrame = paneScrollFrame
    paneScrollFrame:HookScript("OnVerticalScroll", function(frame, offset)
        frame:SetVerticalScroll(offset)

        if addon.MainFrame and addon.MainFrame.state then
            addon.MainFrame.state.celebrationsScrollOffset = tonumber(offset) or 0
        end
    end)

    local scrollChild = CreateFrame("Frame", nil, paneScrollFrame)
    scrollChild:SetSize(944, 760)
    paneScrollFrame:SetScrollChild(scrollChild)
    host = scrollChild

    self.celebrationInputs = {}

    createSectionTitle(host, "Toon Celebrations", 18, -18)

    if not service:CanAccess() then
        local restricted = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        restricted:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        restricted:SetText("Toon Celebrations is only available to officers and the guild master.")
        styleBodyText(restricted)
        scrollChild:SetHeight(96)
        self:RestoreCelebrationsScrollState()
        return
    end

    local settings = service:GetSettings()
    local summary = service:GetSummary(settings.lookbackDays, settings.lookaheadDays)
    local entries = service:GetCelebrationWindow(settings.lookbackDays, settings.lookaheadDays)
    local activeTab = self.state.celebrationsTab or "birthday"
    local activeTiming = self.state.celebrationsTimingTab or "today"
    local messages = service:GetMessages(activeTab, activeTiming)
    local selectedIndex = tonumber(self.state.celebrationsSelectedIndex)
    local tabLabels = {
        birthday = "Birthdays",
        ["anniversary-month"] = "1 Month",
        ["anniversary-six-month"] = "6 Months",
        ["anniversary-year"] = "Yearly",
    }
    local timingLabels = {
        upcoming = "Upcoming",
        today = "Today",
        recent = "Just Missed",
    }
    local editorLabels = {
        birthday = "Birthday",
        ["anniversary-month"] = "1 Month Anniversary",
        ["anniversary-six-month"] = "6 Month Anniversary",
        ["anniversary-year"] = "Yearly Anniversary",
    }
    local activeLabel = editorLabels[activeTab] or "Birthday"
    local previewYears = activeTab == "anniversary-year" and 3 or 0
    local previewOffset = activeTiming == "upcoming" and 1 or (activeTiming == "recent" and -1 or 0)

    if not selectedIndex or selectedIndex < 1 or selectedIndex > #messages then
        selectedIndex = #messages > 0 and 1 or nil
        self.state.celebrationsSelectedIndex = selectedIndex
    end

    if self.state.celebrationDraftKind ~= string.format("%s|%s", activeTab, activeTiming) or self.state.celebrationDraftIndex ~= selectedIndex then
        self.state.celebrationDraft = selectedIndex and (messages[selectedIndex] or "") or ""
        self.state.celebrationDraftKind = string.format("%s|%s", activeTab, activeTiming)
        self.state.celebrationDraftIndex = selectedIndex
    end

    local summaryCardOptions = {
        insetX = 12,
        subFontSize = 11,
        subGap = 8,
        subSpacing = -3,
        titleSize = 15,
        titleTop = 10,
        valueGap = 10,
        valueSize = 18,
    }

    createCard(host, 220, 94, "Guild", summary.guildKey or "Unknown", "Current live guild for celebration review and sending", 10, -18, summaryCardOptions)
    createCard(host, 220, 94, "Window", string.format("-%d / +%d", settings.lookbackDays or 7, settings.lookaheadDays or 14), string.format("%d recent | %d today | %d upcoming", summary.recent or 0, summary.today or 0, summary.upcoming or 0), 240, -18, summaryCardOptions)
    createCard(host, 220, 94, "Reminder", settings.enableBirthday == false and settings.enableAnniversary == false and "Off" or "On", string.format("Login note only | Sent %d", summary.sent or 0), 470, -18, summaryCardOptions)
    createCard(host, 220, 94, "Templates", tostring((summary.birthdayCount or 0) + (summary.anniversaryMonthCount or 0) + (summary.anniversarySixMonthCount or 0) + (summary.anniversaryYearCount or 0)), "Birthday and anniversary message library entries", 700, -18, summaryCardOptions)

    local settingsPanel = createPanel(host)
    settingsPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 14, -126)
    settingsPanel:SetSize(274, 352)

    local settingsTitle = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    settingsTitle:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -12)
    settingsTitle:SetText("Reminder Settings")
    styleTitleText(settingsTitle, 16)

    local settingsMeta = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    settingsMeta:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -32)
    settingsMeta:SetWidth(246)
    settingsMeta:SetJustifyH("LEFT")
    settingsMeta:SetText("Only officers and the guild master can manage celebration reminders for the current guild. GH3 now reminds you after login instead of auto-sending.")
    styleBodyText(settingsMeta, true)

    createCheckbox(settingsPanel, "Enable birthday messages", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -72, settings.enableBirthday ~= false, function(checked)
        service:UpdateSettings({ enableBirthday = checked })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createCheckbox(settingsPanel, "Enable anniversary messages", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -98, settings.enableAnniversary ~= false, function(checked)
        service:UpdateSettings({ enableAnniversary = checked })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local function createSettingRow(parent, labelText, value, y)
        local label = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y)
        label:SetWidth(134)
        label:SetJustifyH("LEFT")
        label:SetText(labelText)
        styleBodyText(label, true)

        local input = createInput(parent, 96, 22, "LEFT", label, "RIGHT", 10, 0)
        input:SetNumeric(false)
        input:SetText(tostring(value or 0))
        table.insert(self.celebrationInputs, input)
        return input
    end

    local lookbackInput = createSettingRow(settingsPanel, "Recent Window", settings.lookbackDays, -140)
    local lookaheadInput = createSettingRow(settingsPanel, "Upcoming Window", settings.lookaheadDays, -170)

    createButton(settingsPanel, "Save Settings", 116, 24, "TOPLEFT", settingsPanel, "TOPLEFT", 14, -236, function()
        service:UpdateSettings({
            lookbackDays = lookbackInput:GetText(),
            lookaheadDays = lookaheadInput:GetText(),
        })
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(settingsPanel, "Restore All", 116, 24, "TOPLEFT", settingsPanel, "TOPLEFT", 142, -236, function()
        service:ResetAllDefaults()
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local statsLabel = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    statsLabel:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -246)
    statsLabel:SetText("Activity")
    styleTitleText(statsLabel, 15)

    local statsText = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    statsText:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -268)
    statsText:SetWidth(246)
    statsText:SetJustifyH("LEFT")
    statsText:SetText(string.format(
        "Birthdays sent: %d\n1 month sent: %d\n6 month sent: %d\nYearly sent: %d",
        summary.stats and summary.stats.birthdaySent or 0,
        summary.stats and summary.stats.anniversaryMonthSent or 0,
        summary.stats and summary.stats.anniversarySixMonthSent or 0,
        summary.stats and summary.stats.anniversaryYearSent or 0
    ))
    styleBodyText(statsText, true)

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", settingsPanel, "TOPRIGHT", 14, 0)
    listPanel:SetSize(288, 352)

    local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -12)
    listTitle:SetText("Message Library")
    styleTitleText(listTitle, 16)

    createButton(listPanel, activeTab == "birthday" and "Birthdays *" or "Birthdays", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 14, -36, function()
        addon.MainFrame.state.celebrationsTab = "birthday"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTab == "anniversary-month" and "1 Month *" or "1 Month", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 146, -36, function()
        addon.MainFrame.state.celebrationsTab = "anniversary-month"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTab == "anniversary-six-month" and "6 Months *" or "6 Months", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 14, -64, function()
        addon.MainFrame.state.celebrationsTab = "anniversary-six-month"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTab == "anniversary-year" and "Yearly *" or "Yearly", 122, 22, "TOPLEFT", listPanel, "TOPLEFT", 146, -64, function()
        addon.MainFrame.state.celebrationsTab = "anniversary-year"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTiming == "upcoming" and "Upcoming *" or "Upcoming", 78, 22, "TOPLEFT", listPanel, "TOPLEFT", 14, -94, function()
        addon.MainFrame.state.celebrationsTimingTab = "upcoming"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTiming == "today" and "Today *" or "Today", 70, 22, "TOPLEFT", listPanel, "TOPLEFT", 100, -94, function()
        addon.MainFrame.state.celebrationsTimingTab = "today"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, activeTiming == "recent" and "Just Missed *" or "Just Missed", 100, 22, "TOPLEFT", listPanel, "TOPLEFT", 178, -94, function()
        addon.MainFrame.state.celebrationsTimingTab = "recent"
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local countLabel = listPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    countLabel:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -124)
    countLabel:SetWidth(260)
    countLabel:SetJustifyH("LEFT")
    countLabel:SetText(string.format("%s %s templates: %d / 50", timingLabels[activeTiming] or "Today", tabLabels[activeTab] or "Messages", #messages))
    styleBodyText(countLabel, true)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 14, -148)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -30, 14)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(236, math.max(#messages * 30, 1))
    listScrollFrame:SetScrollChild(listContent)

    if #messages == 0 then
        local emptyText = listContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptyText:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
        emptyText:SetWidth(236)
        emptyText:SetJustifyH("LEFT")
        emptyText:SetText("No messages are configured for this celebration type.")
        styleBodyText(emptyText, true)
    end

    for index, messageText in ipairs(messages) do
        local row = CreateFrame("Button", nil, listContent, "BackdropTemplate")
        row:SetSize(236, 26)
        row:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 30))
        applySurfaceStyle(row, index == selectedIndex and THEME.card or THEME.panel)
        row:EnableMouse(true)
        row:SetScript("OnClick", function()
            addon.MainFrame.state.celebrationsSelectedIndex = index
            addon.MainFrame.state.celebrationDraft = messageText
            addon.MainFrame.state.celebrationDraftKind = string.format("%s|%s", activeTab, activeTiming)
            addon.MainFrame.state.celebrationDraftIndex = index
            addon.MainFrame:RefreshCurrentView(true)
        end)

        local rowText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        rowText:SetPoint("LEFT", row, "LEFT", 8, 0)
        rowText:SetWidth(220)
        rowText:SetJustifyH("LEFT")
        rowText:SetText(messageText)
        styleBodyText(rowText, true)
    end

    local editorPanel = createPanel(host)
    editorPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 14, 0)
    editorPanel:SetSize(312, 352)

    local editorTitle = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    editorTitle:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -12)
    editorTitle:SetText(activeLabel .. " Editor")
    styleTitleText(editorTitle, 16)

    local editorMeta = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    editorMeta:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -34)
    editorMeta:SetWidth(284)
    editorMeta:SetJustifyH("LEFT")
    editorMeta:SetText(selectedIndex and string.format("Editing %s %s message %d of %d", string.lower(timingLabels[activeTiming] or "today"), string.lower(activeLabel), selectedIndex, #messages) or string.format("Create a new %s %s message", string.lower(timingLabels[activeTiming] or "today"), string.lower(activeLabel)))
    styleBodyText(editorMeta, true)

    local editorInput = createMultilineInput(editorPanel, 284, 138, "TOPLEFT", editorPanel, "TOPLEFT", 14, -58)
    editorInput:SetText(self.state.celebrationDraft or "")
    table.insert(self.celebrationInputs, editorInput)

    local editorOnTextChanged = editorInput:GetScript("OnTextChanged")
    editorInput:SetScript("OnTextChanged", function(input)
        if editorOnTextChanged then
            editorOnTextChanged(input)
        end

        addon.MainFrame.state.celebrationDraft = input:GetText()
        addon.MainFrame.state.celebrationDraftKind = string.format("%s|%s", activeTab, activeTiming)
        addon.MainFrame.state.celebrationDraftIndex = selectedIndex
    end)

    createButton(editorPanel, "Save Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 14, -206, function()
        local ok, errorMessage

        if selectedIndex then
            ok, errorMessage = service:UpdateMessage(activeTab, activeTiming, selectedIndex, addon.MainFrame.state.celebrationDraft)
        else
            ok, errorMessage = service:AddMessage(activeTab, activeTiming, addon.MainFrame.state.celebrationDraft)

            if ok then
                addon.MainFrame.state.celebrationsSelectedIndex = #service:GetMessages(activeTab, activeTiming)
            end
        end

        if not ok then
            addon:Print(errorMessage)
            return
        end

        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "New Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 154, -206, function()
        addon.MainFrame.state.celebrationsSelectedIndex = nil
        addon.MainFrame.state.celebrationDraft = ""
        addon.MainFrame.state.celebrationDraftKind = string.format("%s|%s", activeTab, activeTiming)
        addon.MainFrame.state.celebrationDraftIndex = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "Delete Message", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 14, -238, function()
        local ok, errorMessage = service:DeleteMessage(activeTab, activeTiming, selectedIndex)

        if not ok then
            addon:Print(errorMessage)
            return
        end

        local remaining = service:GetMessages(activeTab, activeTiming)
        addon.MainFrame.state.celebrationsSelectedIndex = math.min(selectedIndex or 1, #remaining)
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(editorPanel, "Restore Tab", 128, 24, "TOPLEFT", editorPanel, "TOPLEFT", 154, -238, function()
        service:ResetMessages(activeTab, activeTiming)
        addon.MainFrame.state.celebrationsSelectedIndex = 1
        addon.MainFrame.state.celebrationDraftKind = nil
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local previewLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    previewLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -276)
    previewLabel:SetText("Guild Chat Preview")
    styleTitleText(previewLabel, 15)

    createReadOnlyMultiline(editorPanel, 284, 46, "TOPLEFT", editorPanel, "TOPLEFT", 14, -300, service:GetPreview(activeTab, activeTiming, self.state.celebrationDraft, previewYears, previewOffset))

    local listHost = createPanel(host)
    listHost:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -488)
    listHost:SetSize(916, 248)

    local queueTitle = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    queueTitle:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -12)
    queueTitle:SetText("Celebration Queue")
    styleTitleText(queueTitle, 16)

    local queueMeta = listHost:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    queueMeta:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -34)
    queueMeta:SetWidth(860)
    queueMeta:SetJustifyH("LEFT")
    queueMeta:SetText(string.format("Showing %d entries for %s across the configured recent and upcoming window. Use Send to post to guild chat or Ignore to clear that date from the queue.", #entries, summary.guildKey or "Unknown guild"))
    styleBodyText(queueMeta, true)

    local columnHeaderRow = CreateFrame("Frame", nil, listHost, "BackdropTemplate")
    columnHeaderRow:SetSize(860, 24)
    columnHeaderRow:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -58)
    applySurfaceStyle(columnHeaderRow, THEME.card)

    local function createCelebrationHeader(parent, text, width, x)
        local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", parent, "LEFT", x, 0)
        label:SetWidth(width)
        label:SetJustifyH("LEFT")
        label:SetText(text)
        styleHighlightText(label)
        return label
    end

    createCelebrationHeader(columnHeaderRow, "Toon", 170, 8)
    createCelebrationHeader(columnHeaderRow, "Celebration", 148, 186)
    createCelebrationHeader(columnHeaderRow, "Date", 96, 342)
    createCelebrationHeader(columnHeaderRow, "When", 96, 446)
    createCelebrationHeader(columnHeaderRow, "Status", 74, 550)
    createCelebrationHeader(columnHeaderRow, "Actions", 150, 632)

    local scrollFrame = CreateFrame("ScrollFrame", nil, listHost, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -86)
    scrollFrame:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -30, 10)

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(860)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)

    if #entries == 0 then
        local empty = scrollChild:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 14, -8)
        empty:SetWidth(820)
        empty:SetJustifyH("LEFT")
        empty:SetText("No recent or upcoming birthdays or guild anniversaries were found for your toons in the current guild.")
        styleBodyText(empty)
        scrollChild:SetHeight(60)
        host:SetHeight(760)
        self:RestoreCelebrationsScrollState()
        return
    end

    local rowHeight = 42
    local rowPitch = 48

    for index, entry in ipairs(entries) do
        local row = CreateFrame("Frame", nil, scrollChild, "BackdropTemplate")
        row:SetSize(860, rowHeight)
        row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, -((index - 1) * rowPitch))
        applySurfaceStyle(row, THEME.panel)

        local toonLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        toonLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -7)
        toonLabel:SetWidth(170)
        toonLabel:SetJustifyH("LEFT")
        toonLabel:SetText(entry.memberName or entry.memberId or "Unknown")
        styleBodyText(toonLabel)

        local kindLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        kindLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 186, -7)
        kindLabel:SetWidth(148)
        kindLabel:SetJustifyH("LEFT")
        kindLabel:SetText(entry.kindLabel or entry.kind or "Celebration")
        styleBodyText(kindLabel)

        local dateLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        dateLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 342, -8)
        dateLabel:SetWidth(96)
        dateLabel:SetJustifyH("LEFT")
        dateLabel:SetText(entry.dateLabel or entry.dayKey or "Unknown")
        styleBodyText(dateLabel, true)

        local timingLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        timingLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 446, -8)
        timingLabel:SetWidth(96)
        timingLabel:SetJustifyH("LEFT")
        timingLabel:SetText(entry.timingLabel or "Today")
        styleBodyText(timingLabel, true)

        local statusLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        statusLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 550, -8)
        statusLabel:SetWidth(74)
        statusLabel:SetJustifyH("LEFT")
        statusLabel:SetText(entry.sent and "Sent" or "Ready")
        styleBodyText(statusLabel, true)

        local sendButton = createButton(row, entry.sent and "Sent" or "Send", 64, 22, "TOPLEFT", row, "TOPLEFT", 632, -10, function()
            local ok, message = service:SendManualCelebration(entry)

            if ok then
                addon:Print("Celebration message sent.")
                addon.MainFrame:RefreshCurrentView(true)
            elseif message then
                addon:Print(message)
            end
        end)

        local ignoreButton = createButton(row, "Ignore", 64, 22, "TOPLEFT", row, "TOPLEFT", 702, -10, function()
            local ok, message = service:IgnoreCelebration(entry)

            if ok then
                addon:Print("Celebration ignored for that date.")
                addon.MainFrame:RefreshCurrentView(true)
            elseif message then
                addon:Print(message)
            end
        end)

        if entry.sent then
            sendButton:Disable()
            ignoreButton:Disable()
        end
    end

    scrollChild:SetHeight(math.max(1, (#entries * rowPitch) - (rowPitch - rowHeight)))
    host:SetHeight(760)
    self:RestoreCelebrationsScrollState()
end

function addon.MainFrame:RenderRaidBingo()
    local host = self:GetViewHost()
    local service = addon.RaidBingoService
    local state = service:GetState()
    local session = service:GetSession()
    local card = service:GetCard()
    local summary = service:GetSummary()
    local selectionRows = service:GetSelectionRows()
    local prizePoolText = summary.prizePool or service:GetPrizePoolText()
    local payouts = service:CalculatePayouts(prizePoolText)
    local rolledSet = {}
    local isSessionActive = summary.sessionId and summary.status == "active"
    local hasEndedSession = summary.sessionId and summary.status ~= "active"

    self.raidBingoInputs = {}

    if (IsInGroup() or IsInRaid()) and not summary.canLead and not summary.sessionId then
        service:MaybeRequestState()
    end

    for _, number in ipairs(session.rolledNumbers or {}) do
        rolledSet[tonumber(number)] = true
    end

    local summaryCardOptions = {
        insetX = 12,
        subFontSize = 11,
        subGap = 8,
        subSpacing = -3,
        titleSize = 15,
        titleTop = 10,
        valueGap = 10,
        valueSize = 18,
    }

    createCard(host, 232, 100, "Session", isSessionActive and "Live" or (hasEndedSession and "Finished" or "Idle"), summary.sessionId and ("Started " .. formatTimestampText(summary.startedAt)) or "Start or request a session to begin", 10, -18, summaryCardOptions)
    createCard(host, 232, 100, "Rolled", summary.rolled, "Unique numbers from a 1-75 bag", 252, -18, summaryCardOptions)
    createCard(host, 232, 100, "Submissions", summary.submissions, "Locked player cards received by the leader", 494, -18, summaryCardOptions)
    createCard(host, 232, 100, "Your Card", summary.locked and "Locked" or (summary.picked .. "/5"), summary.locked and "Selections sent automatically when the session syncs" or "Pick any five spaces to lock the card", 736, -18, summaryCardOptions)

    local controlPanel = createPanel(host)
    controlPanel:SetSize(300, 486)
    controlPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 10, -126)

    local controlTitle = controlPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    controlTitle:SetPoint("TOPLEFT", controlPanel, "TOPLEFT", 16, -14)
    controlTitle:SetText("Session Control")
    styleTitleText(controlTitle, 18)

    local controlMeta = controlPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    controlMeta:SetPoint("TOPLEFT", controlTitle, "BOTTOMLEFT", 0, -8)
    controlMeta:SetWidth(268)
    controlMeta:SetJustifyH("LEFT")
    controlMeta:SetText(string.format("Leader: %s\nStatus: %s\nSession: %s\nPrize Pool: %s%s", shortPlayerName(summary.leader), isSessionActive and (summary.canLead and "Leader controls enabled" or "Waiting on group leader") or (hasEndedSession and "Round complete" or "No active round"), summary.sessionId or "No active round", prizePoolText ~= "" and prizePoolText or "Not set", summary.prizePoolLocked and " (locked)" or ""))
    styleBodyText(controlMeta)

    local sessionButton = createButton(controlPanel, summary.sessionId and "Reset Game" or "Start Session", 126, 24, "TOPLEFT", controlPanel, "TOPLEFT", 16, -104, function()
        service:ResetGame()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local rollButton = createButton(controlPanel, "Roll Number", 126, 24, "LEFT", sessionButton, "RIGHT", 8, 0, function()
        service:RollNumber()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local syncButton = createButton(controlPanel, "Broadcast State", 126, 24, "TOPLEFT", sessionButton, "BOTTOMLEFT", 0, -10, function()
        service:BroadcastState()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local requestButton = createButton(controlPanel, "Request State", 126, 24, "LEFT", syncButton, "RIGHT", 8, 0, function()
        service:RequestState()
    end)

    local endButton = createButton(controlPanel, "End Session", 126, 24, "TOPLEFT", syncButton, "BOTTOMLEFT", 0, -10, function()
        service:EndSession()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local exportButton = createButton(controlPanel, "Export Group", 126, 24, "TOPLEFT", endButton, "BOTTOMLEFT", 0, -10, function()
        service:ShowExportDialog()
    end)

    local rerollButton = createButton(controlPanel, "New Card", 126, 24, "LEFT", exportButton, "RIGHT", 8, 0, function()
        service:RollNewCard()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    if not summary.canLead then
        sessionButton:Disable()
        rollButton:Disable()
        syncButton:Disable()
        endButton:Disable()
    end

    if not summary.sessionId then
        endButton:Disable()
    end

    if hasEndedSession then
        rollButton:Disable()
        endButton:Disable()
    end

    if summary.locked then
        rerollButton:Disable()
    end

    local prizeLabel = controlPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    prizeLabel:SetPoint("TOPLEFT", exportButton, "BOTTOMLEFT", 0, -18)
    prizeLabel:SetText("Prize Pool")
    styleHighlightText(prizeLabel)

    local prizeInput = createInput(controlPanel, 126, 24, "TOPLEFT", prizeLabel, "BOTTOMLEFT", 0, -8)
    prizeInput:SetText(prizePoolText)
    prizeInput:SetNumeric(false)
    prizeInput:SetScript("OnTextChanged", function(self)
        local ok, value = addon.RaidBingoService:SetPrizePool(self:GetText())

        if not ok then
            self:SetText(addon.RaidBingoService:GetPrizePoolText())
            return
        end

        addon.MainFrame.state.raidBingoPrizePool = value
    end)
    prizeInput:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
        addon.MainFrame:RefreshCurrentView(true)
    end)
    prizeInput:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    prizeInput:SetScript("OnEditFocusLost", function(self)
        local ok, value = addon.RaidBingoService:SetPrizePool(self:GetText())

        if ok then
            addon.MainFrame.state.raidBingoPrizePool = value
        else
            self:SetText(addon.RaidBingoService:GetPrizePoolText())
        end

        addon.MainFrame:RefreshCurrentView(true)
    end)
    self.raidBingoInputs[1] = prizeInput

    local previewButton = createButton(controlPanel, "Preview", 126, 24, "LEFT", prizeInput, "RIGHT", 8, 0, function()
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local postWinnersButton = createButton(controlPanel, "Post Winners", 126, 24, "TOPLEFT", prizeInput, "BOTTOMLEFT", 0, -10, function()
        local ok, message = service:PostPayouts(prizePoolText)

        if not ok and message then
            addon:Print(message)
        end
    end)

    if summary.prizePoolLocked then
        prizeInput:Disable()
    end

    if not summary.canLead then
        postWinnersButton:Disable()
    end

    local rolledLabel = controlPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    rolledLabel:SetPoint("TOPLEFT", prizeInput, "BOTTOMLEFT", 0, -42)
    rolledLabel:SetText("Rolled Numbers")
    styleHighlightText(rolledLabel)

    local rolledText = #session.rolledNumbers > 0 and table.concat(session.rolledNumbers, ", ") or "No numbers rolled yet."
    createReadOnlyMultiline(controlPanel, 268, 138, "TOPLEFT", rolledLabel, "BOTTOMLEFT", 0, -10, rolledText)

    local cardPanel = createPanel(host)
    cardPanel:SetSize(300, 486)
    cardPanel:SetPoint("TOPLEFT", controlPanel, "TOPRIGHT", 8, 0)

    local cardTitle = cardPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    cardTitle:SetPoint("TOPLEFT", cardPanel, "TOPLEFT", 16, -14)
    cardTitle:SetText("Your Card")
    styleTitleText(cardTitle, 18)

    local cardStatus = cardPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    cardStatus:SetPoint("TOPLEFT", cardTitle, "BOTTOMLEFT", 0, -8)
    cardStatus:SetWidth(268)
    cardStatus:SetJustifyH("LEFT")
    cardStatus:SetText(summary.locked and "Card locked. Your picks will be re-sent automatically when the leader syncs the round." or string.format("Pick exactly five numbers. Current picks: %d/5.", summary.picked))
    styleBodyText(cardStatus)

    local cardGridStartX = 8
    local cardGridStartY = -112
    local cardGridCellSize = 51
    local cardGridStepX = 56
    local cardGridStepY = 60

    local bingoLetters = { "B", "I", "N", "G", "O" }
    for index, letter in ipairs(bingoLetters) do
        local header = cardPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        header:SetPoint("TOPLEFT", cardPanel, "TOPLEFT", 24 + ((index - 1) * cardGridStepX), -84)
        header:SetText(letter)
        styleTitleText(header, 18)
    end

    local function applyRaidBingoCellTone(button, isMatched, isSelected)
        if isMatched then
            setButtonTone(button, { 0.08, 0.32, 0.16, 0.98 }, { 0.74, 0.96, 0.72, 1 })
        elseif isSelected then
            setButtonTone(button, { 0.24, 0.18, 0.06, 0.98 }, { 0.96, 0.88, 0.52, 1 })
        else
            setButtonTone(button, { 0.08, 0.1, 0.15, 0.98 }, THEME.button.text)
        end
    end

    local cells = service:GetCardCells()
    for _, cell in ipairs(cells) do
        local x = cardGridStartX + ((cell.column - 1) * cardGridStepX)
        local y = cardGridStartY - ((cell.row - 1) * cardGridStepY)

        if cell.free then
            local freeCell = CreateFrame("Frame", nil, cardPanel, "BackdropTemplate")
            freeCell:SetSize(cardGridCellSize, cardGridCellSize)
            freeCell:SetPoint("TOPLEFT", cardPanel, "TOPLEFT", x, y)
            applySurfaceStyle(freeCell, THEME.banner)

            local freeText = freeCell:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            freeText:SetPoint("CENTER", freeCell, "CENTER", 0, 0)
            freeText:SetText("FREE")
            styleTitleText(freeText, 16)
        else
            local cellButton
            cellButton = createButton(cardPanel, tostring(cell.number or "?"), cardGridCellSize, cardGridCellSize, "TOPLEFT", cardPanel, "TOPLEFT", x, y, function()
                local ok, result = service:ToggleCardSelection(cell.index)

                if not ok then
                    return
                end

                local updatedCard = service:GetCard()
                local selectedNumber = tonumber(updatedCard.numbers[cell.index])
                local isSelected = updatedCard.selectedIndices[cell.index] and true or false
                local isMatched = selectedNumber and rolledSet[selectedNumber] and isSelected and true or false

                applyRaidBingoCellTone(cellButton, isMatched, isSelected)

                if result == "locked" then
                    addon.MainFrame:RefreshCurrentView(true)
                    return
                end

                cardStatus:SetText(string.format("Pick exactly five numbers. Current picks: %d/5.", service:GetSummary().picked))
            end)

            if cellButton:GetFontString() then
                cellButton:GetFontString():SetFont(BUTTON_FONT_PATH, 18, "OUTLINE")
            end

            applyRaidBingoCellTone(cellButton, cell.matched, cell.selected)

        end
    end

    local rightPanel = createPanel(host)
    rightPanel:SetSize(342, 486)
    rightPanel:SetPoint("TOPLEFT", cardPanel, "TOPRIGHT", 8, 0)

    local picksTitle = rightPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    picksTitle:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 16, -14)
    picksTitle:SetText("Players & Winnings")
    styleTitleText(picksTitle, 18)

    local pickLines = {}
    if #selectionRows == 0 then
        pickLines[1] = "No player submissions yet."
    else
        for index, row in ipairs(selectionRows) do
            local numbers = {}
            for numberIndex, number in ipairs(row.numbers) do
                if rolledSet[tonumber(number)] then
                    numbers[numberIndex] = "|cff6ee36e" .. tostring(number) .. "|r"
                else
                    numbers[numberIndex] = tostring(number)
                end
            end

            pickLines[index] = string.format("%s [%d]: %s", shortPlayerName(row.player), row.matched, table.concat(numbers, ", "))
        end
    end

    local payoutLines = {}
    if #payouts == 0 then
        payoutLines[1] = "Enter a prize pool to preview winnings once players reach 3, 4, or 5 matches."
    else
        for index, payout in ipairs(payouts) do
            payoutLines[index] = string.format("%s: %d matches - %d", shortPlayerName(payout.player), payout.matched, payout.amount)
        end
    end

    local picksHeader = rightPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    picksHeader:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 16, -52)
    picksHeader:SetText("Locked Cards")
    styleHighlightText(picksHeader)

    createReadOnlyMultiline(rightPanel, 310, 186, "TOPLEFT", picksHeader, "BOTTOMLEFT", 0, -8, table.concat(pickLines, "\n"))

    local payoutsHeader = rightPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    payoutsHeader:SetPoint("TOPLEFT", rightPanel, "TOPLEFT", 16, -276)
    payoutsHeader:SetText("Projected Winnings")
    styleHighlightText(payoutsHeader)

    createReadOnlyMultiline(rightPanel, 310, 150, "TOPLEFT", payoutsHeader, "BOTTOMLEFT", 0, -8, table.concat(payoutLines, "\n"))
end

function addon.MainFrame:SetSelectedContent(contentId)
    self:EnsureState()

    if self.state.selectedContentId ~= contentId then
        self.state.boardDraft = nil
        self.state.boardDraftDirty = false
        self.state.boardIsEditing = false
    end

    self.state.selectedContentId = contentId
end

function addon.MainFrame:GetSelectedContentId()
    self:EnsureState()

    if self.state.selectedContentId and addon.ContentService:GetContent(self.state.selectedContentId) then
        return self.state.selectedContentId
    end

    self.state.selectedContentId = addon.ContentService:GetFirstVisibleContentId()
    return self.state.selectedContentId
end

function addon.MainFrame:GetBoardDraft(record)
    self:EnsureState()

    if not record then
        self.state.boardDraft = nil
        return nil
    end

    local draft = self.state.boardDraft

    if type(draft) ~= "table" or draft.contentId ~= record.id then
        draft = {
            body = record.body or "",
            contentId = record.id,
            guildName = record.guildName or "",
            title = record.title or "",
            type = record.type or "announcement",
        }
        self.state.boardDraft = draft
        self.state.boardDraftDirty = false
    end

    return draft
end

function addon.MainFrame:SetBoardEditing(isEditing)
    self:EnsureState()
    self.state.boardIsEditing = isEditing and true or false

    if not self.state.boardIsEditing then
        self.state.boardDraft = nil
        self.state.boardDraftDirty = false
        self.pendingRefreshAfterEdit = false
    end
end

function addon.MainFrame:SetSelectedRosterMember(memberId)
    self:EnsureState()
    self.state.selectedRosterMemberId = memberId
end

function addon.MainFrame:SetSelectedMyToon(memberId)
    self:EnsureState()
    self.state.selectedMyToonId = memberId
end

function addon.MainFrame:GetSelectedRosterMemberId()
    self:EnsureState()

    if self.state.selectedRosterMemberId and addon.RosterService:GetMember(self.state.selectedRosterMemberId) then
        return self.state.selectedRosterMemberId
    end

    self.state.selectedRosterMemberId = addon.RosterService:GetFirstVisibleMemberId()
    return self.state.selectedRosterMemberId
end

function addon.MainFrame:GetSelectedMyToonId()
    self:EnsureState()

    if self.state.selectedMyToonId and addon.RosterService:IsMyToon(self.state.selectedMyToonId) then
        return self.state.selectedMyToonId
    end

    local currentPlayerId = addon.Store:GetPlayerName()

    if currentPlayerId and addon.RosterService:IsMyToon(currentPlayerId) then
        self.state.selectedMyToonId = currentPlayerId
        return self.state.selectedMyToonId
    end

    self.state.selectedMyToonId = addon.RosterService:GetFirstMyToonId()
    return self.state.selectedMyToonId
end

function addon.MainFrame:SetSelectedGroup(groupId)
    self:EnsureState()

    if self.state.selectedGroupId ~= groupId then
        self.state.groupsDetailScrollOffset = 0
        self.state.groupsSignupScrollOffset = 0
    end

    self.state.selectedGroupId = groupId
end

function addon.MainFrame:GetSelectedGroupId()
    self:EnsureState()

    if self.state.selectedGroupId and addon.GroupsService:GetGroup(self.state.selectedGroupId) then
        return self.state.selectedGroupId
    end

    local groups = addon.GroupsService:ListGroups()
    self.state.selectedGroupId = groups[1] and groups[1].id or nil
    return self.state.selectedGroupId
end

function addon.MainFrame:RenderDashboard()
    local host = self:GetViewHost()
    local counts = addon.RosterService:GetCounts()
    local raidNeeds = addon.NeedsService:GetNeedsSnapshot("raid", true)
    local combinedNeeds = addon.NeedsService:GetCombinedNeedsSnapshot("raid", true)
    local activityNeeds = combinedNeeds.planner
    local maintenance = addon.MaintenanceService:GetSummary()
    local boardItems = addon.ContentService:ListVisibleContent()

    local function formatActivityStart(timestamp)
        local startAt = tonumber(timestamp) or 0

        if startAt <= 0 then
            return "No start time"
        end

        return date("%m/%d %H:%M", startAt)
    end

    createSectionTitle(host, "Guild Overview", 18, -18)

    createCard(host, 216, 132, "Roster", counts.total, string.format("%d online in current guild", counts.online), 18, -54)
    createCard(host, 216, 132, "Board", #boardItems, "Unified announcements, resources, and events", 246, -54)
    createCard(host, 216, 132, "Needs", combinedNeeds.totalNeeds, addon.NeedsService:GetCombinedSummaryText("raid", true), 474, -54)
    createCard(host, 216, 132, "Maintenance", maintenance.staleCount, string.format("%d records queued for review", maintenance.purgeCount), 702, -54)

    local summaryPanel = createPanel(host)
    summaryPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -214)
    summaryPanel:SetSize(916, 396)

    local summaryTitle = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    summaryTitle:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 14, -14)
    summaryTitle:SetText("Live Summary")
    styleTitleText(summaryTitle, 18)

    local lines = {
        string.format("Home guild: %s", addon.FederationService:GetHomeGuild() or "Not available"),
        string.format("Linked guilds: %d", #addon.FederationService:GetLinkedGuilds()),
        string.format("Raid shortages: tanks %d, healers %d, melee %d, ranged %d, dps %d", raidNeeds.deficits.tanks, raidNeeds.deficits.healers, raidNeeds.deficits.melee, raidNeeds.deficits.ranged, raidNeeds.deficits.dps),
        string.format("Planner shortages: %d open slots across %d activities", activityNeeds.openSlots or 0, activityNeeds.openActivities or 0),
        string.format("Stale members: %d", maintenance.staleCount),
        string.format("Pending cleanup: %d", maintenance.purgeCount),
    }

    for index = 1, math.min(activityNeeds.openActivities or 0, 2) do
        local activity = activityNeeds.activities[index]

        if activity then
            lines[#lines + 1] = string.format(
                "Activity need: %s | %s | %s",
                activity.eventName or "Activity",
                addon.NeedsService:FormatDeficitText(activity.deficits, { "tanks", "healers", "melee", "ranged" }, "covered"),
                formatActivityStart(activity.startAt)
            )
        end
    end

    for index, line in ipairs(lines) do
        local text = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 16, -48 - ((index - 1) * 24))
        text:SetText(line)
        styleBodyText(text)
    end
end

function addon.MainFrame:RenderBoard()
    local host = self:GetViewHost()
    local guildOptions = addon.FederationService:GetAllGuildKeys()
    local records = addon.ContentService:ListVisibleContent({
        searchText = self.state.boardFilterText,
        typeFilter = self.state.boardFilterType,
        guildName = self.state.boardFilterGuild,
        pinnedOnly = self.state.boardFilterPinnedOnly,
    })
    local selectedContentId = self:GetSelectedContentId()
    local selectedRecord = selectedContentId and addon.ContentService:GetContent(selectedContentId) or nil
    local canManageBoard = addon.ContentService:CanManageBoard()
    local isVisible = false

    createSectionTitle(host, "Unified Board", 18, -18)

    for _, record in ipairs(records) do
        if record.id == selectedContentId then
            isVisible = true
            break
        end
    end

    if not isVisible then
        self:SetSelectedContent(records[1] and records[1].id or nil)
        selectedContentId = self:GetSelectedContentId()
        selectedRecord = selectedContentId and addon.ContentService:GetContent(selectedContentId) or nil
    end

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    listPanel:SetSize(280, 580)

    local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -12)
    listTitle:SetText("Board Posts")
    styleTitleText(listTitle, 16)

    local filterInput = createInput(listPanel, 220, 22, "TOPLEFT", listPanel, "TOPLEFT", 12, -44)
    filterInput:SetText(self.state.boardFilterText or "")
    filterInput:SetScript("OnEnterPressed", function(self)
        addon.MainFrame.state.boardFilterText = self:GetText()
        addon.MainFrame:RefreshCurrentView()
    end)

    local searchHint = listPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    searchHint:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -70)
    searchHint:SetText("Press Enter to search")
    styleBodyText(searchHint, true)

    local filterRow = CreateFrame("Frame", nil, listPanel)
    filterRow:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -96)
    filterRow:SetSize(232, 22)

    createButton(filterRow, "Type: " .. self.state.boardFilterType, 108, 22, "TOPLEFT", filterRow, "TOPLEFT", 0, 0, function(button)
        addon.MainFrame.state.boardFilterType = getNextOption(BOARD_TYPE_OPTIONS, addon.MainFrame.state.boardFilterType)
        button:SetText("Type: " .. addon.MainFrame.state.boardFilterType)
        addon.MainFrame:RefreshCurrentView()
    end)

    local function getBoardGuildButtonText()
        local value = addon.MainFrame.state.boardFilterGuild or "all"
        return cropText("Guild: " .. value, 16)
    end

    createButton(filterRow, getBoardGuildButtonText(), 116, 22, "TOPLEFT", filterRow, "TOPLEFT", 116, 0, function(button)
        local allGuilds = { "all" }

        for _, guildKey in ipairs(guildOptions) do
            allGuilds[#allGuilds + 1] = guildKey
        end

        addon.MainFrame.state.boardFilterGuild = getNextOption(allGuilds, addon.MainFrame.state.boardFilterGuild)
        button:SetText(getBoardGuildButtonText())
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(listPanel, self.state.boardFilterPinnedOnly and "Pinned Only" or "All Posts", 224, 22, "TOPLEFT", listPanel, "TOPLEFT", 12, -128, function(button)
        addon.MainFrame.state.boardFilterPinnedOnly = not addon.MainFrame.state.boardFilterPinnedOnly
        button:SetText(addon.MainFrame.state.boardFilterPinnedOnly and "Pinned Only" or "All Posts")
        addon.MainFrame:RefreshCurrentView()
    end)

    local newPostButton = createButton(listPanel, "New Post", 100, 22, "TOPRIGHT", listPanel, "TOPRIGHT", -12, -10, function()
        local record = addon.ContentService:Create("announcement", "New Post", "", addon.Store:GetDisplayGuildKey() or "Unguilded")
        if not record then
            return
        end
        addon.MainFrame:SetSelectedContent(record.id)
        addon.MainFrame:RefreshCurrentView()
    end)

    if not canManageBoard then
        newPostButton:Disable()
    end

    for index, record in ipairs(records) do
        if index > 18 then
            break
        end

        local button = createButton(listPanel, string.format("%s%s", record.title, record.pinned and " | Pinned" or ""), 256, 24, "TOPLEFT", listPanel, "TOPLEFT", 12, -168 - ((index - 1) * 28), function()
            addon.MainFrame:SetSelectedContent(record.id)
            addon.MainFrame:RefreshCurrentView()
        end)
    end

    local editorPanel = createPanel(host)
    editorPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 16, 0)
    editorPanel:SetSize(620, 580)

    local editorTitle = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    editorTitle:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -14)
    editorTitle:SetText(selectedRecord and selectedRecord.title or "No post selected")
    styleTitleText(editorTitle, 18)

    if not selectedRecord then
        self.state.boardDraft = nil
        self.state.boardDraftDirty = false
        self.state.boardIsEditing = false
        self.boardEditorInputs = nil
        return
    end

    local draft = self:GetBoardDraft(selectedRecord)
    local isEditing = self.state.boardIsEditing and canManageBoard and true or false

    local titleLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    titleLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 18, -54)
    titleLabel:SetText("Title")
    styleHighlightText(titleLabel)

    local titleInput = createInput(editorPanel, 308, 24, "TOPLEFT", editorPanel, "TOPLEFT", 18, -76)
    titleInput:SetText(draft.title or "")
    if isEditing then
        titleInput:SetScript("OnTextChanged", function(self)
            addon.MainFrame:GetBoardDraft(selectedRecord).title = self:GetText()
            addon.MainFrame.state.boardDraftDirty = true
        end)
    else
        titleInput:Disable()
    end

    local typeLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    typeLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 344, -54)
    typeLabel:SetText("Type")
    styleHighlightText(typeLabel)

    local typeInput = createInput(editorPanel, 102, 24, "TOPLEFT", editorPanel, "TOPLEFT", 344, -76)
    typeInput:SetText(draft.type or "announcement")
    if isEditing then
        typeInput:SetScript("OnTextChanged", function(self)
            addon.MainFrame:GetBoardDraft(selectedRecord).type = self:GetText()
            addon.MainFrame.state.boardDraftDirty = true
        end)
        typeInput:SetScript("OnEscapePressed", function(self)
            self:ClearFocus()
        end)
    else
        typeInput:Disable()
    end

    local guildLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    guildLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 462, -54)
    guildLabel:SetText("Guild")
    styleHighlightText(guildLabel)

    local guildInput = createInput(editorPanel, 140, 24, "TOPLEFT", editorPanel, "TOPLEFT", 462, -76)
    guildInput:SetText(draft.guildName or "")
    if isEditing then
        guildInput:SetScript("OnTextChanged", function(self)
            addon.MainFrame:GetBoardDraft(selectedRecord).guildName = self:GetText()
            addon.MainFrame.state.boardDraftDirty = true
        end)
    else
        guildInput:Disable()
    end

    local bodyLabel = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    bodyLabel:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 18, -116)
    bodyLabel:SetText("Body")
    styleHighlightText(bodyLabel)

    local bodyInput = nil

    if isEditing then
        bodyInput = createMultilineInput(editorPanel, 584, 346, "TOPLEFT", editorPanel, "TOPLEFT", 18, -138)
        bodyInput:SetText(draft.body or "")
        local bodyInputOnTextChanged = bodyInput:GetScript("OnTextChanged")
        bodyInput:SetScript("OnTextChanged", function(self)
            if bodyInputOnTextChanged then
                bodyInputOnTextChanged(self)
            end

            addon.MainFrame:GetBoardDraft(selectedRecord).body = self:GetText()
            addon.MainFrame.state.boardDraftDirty = true
        end)

        self.boardEditorInputs = {
            bodyInput,
            guildInput,
            titleInput,
            typeInput,
        }
    else
        createReadOnlyMultiline(editorPanel, 584, 346, "TOPLEFT", editorPanel, "TOPLEFT", 18, -138, selectedRecord.body or "")
        self.boardEditorInputs = nil
    end

    local meta = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    meta:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 18, -494)
    meta:SetText(string.format("By %s | %s | revision %d", selectedRecord.author or "unknown", date("%Y-%m-%d", selectedRecord.updatedAt or time()), selectedRecord.revision or 0))
    styleBodyText(meta, true)

    local actionRow = CreateFrame("Frame", nil, editorPanel)
    actionRow:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 18, -532)
    actionRow:SetSize(472, 24)

    if isEditing then
        createButton(actionRow, "Save Post", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", 0, 0, function()
            local currentDraft = addon.MainFrame:GetBoardDraft(selectedRecord)

            addon.ContentService:Update(selectedRecord.id, {
                title = currentDraft.title,
                type = currentDraft.type,
                guildName = currentDraft.guildName,
                body = currentDraft.body,
            })
            addon.MainFrame:SetBoardEditing(false)
            addon.MainFrame:RefreshCurrentView()
        end)

        createButton(actionRow, "Cancel", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", 118, 0, function()
            addon.MainFrame:SetBoardEditing(false)
            addon.MainFrame:RefreshCurrentView()
        end)
    else
        local editButton = createButton(actionRow, "Edit", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", 0, 0, function()
            addon.MainFrame:SetBoardEditing(true)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        if not canManageBoard then
            editButton:Disable()
        end
    end

    local pinButton = createButton(actionRow, selectedRecord.pinned and "Unpin" or "Pin", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", isEditing and 236 or 118, 0, function()
        addon.ContentService:TogglePinned(selectedRecord.id)
        addon.MainFrame:RefreshCurrentView()
    end)

    local deleteButton = createButton(actionRow, "Delete", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", isEditing and 354 or 236, 0, function()
        addon.ContentService:Delete(selectedRecord.id)
        addon.MainFrame:SetBoardEditing(false)
        addon.MainFrame:SetSelectedContent(nil)
        addon.MainFrame:RefreshCurrentView()
    end)

    if not canManageBoard then
        pinButton:Disable()
        deleteButton:Disable()
    end

    if isEditing and bodyInput then
        bodyInput:SetFocus()
    end
end

function addon.MainFrame:RenderRoster()
    local host = self:GetViewHost()
    local members = addon.RosterService:ListVisibleMembers()
    local filteredMembers = {}
    local canExportRoster = addon.GroupsService and addon.GroupsService.CanManageGroups and addon.GroupsService:CanManageGroups() or false
    local searchText = lowerText(self.state.rosterSearchText)
    local sortKey = self.state.rosterSortKey or "Toon"
    local selectedColumns = normalizeRosterColumns(self.state.rosterColumns)
    local headers = buildLegacyRosterHeaders(selectedColumns)

    createSectionTitle(host, "Guild Roster", 18, -18)

    if canExportRoster then
        createButton(host, "Export Pane CSV", 124, 24, "TOPLEFT", host, "TOPLEFT", 162, -22, function()
            addon.MainFrame:ShowCsvExportDialog(
                "Roster Pane CSV Export",
                addon.MainFrame:BuildRosterExportCsv(members),
                "Copy this text and save it as a .csv file. This export reflects the roster pane, including any My Toons fields layered onto visible members."
            )
        end)

        createButton(host, "Export Join Dates", 136, 24, "LEFT", host, "LEFT", 294, -22, function()
            addon.MainFrame:ShowCsvExportDialog(
                "Guild Join Date CSV Export",
                addon.MainFrame:BuildGuildJoinDateExportCsv(addon.RosterService:ListCurrentGuildMembersRaw()),
                "Copy this text and save it as a .csv file. This export reads the raw current-guild roster records, without My Toons overlays, so officers and the guild master can look up stored join dates for guild members. Blank join-date rows mean GH3 has not populated that member's date yet."
            )
        end)
    end

    for _, member in ipairs(members) do
        if (not self.state.rosterOnlineOnly or member.online) and memberMatchesRosterSearch(member, searchText) then
            filteredMembers[#filteredMembers + 1] = member
        end
    end

    table.sort(filteredMembers, function(left, right)
        local leftValue = getLegacyRosterSortValue(left, sortKey)
        local rightValue = getLegacyRosterSortValue(right, sortKey)

        if leftValue == rightValue then
            return lowerText(left.fullName) < lowerText(right.fullName)
        end

        if self.state.rosterSortDescending then
            return leftValue > rightValue
        end

        return leftValue < rightValue
    end)

    local totalPages = math.max(1, math.ceil(#filteredMembers / ROSTER_PAGE_SIZE))
    self.state.rosterPage = math.max(1, math.min(self.state.rosterPage or 1, totalPages))
    local startIndex = ((self.state.rosterPage - 1) * ROSTER_PAGE_SIZE) + 1
    local endIndex = math.min(#filteredMembers, startIndex + ROSTER_PAGE_SIZE - 1)

    local rosterPanel = createPanel(host)
    rosterPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    rosterPanel:SetSize(916, 580)

    local showOnlineCheckbox = createCheckbox(rosterPanel, "Show Online Players Only", "TOPLEFT", rosterPanel, "TOPLEFT", 10, -12, self.state.rosterOnlineOnly, function(isChecked)
        addon.MainFrame.state.rosterOnlineOnly = isChecked
        addon.MainFrame.state.rosterPage = 1
        addon.MainFrame:RefreshCurrentView()
    end)

    local toolbarRow = CreateFrame("Frame", nil, rosterPanel)
    toolbarRow:SetPoint("TOPRIGHT", rosterPanel, "TOPRIGHT", -12, -10)
    toolbarRow:SetSize(520, 24)

    local searchInput = createInput(toolbarRow, 160, 24, "TOPRIGHT", toolbarRow, "TOPRIGHT", 0, 0)
    searchInput:SetText(self.state.rosterSearchText or "")
    searchInput:SetScript("OnEnterPressed", function(self)
        addon.MainFrame.state.rosterSearchText = self:GetText() or ""
        addon.MainFrame.state.rosterPage = 1
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(toolbarRow, "Clear", 52, 24, "RIGHT", searchInput, "LEFT", -6, 0, function()
        addon.MainFrame.state.rosterSearchText = ""
        addon.MainFrame.state.rosterPage = 1
        addon.MainFrame:RefreshCurrentView()
    end)

    local searchLabel = toolbarRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    searchLabel:SetPoint("RIGHT", searchInput, "LEFT", -66, 0)
    searchLabel:SetText("Search")
    styleHighlightText(searchLabel)

    local selectorGroup = CreateFrame("Frame", nil, toolbarRow)
    selectorGroup:SetSize(250, 24)
    selectorGroup:SetPoint("RIGHT", searchLabel, "LEFT", -18, 0)

    local selectorLabel = selectorGroup:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    selectorLabel:SetPoint("LEFT", selectorGroup, "LEFT", 0, 0)
    selectorLabel:SetText("Columns")
    styleHighlightText(selectorLabel)

    local columnDropdown = createMenuButton(selectorGroup, "Columns", 170, 24, "LEFT", selectorLabel, "RIGHT", 8, 0)
    columnDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, label in ipairs(LEGACY_ROSTER_COLUMN_ORDER) do
                menuOptions[#menuOptions + 1] = {
                    text = label,
                    value = label,
                    checked = addon.MainFrame.state.rosterColumns[label] and true or false,
                    isNotRadio = true,
                    keepShownOnClick = true,
                    onClick = function(value)
                        local selectedCount = countSelectedRosterColumns(addon.MainFrame.state.rosterColumns)

                        if addon.MainFrame.state.rosterColumns[value] then
                            addon.MainFrame.state.rosterColumns[value] = false
                        elseif selectedCount < LEGACY_ROSTER_MAX_COLUMNS then
                            addon.MainFrame.state.rosterColumns[value] = true
                        else
                            addon:Print(string.format("You can select up to %d roster columns.", LEGACY_ROSTER_MAX_COLUMNS))
                            return
                        end

                        addon.MainFrame.state.rosterPage = 1
                        addon.MainFrame:RefreshCurrentView()
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(columnDropdown, string.format("Columns Selected (%d)", countSelectedRosterColumns(selectedColumns)))

    local headerFrame = CreateFrame("Frame", nil, rosterPanel)
    headerFrame:SetSize(896, 20)
    headerFrame:SetPoint("TOPLEFT", rosterPanel, "TOPLEFT", 10, -62)

    local headerWidth = (headerFrame:GetWidth() - 5) / #headers

    for index, headerText in ipairs(headers) do
        local header = createButton(headerFrame, "", headerWidth, 20, index == 1 and "TOPLEFT" or "LEFT", index == 1 and headerFrame or headerFrame.headers[index - 1], index == 1 and "TOPLEFT" or "RIGHT", index == 1 and 2 or 0, 0, nil)

        if headerText == "Actions" then
            header:Disable()
            header:SetText("Actions")
            styleActionButton(header)
            styleActionText(header:GetFontString())
        else
            header:SetText(headerText .. (sortKey == headerText and (self.state.rosterSortDescending and " v" or " ^") or ""))
            header:SetScript("OnClick", function()
                if addon.MainFrame.state.rosterSortKey == headerText then
                    addon.MainFrame.state.rosterSortDescending = not addon.MainFrame.state.rosterSortDescending
                else
                    addon.MainFrame.state.rosterSortKey = headerText
                    addon.MainFrame.state.rosterSortDescending = false
                end

                addon.MainFrame:RefreshCurrentView()
            end)
            styleButton(header)
        end

        headerFrame.headers = headerFrame.headers or {}
        headerFrame.headers[index] = header
    end

    local content = CreateFrame("Frame", nil, rosterPanel)
    content:SetSize(896, 400)
    content:SetPoint("TOPLEFT", rosterPanel, "TOPLEFT", 10, -86)

    local function createRosterCell(parent, width, xOffset, textValue)
        local cell = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
        cell:SetSize(width, 20)
        cell:SetPoint("LEFT", parent, "LEFT", xOffset, 0)
        cell:SetAutoFocus(false)
        cell:SetFontObject("GameFontHighlightSmall")
        cell:SetJustifyH("LEFT")
        styleInputText(cell)
        cell:Disable()
        cell:SetText(tostring(textValue or ""))
        cell:SetCursorPosition(0)
        cell:SetScript("OnMouseDown", function(self)
            self:Enable()
            self:SetFocus()
            self:HighlightText()
        end)
        cell:SetScript("OnEditFocusLost", function(self)
            self:Disable()
            self:HighlightText(0, 0)
        end)
        cell:SetScript("OnCursorChanged", function(self)
            self:SetCursorPosition(0)
        end)
        cell:SetScript("OnKeyDown", function(self, key)
            if key == "A" and IsControlKeyDown() then
                self:HighlightText()
            end
        end)
        return cell
    end

    if #filteredMembers == 0 then
        local empty = rosterPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", content, "TOPLEFT", 4, -4)
        empty:SetText("No roster rows matched the current filters.")
    else
        for index = startIndex, endIndex do
            local member = filteredMembers[index]
            local rowIndex = index - startIndex
            local row = CreateFrame("Frame", nil, content)
            local rowHeight = 20
            local cellOffset = 0

            row:SetSize(headerFrame:GetWidth(), rowHeight)
            row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(rowHeight * rowIndex))

            local background = row:CreateTexture(nil, "BACKGROUND")
            background:SetAllPoints(row)
            background:SetColorTexture(member.online and 0.2 or 0.1, member.online and 0.6 or 0.1, member.online and 0.2 or 0.1, 0.7)

            local cells = {}
            for headerIndex, headerText in ipairs(headers) do
                if headerText == "Actions" then
                    local actionContainer = CreateFrame("Frame", nil, row)
                    actionContainer:SetSize(headerWidth, rowHeight)
                    actionContainer:SetPoint("LEFT", row, "LEFT", cellOffset, 0)

                    local inviteButton = createButton(actionContainer, "I", 20, 20, "LEFT", actionContainer, "LEFT", 0, 0, function()
                        if C_PartyInfo and C_PartyInfo.InviteUnit then
                            C_PartyInfo.InviteUnit(member.fullName)
                        else
                            InviteUnit(member.fullName)
                        end

                        if GetNumGroupMembers() >= 5 and not IsInRaid() then
                            ConvertToRaid()
                        end
                    end)
                    styleActionButton(inviteButton)

                    local whisperButton = createButton(actionContainer, "W", 20, 20, "LEFT", inviteButton, "RIGHT", 5, 0, function()
                        ChatFrame_OpenChat("/w " .. (member.fullName or "") .. " ", DEFAULT_CHAT_FRAME)
                    end)
                    styleActionButton(whisperButton)

                    cells[headerIndex] = actionContainer
                else
                    local value = getLegacyRosterValue(member, headerText)
                    local cell = createRosterCell(row, headerWidth, cellOffset, value)

                    if headerText == "Toon" then
                        local classColor = RAID_CLASS_COLORS and RAID_CLASS_COLORS[member.classFile or ""]
                        if classColor then
                            cell:SetTextColor(classColor.r, classColor.g, classColor.b)
                        end
                    end

                    cells[headerIndex] = cell
                end

                cellOffset = cellOffset + headerWidth
            end

            local separator = row:CreateTexture(nil, "ARTWORK")
            separator:SetColorTexture(0.5, 0.5, 0.5, 1)
            separator:SetHeight(1)
            separator:SetPoint("TOPLEFT", row, "BOTTOMLEFT", 0, 0)
            separator:SetPoint("TOPRIGHT", row, "BOTTOMRIGHT", 0, 0)
        end
    end

    local toonCountText = rosterPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    toonCountText:SetPoint("BOTTOM", rosterPanel, "BOTTOM", 0, 35)
    toonCountText:SetText("Total Toons: " .. #filteredMembers)
    styleHighlightText(toonCountText)

    local pageIndicator = rosterPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    pageIndicator:SetPoint("BOTTOM", rosterPanel, "BOTTOM", 0, 10)
    pageIndicator:SetText(string.format("Page %d of %d", self.state.rosterPage, totalPages))
    styleHighlightText(pageIndicator)

    createButton(rosterPanel, "Previous", 100, 30, "BOTTOMRIGHT", rosterPanel, "BOTTOMRIGHT", -120, 10, function()
        if addon.MainFrame.state.rosterPage > 1 then
            addon.MainFrame.state.rosterPage = addon.MainFrame.state.rosterPage - 1
            addon.MainFrame:RefreshCurrentView()
        end
    end)

    createButton(rosterPanel, "Next", 100, 30, "BOTTOMRIGHT", rosterPanel, "BOTTOMRIGHT", -10, 10, function()
        if addon.MainFrame.state.rosterPage < totalPages then
            addon.MainFrame.state.rosterPage = addon.MainFrame.state.rosterPage + 1
            addon.MainFrame:RefreshCurrentView()
        end
    end)

    createButton(rosterPanel, "Reset", 100, 30, "BOTTOMLEFT", rosterPanel, "BOTTOMLEFT", 10, 10, function()
        addon.MainFrame.state.rosterOnlineOnly = false
        addon.MainFrame.state.rosterSearchText = ""
        addon.MainFrame.state.rosterSortKey = "Toon"
        addon.MainFrame.state.rosterSortDescending = false
        addon.MainFrame.state.rosterPage = 1
        addon.MainFrame.state.rosterColumns = copyMap(DEFAULT_ROSTER_COLUMNS)
        addon.MainFrame:RefreshCurrentView()
    end)
end

function addon.MainFrame:RenderMemberPane()
    local host = self:GetViewHost()
    local members = addon.RosterService:ListMyToons()
    local selectedMemberId = self:GetSelectedMyToonId()
    local selectedMember = selectedMemberId and addon.RosterService:GetMyToonMember(selectedMemberId) or nil
    local isCurrentPlayerToon = selectedMember and selectedMember.id == addon.Store:GetPlayerName()

    if isCurrentPlayerToon then
        addon.RosterService:RefreshLivePlayerMember()
        selectedMember = addon.RosterService:GetMyToonMember(selectedMemberId) or selectedMember
    end

    createSectionTitle(host, "Member Pane", 18, -18)

    createButton(host, "Export All", 112, 24, "TOPRIGHT", host, "TOPRIGHT", -18, -22, function()
        addon.MainFrame:ShowCsvExportDialog("My Toons Export", addon.MainFrame:BuildMyToonsExportCsv(members))
    end)

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    listPanel:SetSize(232, 580)

    local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -12)
    listTitle:SetText("My Toons")
    styleTitleText(listTitle, 16)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -38)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -30, 12)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(188, math.max(#members * 28, 1))
    listScrollFrame:SetScrollChild(listContent)

    if #members == 0 then
        local emptyText = listContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptyText:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
        emptyText:SetWidth(188)
        emptyText:SetJustifyH("LEFT")
        emptyText:SetText("Log into a character once to add it to My Toons.")
        styleBodyText(emptyText, true)
    end

    for index, member in ipairs(members) do
        local button = createButton(listContent, member.fullName or "Unknown", 188, 24, "TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 28), function()
            addon.MainFrame:SetSelectedMyToon(member.id)
            addon.MainFrame:RefreshCurrentView()
        end)
    end

    local editorPanel = createPanel(host)
    editorPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 16, 0)
    editorPanel:SetSize(668, 580)

    local editorTitle = editorPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    editorTitle:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -14)
    editorTitle:SetText(selectedMember and selectedMember.fullName or "No toon selected")
    styleTitleText(editorTitle, 18)

    if not selectedMember then
        return
    end

    local roleOptions = addon.RosterService:GetRoleOptions(selectedMember)
    local profileOptions = addon.RosterService:GetProfileOptions(selectedMember)
    local myToonOptions = {}
    local joinYear, joinMonth, joinDay = splitIsoDateParts(selectedMember.guildJoinDate)
    local birthYear, birthMonth, birthDay = splitIsoDateParts(selectedMember.birthdate)
    local joinDateValue = buildIsoDate(joinYear, joinMonth, joinDay) or ""
    local birthdateValue = buildIsoDate(birthYear, birthMonth, birthDay) or ""

    for _, member in ipairs(members) do
        myToonOptions[#myToonOptions + 1] = member.fullName
    end

    local scrollFrame = CreateFrame("ScrollFrame", nil, editorPanel, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", editorPanel, "TOPLEFT", 14, -46)
    scrollFrame:SetPoint("BOTTOMRIGHT", editorPanel, "BOTTOMRIGHT", -28, 14)

    local formPanel = CreateFrame("Frame", nil, scrollFrame)
    formPanel:SetSize(620, 1)
    scrollFrame:SetScrollChild(formPanel)

    local sectionWidth = 620
    local twoColumnWidth = 294
    local threeColumnWidth = 193
    local sectionGap = 10
    local rowGap = 8
    local cellHeight = 42
    local inputs = {}
    local sectionCursorY = 0
    local calendarPanel
    local calendarTitle
    local calendarDayButtons = {}
    local activeCalendarField
    local activeCalendarButton
    local memberCalendarMonth = tonumber(date("%m")) or 1
    local memberCalendarYear = tonumber(date("%Y")) or 2000

    local function setInputValue(input, value)
        if type(value) == "table" then
            input:SetText(joinList(value))
        elseif value ~= nil then
            input:SetText(tostring(value))
        else
            input:SetText("")
        end
    end

    local function createSection(title, height)
        local section = CreateFrame("Frame", nil, formPanel)
        section:SetPoint("TOPLEFT", formPanel, "TOPLEFT", 0, sectionCursorY)
        section:SetSize(sectionWidth, height)

        local heading = section:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        heading:SetPoint("TOPLEFT", section, "TOPLEFT", 4, -4)
        heading:SetText(title)
        styleTitleText(heading, 16)

        sectionCursorY = sectionCursorY - height - sectionGap
        return section
    end

    local function createCell(parent, width, point, relativeTo, relativePoint, x, y, titleText)
        local cell = CreateFrame("Frame", nil, parent)
        cell:SetSize(width, cellHeight)
        cell:SetPoint(point, relativeTo, relativePoint, x, y)

        local title = cell:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        title:SetPoint("TOPLEFT", cell, "TOPLEFT", 2, -2)
        title:SetText(titleText)
        styleHighlightText(title)

        return cell
    end

    local function createCellInput(cell, key, value, readOnly, width)
        local input = createInput(cell, width or (cell:GetWidth() - 20), 24, "TOPLEFT", cell, "TOPLEFT", 10, -18)
        setInputValue(input, value)

        if readOnly then
            input:Disable()
        end

        if key then
            inputs[key] = input
        end

        return input
    end

    local function createCellDropdown(cell, width)
        return createMenuButton(cell, "Select", width or (cell:GetWidth() - 20), 24, "TOPLEFT", cell, "TOPLEFT", 10, -18)
    end

    local myToonMeta = selectedMemberId and addon.RosterService:GetMyToonRecord(selectedMemberId) or nil
    local joinDateCandidate = selectedMemberId and addon.RosterService:GetJoinDateCandidate(selectedMemberId) or nil

    local function getDateValue(fieldKey)
        return fieldKey == "birthdate" and birthdateValue or joinDateValue
    end

    local function setDateValue(fieldKey, value)
        if fieldKey == "birthdate" then
            birthdateValue = value or ""
        else
            joinDateValue = value or ""
        end
    end

    local function normalizeCalendarMonth()
        while memberCalendarMonth < 1 do
            memberCalendarMonth = memberCalendarMonth + 12
            memberCalendarYear = memberCalendarYear - 1
        end

        while memberCalendarMonth > 12 do
            memberCalendarMonth = memberCalendarMonth - 12
            memberCalendarYear = memberCalendarYear + 1
        end
    end

    local function refreshCalendarButtonText(button, value, emptyLabel)
        if button then
            button:SetText(value ~= "" and value or emptyLabel)
        end
    end

    local birthdateButton
    local joinDateButton
    local calendarYearInput
        local refreshMemberCalendar

    local function applyMemberCalendarYearJump()
        local requestedYear = math.floor(tonumber(calendarYearInput and calendarYearInput:GetText()) or 0)

        if requestedYear < 1900 or requestedYear > 2100 then
            if calendarYearInput then
                calendarYearInput:SetText(tostring(memberCalendarYear))
            end
            return
        end

        memberCalendarYear = requestedYear
        refreshMemberCalendar()
    end

    local function refreshDateButtons()
        refreshCalendarButtonText(birthdateButton, birthdateValue, "Pick Birthdate")
        refreshCalendarButtonText(joinDateButton, joinDateValue, "Pick Join Date")
    end

    function refreshMemberCalendar()
        normalizeCalendarMonth()

        local monthNames = {
            "January", "February", "March", "April", "May", "June",
            "July", "August", "September", "October", "November", "December",
        }
        local firstDay = time({ year = memberCalendarYear, month = memberCalendarMonth, day = 1, hour = 12, min = 0, sec = 0 })
        local firstWeekday = date("*t", firstDay).wday
        local daysInMonth = getDaysInMonth(memberCalendarYear, memberCalendarMonth)

        calendarTitle:SetText(string.format("%s %d", monthNames[memberCalendarMonth] or "Month", memberCalendarYear))

        if calendarYearInput then
            calendarYearInput:SetText(tostring(memberCalendarYear))
        end

        for buttonIndex, button in ipairs(calendarDayButtons) do
            local dayNumber = buttonIndex - firstWeekday + 1

            if dayNumber >= 1 and dayNumber <= daysInMonth then
                local selectedDate = string.format("%04d-%02d-%02d", memberCalendarYear, memberCalendarMonth, dayNumber)
                button._selectedDate = selectedDate
                button:SetText(getDateValue(activeCalendarField) == selectedDate and ("[" .. tostring(dayNumber) .. "]") or tostring(dayNumber))
                button:Enable()
            else
                button._selectedDate = nil
                button:SetText("")
                button:Disable()
            end
        end
    end

    local function openMemberCalendar(fieldKey, anchorButton)
        activeCalendarField = fieldKey
        activeCalendarButton = anchorButton

        local value = getDateValue(fieldKey)
        memberCalendarYear = tonumber(value:match("^(%d%d%d%d)%-%d%d%-%d%d$")) or tonumber(date("%Y")) or memberCalendarYear
        memberCalendarMonth = tonumber(value:match("^%d%d%d%d%-(%d%d)%-%d%d$")) or tonumber(date("%m")) or memberCalendarMonth

        calendarPanel:ClearAllPoints()
        calendarPanel:SetPoint("TOPLEFT", anchorButton, "BOTTOMLEFT", 0, -6)
        calendarPanel:Show()
        refreshMemberCalendar()
    end

    local topSection = createSection("Profile", 224)
    local nameCell = createCell(topSection, twoColumnWidth, "TOPLEFT", topSection, "TOPLEFT", 12, -34, "Name")
    createCellInput(nameCell, "fullName", selectedMember.fullName, true)

    local specCell = createCell(topSection, twoColumnWidth, "LEFT", nameCell, "RIGHT", 8, 0, "Spec")
    createCellInput(specCell, "spec", selectedMember.spec)

    local realmCell = createCell(topSection, twoColumnWidth, "TOPLEFT", nameCell, "BOTTOMLEFT", 0, -rowGap, "Realm")
    createCellInput(realmCell, "realm", selectedMember.realm, true)

    local birthdateCell = createCell(topSection, twoColumnWidth, "LEFT", realmCell, "RIGHT", 8, 0, "Birthdate (Optional)")
    birthdateButton = createButton(birthdateCell, "Pick Birthdate", 210, 22, "TOPLEFT", birthdateCell, "TOPLEFT", 10, -18, function(self)
        if calendarPanel:IsShown() and activeCalendarField == "birthdate" then
            calendarPanel:Hide()
            return
        end

        openMemberCalendar("birthdate", self)
    end)
    createButton(birthdateCell, "Clear", 60, 22, "LEFT", birthdateButton, "RIGHT", 6, 0, function()
        setDateValue("birthdate", "")
        refreshDateButtons()
        if activeCalendarField == "birthdate" and calendarPanel then
            calendarPanel:Hide()
        end
    end)

    local factionCell = createCell(topSection, twoColumnWidth, "TOPLEFT", realmCell, "BOTTOMLEFT", 0, -rowGap, "Faction")
    createCellInput(factionCell, "faction", selectedMember.faction, true)

    local battleTagCell = createCell(topSection, twoColumnWidth, "LEFT", factionCell, "RIGHT", 8, 0, "BattleTag (Optional)")
    createCellInput(battleTagCell, "battleTag", selectedMember.battleTag)

    local mainCharacterCell = createCell(topSection, twoColumnWidth, "TOPLEFT", factionCell, "BOTTOMLEFT", 0, -rowGap, "Main Character")
    local mainCharDropdown = createCellDropdown(mainCharacterCell, 246)
    initializeSingleSelectMenuButton(mainCharDropdown, myToonOptions, selectedMember.mainCharacter or selectedMember.fullName)

    local noteCell = createCell(topSection, twoColumnWidth, "LEFT", mainCharacterCell, "RIGHT", 8, 0, "Note")
    createCellInput(noteCell, "note", selectedMember.note)

    local middleSection = createSection("Guild", joinDateCandidate and 232 or 186)
    local guildCell = createCell(middleSection, twoColumnWidth, "TOPLEFT", middleSection, "TOPLEFT", 12, -34, "Guild Name")
    createCellInput(guildCell, "guildName", selectedMember.guildName, true)

    local statsCell = createCell(middleSection, twoColumnWidth, "TOPLEFT", guildCell, "BOTTOMLEFT", 0, -rowGap, "Stats")
    statsCell:SetHeight(54)
    local levelLabel = statsCell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    levelLabel:SetPoint("TOPLEFT", statsCell, "TOPLEFT", 10, -18)
    levelLabel:SetText("Level")
    styleBodyText(levelLabel, true)
    local levelInput = createInput(statsCell, 86, 24, "TOPLEFT", statsCell, "TOPLEFT", 10, -28)
    setInputValue(levelInput, selectedMember.level)
    levelInput:Disable()
    inputs.level = levelInput

    local itemLevelLabel = statsCell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    itemLevelLabel:SetPoint("TOPLEFT", statsCell, "TOPLEFT", 144, -18)
    itemLevelLabel:SetText("Item Level")
    styleBodyText(itemLevelLabel, true)
    local itemLevelInput = createInput(statsCell, 110, 24, "TOPLEFT", statsCell, "TOPLEFT", 144, -28)
    setInputValue(itemLevelInput, selectedMember.itemLevel)
    inputs.itemLevel = itemLevelInput

    local classCell = createCell(middleSection, twoColumnWidth, "LEFT", statsCell, "RIGHT", 8, 0, "Class")
    classCell:SetHeight(54)
    createCellInput(classCell, "className", selectedMember.className, true)

    local joinDateCell = createCell(middleSection, twoColumnWidth, "TOPLEFT", statsCell, "BOTTOMLEFT", 0, -rowGap, "Guild Join Date (Optional)")
    joinDateButton = createButton(joinDateCell, "Pick Join Date", 210, 22, "TOPLEFT", joinDateCell, "TOPLEFT", 10, -18, function(self)
        if calendarPanel:IsShown() and activeCalendarField == "guildJoinDate" then
            calendarPanel:Hide()
            return
        end

        openMemberCalendar("guildJoinDate", self)
    end)
    createButton(joinDateCell, "Clear", 60, 22, "LEFT", joinDateButton, "RIGHT", 6, 0, function()
        setDateValue("guildJoinDate", "")
        refreshDateButtons()
        if activeCalendarField == "guildJoinDate" and calendarPanel then
            calendarPanel:Hide()
        end
    end)

    local recordCell = createCell(middleSection, twoColumnWidth, "LEFT", joinDateCell, "RIGHT", 8, 0, joinDateCandidate and "Record And Detected Date" or "Record")
    recordCell:SetHeight(joinDateCandidate and 86 or 42)
    local recordText = recordCell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    recordText:SetPoint("TOPLEFT", recordCell, "TOPLEFT", 10, -20)
    recordText:SetWidth(twoColumnWidth - 20)
    recordText:SetJustifyH("LEFT")
    recordText:SetText(joinDateCandidate and string.format(
        "Updated by %s | revision %d\nDetected join date: %s (%s)",
        (myToonMeta and myToonMeta.updatedBy) or selectedMember.updatedBy or "unknown",
        (myToonMeta and myToonMeta.revision) or 0,
        joinDateCandidate.value or "unknown",
        joinDateCandidate.source or "candidate"
    ) or string.format(
        "Updated by %s | revision %d",
        (myToonMeta and myToonMeta.updatedBy) or selectedMember.updatedBy or "unknown",
        (myToonMeta and myToonMeta.revision) or 0
    ))
    styleBodyText(recordText, true)

    if joinDateCandidate then
        createButton(recordCell, "Use Detected Date", 132, 22, "TOPLEFT", recordCell, "TOPLEFT", 10, -56, function()
            local updatedRecord, errorMessage = addon.RosterService:ApplyJoinDateCandidate(selectedMember.id)

            if not updatedRecord then
                addon:Print(errorMessage or "Unable to apply detected join date.")
                return
            end

            addon.MainFrame:RefreshCurrentView()
        end)

        createButton(recordCell, "Dismiss", 80, 22, "LEFT", recordCell, "LEFT", 150, -56, function()
            addon.RosterService:ClearJoinDateCandidate(selectedMember.id)
            addon.MainFrame:RefreshCurrentView()
        end)
    end

    local bottomSection = createSection("Roles And Interests", 142)
    local primaryRoleCell = createCell(bottomSection, threeColumnWidth, "TOPLEFT", bottomSection, "TOPLEFT", 12, -34, "Primary Role")
    local primaryRoleDropdown = createCellDropdown(primaryRoleCell, 145)
    initializeSingleSelectMenuButton(primaryRoleDropdown, roleOptions, getValidOption(roleOptions, string.lower(selectedMember.primaryRole or "dps")), formatDropdownOption)

    local profileCell = createCell(bottomSection, threeColumnWidth, "LEFT", primaryRoleCell, "RIGHT", 8, 0, "Combat Profile")
    local profileDropdown = createCellDropdown(profileCell, 145)
    initializeSingleSelectMenuButton(profileDropdown, profileOptions, getValidOption(profileOptions, string.lower(selectedMember.meleeOrRanged or "unknown")), formatDropdownOption)

    local secondaryRolesCell = createCell(bottomSection, threeColumnWidth, "LEFT", profileCell, "RIGHT", 8, 0, "Secondary Roles")
    local secondaryRolesDropdown = createCellDropdown(secondaryRolesCell, 145)
    initializeMultiSelectMenuButton(secondaryRolesDropdown, roleOptions, selectedMember.secondaryRoles or {}, formatDropdownOption)

    local professionsCell = createCell(bottomSection, twoColumnWidth, "TOPLEFT", primaryRoleCell, "BOTTOMLEFT", 0, -rowGap, "Professions")
    local professionsDropdown = createCellDropdown(professionsCell, 246)
    initializeMultiSelectMenuButton(professionsDropdown, PROFESSION_OPTIONS, selectedMember.professions or {}, formatDropdownOption)

    local interestsCell = createCell(bottomSection, twoColumnWidth, "LEFT", professionsCell, "RIGHT", 8, 0, "Interests")
    local interestsDropdown = createCellDropdown(interestsCell, 246)
    initializeMultiSelectMenuButton(interestsDropdown, INTEREST_OPTIONS, selectedMember.interests or {}, formatDropdownOption)

    local function saveSelectedMyToon()
        local joinDate = joinDateValue or ""
        local birthdate = birthdateValue or ""
        local selectedSecondaryRoles = {}

        for _, role in ipairs(secondaryRolesDropdown.selectedValues or {}) do
            selectedSecondaryRoles[#selectedSecondaryRoles + 1] = role
        end

        addon.RosterService:UpdateMember(selectedMember.id, {
            spec = inputs.spec:GetText(),
            itemLevel = inputs.itemLevel:GetText(),
            primaryRole = primaryRoleDropdown.selectedValue,
            meleeOrRanged = profileDropdown.selectedValue,
            secondaryRoles = selectedSecondaryRoles,
            mainCharacter = mainCharDropdown.selectedValue,
            guildJoinDate = joinDate,
            birthdate = birthdate,
            battleTag = inputs.battleTag:GetText(),
            professions = professionsDropdown.selectedValues,
            interests = interestsDropdown.selectedValues,
            note = inputs.note:GetText(),
        })
        addon.MainFrame:RefreshCurrentView()
    end

    createButton(editorPanel, "Save Toon", 120, 28, "TOPRIGHT", editorPanel, "TOPRIGHT", -14, -10, function()
        saveSelectedMyToon()
    end)

    local footerRow = CreateFrame("Frame", nil, formPanel)
    footerRow:SetPoint("TOPLEFT", formPanel, "TOPLEFT", 0, sectionCursorY + 2)
    footerRow:SetSize(sectionWidth, 42)

    local deleteToonButton = createButton(footerRow, "Delete Toon", 120, 28, "TOPRIGHT", footerRow, "TOPRIGHT", 0, 0, function()
        local deletedRecord, errorMessage = addon.RosterService:DeleteMyToon(selectedMember.id)

        if not deletedRecord then
            addon:Print(errorMessage or "Unable to delete toon.")
            return
        end

        addon.MainFrame:SetSelectedMyToon(nil)
        addon:Print("Deleted My Toon:", selectedMember.fullName or selectedMember.id)
        addon.MainFrame:RefreshCurrentView()
    end)

    if selectedMember.id == addon.Store:GetPlayerName() then
        deleteToonButton:Disable()
    end

    calendarPanel = createPanel(editorPanel)
    calendarPanel:SetSize(252, 242)
    calendarPanel:SetFrameStrata("FULLSCREEN_DIALOG")
    calendarPanel:SetFrameLevel(editorPanel:GetFrameLevel() + 30)
    calendarPanel:SetToplevel(true)
    calendarPanel:Hide()

    calendarTitle = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    calendarTitle:SetPoint("TOP", calendarPanel, "TOP", 0, -12)
    styleTitleText(calendarTitle, 15)

    local calendarYearLabel = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    calendarYearLabel:SetPoint("TOPLEFT", calendarPanel, "TOPLEFT", 16, -40)
    calendarYearLabel:SetWidth(34)
    calendarYearLabel:SetJustifyH("LEFT")
    calendarYearLabel:SetText("Year")
    styleBodyText(calendarYearLabel, true)

    calendarYearInput = createInput(calendarPanel, 58, 20, "LEFT", calendarYearLabel, "RIGHT", 6, 0)
    calendarYearInput:SetNumeric(true)
    calendarYearInput:SetMaxLetters(4)
    calendarYearInput:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
        applyMemberCalendarYearJump()
    end)

    createButton(calendarPanel, "Jump", 48, 20, "LEFT", calendarYearInput, "RIGHT", 6, 0, function()
        applyMemberCalendarYearJump()
    end)

    createButton(calendarPanel, "<", 24, 22, "TOPLEFT", calendarPanel, "TOPLEFT", 12, -10, function()
        memberCalendarMonth = memberCalendarMonth - 1
        refreshMemberCalendar()
    end)

    createButton(calendarPanel, ">", 24, 22, "TOPRIGHT", calendarPanel, "TOPRIGHT", -12, -10, function()
        memberCalendarMonth = memberCalendarMonth + 1
        refreshMemberCalendar()
    end)

    for columnIndex, labelText in ipairs({ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }) do
        local label = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", calendarPanel, "TOPLEFT", 14 + ((columnIndex - 1) * 32), -70)
        label:SetWidth(28)
        label:SetJustifyH("CENTER")
        label:SetText(labelText)
        styleBodyText(label, true)
    end

    for rowIndex = 1, 6 do
        for columnIndex = 1, 7 do
            local buttonIndex = ((rowIndex - 1) * 7) + columnIndex
            local button = createButton(calendarPanel, "", 28, 22, "TOPLEFT", calendarPanel, "TOPLEFT", 12 + ((columnIndex - 1) * 32), -86 - ((rowIndex - 1) * 24), function(self)
                if not self._selectedDate or not activeCalendarField then
                    return
                end

                setDateValue(activeCalendarField, self._selectedDate)
                refreshDateButtons()
                calendarPanel:Hide()
                activeCalendarButton = nil
                refreshMemberCalendar()
            end)
            calendarDayButtons[buttonIndex] = button
        end
    end

    if isCurrentPlayerToon then
        if inputs.spec then
            inputs.spec:Disable()
        end

        if inputs.itemLevel then
            inputs.itemLevel:Disable()
        end
    end

    refreshDateButtons()
    formPanel:SetHeight(math.max(1, -sectionCursorY + 42))
end

function addon.MainFrame:RenderGroups()
    local host = self:GetViewHost()
    self.groupsInputs = {}
    local candidateMembers = addon.RosterService:ListVisibleMembers()
    local myToons = addon.RosterService:ListMyToons()
    local sortedCandidates = {}
    local canManageGroups = addon.GroupsService:CanManageGroups()

    for _, member in ipairs(candidateMembers) do
        sortedCandidates[#sortedCandidates + 1] = member
    end

    table.sort(sortedCandidates, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    table.sort(myToons, function(left, right)
        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    createSectionTitle(host, "Activity Planner", 18, -18)

    if canManageGroups then
        createButton(host, "Export Party/Raid", 138, 24, "TOPLEFT", host, "TOPLEFT", 192, -22, function()
            addon.MainFrame:ShowCsvExportDialog("Party/Raid Export", addon.MainFrame:BuildCurrentPartyRaidExportCsv())
        end)

        createButton(host, "Export Events", 126, 24, "TOPLEFT", host, "TOPLEFT", 338, -22, function()
            addon.MainFrame:ShowCsvExportDialog("Events Export", addon.MainFrame:BuildGroupsEventsExportCsv(addon.GroupsService:ListGroups({
                activityType = "all",
                sortBy = "start",
            })))
        end)
    end

    local function clampMythicLevel(value)
        local text = string.lower(tostring(value or ""))
        local level = tonumber(text:match("mythic%+(%d+)")) or tonumber(text) or 2
        return math.min(math.max(math.floor(level), 2), 20)
    end

    local function usesMythicActivity(record)
        if not record then
            return false
        end

        return tostring(record.activityType or "") == "party"
            and string.lower(tostring(record.difficulty or "")):match("^mythic%+") ~= nil
    end

    local function copyWeekdays(days)
        local values = {}

        for _, day in ipairs(days or {}) do
            local numericDay = tonumber(day)

            if numericDay and numericDay >= 1 and numericDay <= 7 and not arrayContains(values, numericDay) then
                values[#values + 1] = numericDay
            end
        end

        table.sort(values)
        return values
    end

    local function parseWeekdaysFromText(value)
        local tokenMap = {
            sun = 1,
            sunday = 1,
            mon = 2,
            monday = 2,
            tue = 3,
            tues = 3,
            tuesday = 3,
            wed = 4,
            weds = 4,
            wednesday = 4,
            thu = 5,
            thur = 5,
            thurs = 5,
            thursday = 5,
            fri = 6,
            friday = 6,
            sat = 7,
            saturday = 7,
        }
        local values = {}
        local seen = {}

        for token in string.gmatch(string.lower(tostring(value or "")), "[^,%s/]+") do
            local numericDay = tokenMap[token] or tonumber(token)

            if numericDay and numericDay >= 1 and numericDay <= 7 and not seen[numericDay] then
                seen[numericDay] = true
                values[#values + 1] = numericDay
            end
        end

        table.sort(values)
        return values
    end

    local function buildWeekdayText(weekdays)
        local labels = {
            [1] = "Sun",
            [2] = "Mon",
            [3] = "Tue",
            [4] = "Wed",
            [5] = "Thu",
            [6] = "Fri",
            [7] = "Sat",
        }
        local parts = {}

        for _, day in ipairs(copyWeekdays(weekdays)) do
            parts[#parts + 1] = labels[day] or tostring(day)
        end

        return table.concat(parts, ",")
    end

    local function getGroupTypeLabel(group)
        if usesMythicActivity(group) then
            return "Mythic+"
        end

        if tostring(group and group.activityType or "") == "raid" then
            return "Raid"
        end

        return "Party"
    end

    local function getGroupDifficultyLabel(group)
        if not group then
            return "-"
        end

        if usesMythicActivity(group) then
            return "Key " .. tostring(clampMythicLevel(group.difficulty))
        end

        local difficulty = tostring(group.difficulty or "")

        if difficulty == "" or difficulty == "Party" then
            return "Open Run"
        end

        return difficulty
    end

    local function buildDefaultEventName(activityKind, raidDifficulty, mythicLevel)
        if activityKind == "mythicplus" then
            return "Mythic+" .. tostring(clampMythicLevel(mythicLevel)) .. " Run"
        end

        if activityKind == "raid" then
            return tostring(raidDifficulty or "Heroic") .. " Raid"
        end

        return "Party Event"
    end

    local function getDefaultRequirements(activityKind)
        if activityKind == "raid" then
            return { tanks = 2, healers = 4, melee = 5, ranged = 7 }
        end

        return { tanks = 1, healers = 1, melee = 1, ranged = 2 }
    end

    local function matchesSignupSearch(member, searchText)
        local query = lowerText(searchText)

        if query == "" then
            return true
        end

        local haystacks = {
            lowerText(member.fullName),
            lowerText(member.name),
            lowerText(member.mainCharacter),
            lowerText(member.className),
            lowerText(member.spec),
        }

        if addon.RosterService:IsMyToon(member) and (query == "me" or query == "my" or query == "alt" or query == "alts" or query == "toon" or query == "toons") then
            return true
        end

        for _, value in ipairs(haystacks) do
            if value ~= "" and string.find(value, query, 1, true) then
                return true
            end
        end

        return false
    end

    local function formatStartLabel(group, timestamp)
        local display = addon.GroupsService:GetOccurrenceDisplay(group, timestamp)
        return display and display.localCompactLabel or "No start time"
    end

    local function formatOccurrenceLabel(group, timestamp)
        local display = addon.GroupsService:GetOccurrenceDisplay(group, timestamp)
        return display and display.localLabel or "Set schedule"
    end

    local function getActiveOccurrenceStart(group)
        if not group or not group.recurrence or group.recurrence.mode == "none" then
            return tonumber(group and group.startAt) or 0
        end

        if self.state.groupsSelectedOccurrenceGroupId ~= group.id then
            self.state.groupsSelectedOccurrenceGroupId = group.id
            self.state.groupsSelectedOccurrenceStart = addon.GroupsService:GetResolvedOccurrenceStart(group)
        else
            self.state.groupsSelectedOccurrenceStart = addon.GroupsService:GetResolvedOccurrenceStart(group, self.state.groupsSelectedOccurrenceStart)
        end

        return tonumber(self.state.groupsSelectedOccurrenceStart) or 0
    end

    local function buildNeedsSummary(deficits)
        local ordered = { "tanks", "healers", "melee", "ranged" }
        local labels = {
            tanks = "tank",
            healers = "healer",
            melee = "melee",
            ranged = "ranged",
        }
        local parts = {}

        for _, key in ipairs(ordered) do
            local value = deficits and deficits[key] or 0

            if value and value > 0 then
                parts[#parts + 1] = string.format("%s %d", labels[key], value)
            end
        end

        if #parts == 0 then
            return "Suggested lineup covers all configured role requirements."
        end

        return "Still needed: " .. table.concat(parts, ", ")
    end

    local function buildCompactNeedsSummary(deficits)
        local parts = {}
        local labels = {
            tanks = "T",
            healers = "H",
            melee = "M",
            ranged = "R",
        }

        for _, key in ipairs({ "tanks", "healers", "melee", "ranged" }) do
            local value = deficits and deficits[key] or 0

            if value and value > 0 then
                parts[#parts + 1] = string.format("%s%d", labels[key], value)
            end
        end

        if #parts == 0 then
            return "Covered"
        end

        return table.concat(parts, " ")
    end

    local function buildSuggestedText(planner)
        if not planner or #(planner.suggested or {}) == 0 then
            return "No suggested lineup yet. Add signups that meet the role and item level requirements to populate recommendations."
        end

        local lines = {}

        for index, entry in ipairs(planner.suggested) do
            local roleLabel = entry.role or formatDropdownOption(entry.primaryRole or "dps")
            lines[#lines + 1] = string.format("%d. %s | %s | iLvl %s", index, entry.fullName or "Unknown", roleLabel, tostring(entry.itemLevel or 0))
        end

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



    local function getInitialEditorState(group)
        local activityKind = "party"
        local raidDifficulty = "Heroic"
        local mythicLevel = 2
        local durationHours = 2
        local startDate = ""
        local startTime = "20:00"
        local recurrenceMode = "none"
        local dailyPreset = "weekdays"
        local weeklyDays = { 2, 4 }
        local minItemLevel = 0
        local requirements = { tanks = 1, healers = 1, melee = 1, ranged = 2 }

        if group then
            if tostring(group.activityType or "") == "raid" then
                activityKind = "raid"
                raidDifficulty = tostring(group.difficulty or "Heroic")
            elseif usesMythicActivity(group) then
                activityKind = "mythicplus"
                mythicLevel = clampMythicLevel(group.difficulty)
            else
                activityKind = "party"
            end

            startDate, startTime = addon.GroupsService:GetStartFields(group)
            recurrenceMode = group.recurrence and group.recurrence.mode or "none"
            minItemLevel = tonumber(group.minItemLevel) or 0
            durationHours = math.max(tonumber(group.durationHours) or (activityKind == "raid" and 3 or 2), 1)
            requirements = {
                tanks = tonumber(group.requirements and group.requirements.tanks) or 0,
                healers = tonumber(group.requirements and group.requirements.healers) or 0,
                melee = tonumber(group.requirements and group.requirements.melee) or 0,
                ranged = tonumber(group.requirements and group.requirements.ranged) or 0,
            }

            local recurrenceDays = copyWeekdays(group.recurrence and group.recurrence.weekdays or {})

            if recurrenceMode == "daily" then
                if #recurrenceDays == 7 then
                    dailyPreset = "alldays"
                else
                    dailyPreset = "weekdays"
                end
            elseif recurrenceMode == "weekly" and #recurrenceDays > 0 then
                weeklyDays = recurrenceDays
            end
        end

        if activityKind ~= "raid" and not usesMythicActivity(group) then
            mythicLevel = clampMythicLevel(group and group.difficulty or 2)
        end

        return {
            mode = group and "edit" or "create",
            groupId = group and group.id or nil,
            eventName = group and (group.eventName or group.name or "") or "",
            activityKind = activityKind,
            raidDifficulty = raidDifficulty,
            mythicLevel = mythicLevel,
            durationHours = durationHours,
            startDate = startDate,
            startTime = startTime ~= "" and startTime or "20:00",
            recurrenceMode = recurrenceMode,
            dailyPreset = dailyPreset,
            weeklyDays = weeklyDays,
            minItemLevel = minItemLevel,
            requirements = requirements,
            calendarMonth = tonumber(startDate:match("^%d%d%d%d%-(%d%d)%-%d%d$")) or tonumber(date("%m")),
            calendarYear = tonumber(startDate:match("^(%d%d%d%d)%-%d%d%-%d%d$")) or tonumber(date("%Y")),
        }
    end

    local openGroupsEditor
    local closeGroupsEditor

    if canManageGroups then
        createButton(host, "Create Activity", 126, 24, "TOPRIGHT", host, "TOPRIGHT", -16, -20, function()
            if openGroupsEditor then
                openGroupsEditor(nil)
            end
        end)
    else
        local accessNote = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        accessNote:SetPoint("TOPRIGHT", host, "TOPRIGHT", -16, -22)
        accessNote:SetText("Officers and the guild master can create or edit activities.")
        styleBodyText(accessNote, true)
    end

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    listPanel:SetSize(220, 580)

    local detailPanel = createPanel(host)
    detailPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 16, 0)
    detailPanel:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -12, 12)

    local detailScrollFrame = CreateFrame("ScrollFrame", nil, detailPanel, "UIPanelScrollFrameTemplate")
    detailScrollFrame:SetPoint("TOPLEFT", detailPanel, "TOPLEFT", 12, -12)
    detailScrollFrame:SetPoint("BOTTOMRIGHT", detailPanel, "BOTTOMRIGHT", -10, 12)
    self.groupsDetailScrollFrame = detailScrollFrame
    detailScrollFrame:HookScript("OnVerticalScroll", function(frame, offset)
        addon.MainFrame:EnsureState()
        addon.MainFrame.state.groupsDetailScrollOffset = tonumber(offset) or tonumber(frame:GetVerticalScroll()) or 0
    end)

    local detailContent = CreateFrame("Frame", nil, detailScrollFrame)
    detailContent:SetSize(math.max((detailPanel:GetWidth() or 0) - 26, 654), 1120)
    detailScrollFrame:SetScrollChild(detailContent)

    createButton(listPanel, self.state.groupsFilterType == "all" and "Filter: All" or (self.state.groupsFilterType == "raid" and "Filter: Raid" or "Filter: Party"), 102, 22, "TOPLEFT", listPanel, "TOPLEFT", 12, -12, function()
        if addon.MainFrame.state.groupsFilterType == "all" then
            addon.MainFrame.state.groupsFilterType = "raid"
        elseif addon.MainFrame.state.groupsFilterType == "raid" then
            addon.MainFrame.state.groupsFilterType = "party"
        else
            addon.MainFrame.state.groupsFilterType = "all"
        end

        addon.MainFrame:RefreshCurrentView(true)
    end)

    createButton(listPanel, self.state.groupsSortKey == "type" and "Sort: Type" or "Sort: Start", 102, 22, "TOPRIGHT", listPanel, "TOPRIGHT", -12, -12, function()
        addon.MainFrame.state.groupsSortKey = addon.MainFrame.state.groupsSortKey == "type" and "start" or "type"
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local groups = addon.GroupsService:ListGroups({
        activityType = self.state.groupsFilterType,
        sortBy = self.state.groupsSortKey,
    })

    local selectedGroupId = self:GetSelectedGroupId()
    local selectedInFilter = false

    for _, group in ipairs(groups) do
        if group.id == selectedGroupId then
            selectedInFilter = true
            break
        end
    end

    if not selectedInFilter then
        selectedGroupId = groups[1] and groups[1].id or nil
        self:SetSelectedGroup(selectedGroupId)
    end

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -44)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -18, 12)
    self.groupsListScrollFrame = listScrollFrame
    listScrollFrame:HookScript("OnVerticalScroll", function(frame, offset)
        addon.MainFrame:EnsureState()
        addon.MainFrame.state.groupsListScrollOffset = tonumber(offset) or tonumber(frame:GetVerticalScroll()) or 0
    end)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(194, math.max(#groups * 50, 1))
    listScrollFrame:SetScrollChild(listContent)

    local editorOverlay = CreateFrame("Frame", nil, host)
    editorOverlay:SetAllPoints(host)
    editorOverlay:SetFrameStrata("DIALOG")
    editorOverlay:SetFrameLevel(host:GetFrameLevel() + 40)
    editorOverlay:EnableMouse(true)
    editorOverlay:Hide()

    local editorShade = editorOverlay:CreateTexture(nil, "BACKGROUND")
    editorShade:SetAllPoints(editorOverlay)
    editorShade:SetColorTexture(0, 0, 0, 0.72)

    local editorDialog = createPanel(editorOverlay)
    editorDialog:SetSize(720, 586)
    editorDialog:SetPoint("CENTER", editorOverlay, "CENTER", 0, 0)
    editorDialog:SetFrameLevel(editorOverlay:GetFrameLevel() + 1)

    local editorTitle = editorDialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    editorTitle:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 16, -14)
    styleTitleText(editorTitle, 18)

    local editorSubtitle = editorDialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    editorSubtitle:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 16, -38)
    editorSubtitle:SetWidth(600)
    editorSubtitle:SetJustifyH("LEFT")
    styleBodyText(editorSubtitle, true)

    local editorState = getInitialEditorState(nil)
    local dateButton
    local timeInput
    local scheduleTimezoneNote
    local eventInput
    local minItemLevelInput
    local mythicLevelInput
    local durationHoursInput
    local tanksInput
    local healersInput
    local meleeInput
    local rangedInput
    local raidDifficultyButtons = {}
    local activityKindButtons = {}
    local recurrenceButtons = {}
    local dailyPresetButtons = {}
    local weeklyDayButtons = {}
    local requirementHint
    local typeHint
    local dailyPanel
    local weeklyPanel
    local mythicPanel
    local raidDifficultyPanel
    local calendarPanel
    local calendarTitle
    local calendarDayButtons = {}

    local function createEditorTableCell(parent, width, height, point, relativeTo, relativePoint, x, y, titleText)
        local cell = createPanel(parent)
        cell:SetSize(width, height)
        cell:SetPoint(point, relativeTo, relativePoint, x, y)
        applySurfaceStyle(cell, THEME.card)

        local title = cell:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        title:SetPoint("TOPLEFT", cell, "TOPLEFT", 10, -8)
        title:SetText(titleText)
        styleHighlightText(title)

        cell._title = title
        return cell
    end

    closeGroupsEditor = function()
        if calendarPanel then
            calendarPanel:Hide()
        end

        if editorOverlay then
            editorOverlay:Hide()
        end
    end

    createCloseButton(editorDialog, "TOPRIGHT", editorDialog, "TOPRIGHT", -10, -10, function()
        closeGroupsEditor()
    end)

    local function refreshEditorButtons(buttons, activeValue)
        for value, button in pairs(buttons) do
            button:SetText((value == activeValue and "[x] " or "[ ] ") .. (button._baseText or tostring(value)))
        end
    end

    local function refreshDateButton()
        dateButton:SetText(editorState.startDate ~= "" and editorState.startDate or "Pick Date")
    end

    local function refreshScheduleTimezoneNote()
        if not scheduleTimezoneNote then
            return
        end

        local timezoneInfo = addon.GroupsService:GetCurrentTimezoneInfo(editorState.startDate, timeInput and timeInput:GetText() or "")
        scheduleTimezoneNote:SetText(string.format(
            "This form shows your local time. If you change the date or time, GH3 saves the schedule in your current timezone: %s.",
            timezoneInfo and timezoneInfo.label or "UTC+0:00"
        ))
    end

    local function refreshRequirementHint()
        requirementHint:SetText(string.format(
            "Total DPS = %d",
            (tonumber(meleeInput:GetText()) or 0) + (tonumber(rangedInput:GetText()) or 0)
        ))
    end

    local function refreshTypeControls()
        refreshEditorButtons(activityKindButtons, editorState.activityKind)

        mythicPanel:SetShown(editorState.activityKind == "mythicplus")
        raidDifficultyPanel:SetShown(editorState.activityKind == "raid")

        if editorState.activityKind == "mythicplus" then
            typeHint:SetText("Mythic+ uses a keystone level from 2 to 20.")
        elseif editorState.activityKind == "raid" then
            typeHint:SetText("Raid activities use Normal, Heroic, or Mythic difficulty.")
        else
            typeHint:SetText("Party is intended for farming, PvP, social, or open-run events.")
        end
    end

    local function refreshRecurrenceControls()
        refreshEditorButtons(recurrenceButtons, editorState.recurrenceMode)
        dailyPanel:SetShown(editorState.recurrenceMode == "daily")
        weeklyPanel:SetShown(editorState.recurrenceMode == "weekly")
        refreshEditorButtons(dailyPresetButtons, editorState.dailyPreset)

        for day, button in pairs(weeklyDayButtons) do
            local isActive = arrayContains(editorState.weeklyDays, day)
            button:SetText((isActive and "[x] " or "[ ] ") .. (button._baseText or tostring(day)))
        end
    end

    local function refreshRaidDifficultyButtons()
        refreshEditorButtons(raidDifficultyButtons, editorState.raidDifficulty)
    end

    local function normalizeCalendarMonth()
        while editorState.calendarMonth < 1 do
            editorState.calendarMonth = editorState.calendarMonth + 12
            editorState.calendarYear = editorState.calendarYear - 1
        end

        while editorState.calendarMonth > 12 do
            editorState.calendarMonth = editorState.calendarMonth - 12
            editorState.calendarYear = editorState.calendarYear + 1
        end
    end

    local function refreshCalendar()
        normalizeCalendarMonth()

        local monthNames = {
            "January", "February", "March", "April", "May", "June",
            "July", "August", "September", "October", "November", "December",
        }
        local year = editorState.calendarYear
        local month = editorState.calendarMonth
        local firstDay = time({ year = year, month = month, day = 1, hour = 12, min = 0, sec = 0 })
        local firstWeekday = date("*t", firstDay).wday
        local daysInMonth = getDaysInMonth(year, month)

        calendarTitle:SetText(string.format("%s %d", monthNames[month] or "Month", year))

        if calendarYearInput then
            calendarYearInput:SetText(tostring(year))
        end

        for buttonIndex, button in ipairs(calendarDayButtons) do
            local dayNumber = buttonIndex - firstWeekday + 1

            if dayNumber >= 1 and dayNumber <= daysInMonth then
                local selectedDate = string.format("%04d-%02d-%02d", year, month, dayNumber)
                button._selectedDate = selectedDate
                button:SetText(tostring(dayNumber))
                button:Enable()

                if editorState.startDate == selectedDate then
                    button:SetText("[" .. tostring(dayNumber) .. "]")
                end
            else
                button._selectedDate = nil
                button:SetText("")
                button:Disable()
            end
        end
    end

    local function applyEditorState(state)
        editorState = state

        editorTitle:SetText(state.mode == "edit" and "Edit Activity" or "Create Activity")
        editorSubtitle:SetText(state.mode == "edit"
            and "Edit uses the same form as create. Save applies the new schedule and role requirements to the selected activity."
            or "Create a new activity using a popup editor instead of inline fields.")

        eventInput:SetText(state.eventName or "")
        timeInput:SetText(state.startTime ~= "" and state.startTime or "20:00")
        minItemLevelInput:SetText(tostring(state.minItemLevel or 0))
        mythicLevelInput:SetText(tostring(clampMythicLevel(state.mythicLevel or 2)))
        durationHoursInput:SetText(tostring(math.max(tonumber(state.durationHours) or 1, 1)))
        tanksInput:SetText(tostring(state.requirements and state.requirements.tanks or 0))
        healersInput:SetText(tostring(state.requirements and state.requirements.healers or 0))
        meleeInput:SetText(tostring(state.requirements and state.requirements.melee or 0))
        rangedInput:SetText(tostring(state.requirements and state.requirements.ranged or 0))
        refreshDateButton()
        refreshTypeControls()
        refreshRaidDifficultyButtons()
        refreshRecurrenceControls()
        refreshRequirementHint()
        refreshScheduleTimezoneNote()
        refreshCalendar()
    end

    local function collectRecurrenceWeekdays()
        if editorState.recurrenceMode == "daily" then
            if editorState.dailyPreset == "alldays" then
                return { 1, 2, 3, 4, 5, 6, 7 }
            end

            return { 2, 3, 4, 5, 6 }
        end

        if editorState.recurrenceMode == "weekly" then
            return copyWeekdays(editorState.weeklyDays)
        end

        return {}
    end

    local function saveEditor()
        local activityName = tostring(eventInput:GetText() or ""):match("^%s*(.-)%s*$")
        local recurrenceWeekdays = collectRecurrenceWeekdays()
        local mythicLevel = clampMythicLevel(mythicLevelInput:GetText())
        local payload = {
            eventName = activityName ~= "" and activityName or buildDefaultEventName(editorState.activityKind, editorState.raidDifficulty, mythicLevel),
            activityType = editorState.activityKind == "raid" and "raid" or "party",
            difficulty = editorState.activityKind == "raid" and editorState.raidDifficulty or (editorState.activityKind == "mythicplus" and ("Mythic+" .. tostring(mythicLevel)) or "Party"),
            mythicLevel = mythicLevel,
            durationHours = math.max(tonumber(durationHoursInput:GetText()) or 1, 1),
            minItemLevel = tonumber(minItemLevelInput:GetText()) or 0,
            startDate = tostring(editorState.startDate or ""),
            startTime = tostring(timeInput:GetText() or ""),
            recurrenceMode = editorState.recurrenceMode,
            recurrenceWeekdays = recurrenceWeekdays,
            requirements = {
                tanks = tonumber(tanksInput:GetText()) or 0,
                healers = tonumber(healersInput:GetText()) or 0,
                melee = tonumber(meleeInput:GetText()) or 0,
                ranged = tonumber(rangedInput:GetText()) or 0,
            },
        }

        if editorState.recurrenceMode == "weekly" and #recurrenceWeekdays == 0 then
            addon:Print("Weekly activities need at least one day selected.")
            return
        end

        if editorState.mode == "edit" and editorState.groupId then
            local ok, message = addon.GroupsService:UpdateGroupConfig(editorState.groupId, payload)

            if not ok then
                addon:Print(message or "Could not save activity.")
                return
            end

            closeGroupsEditor()
            addon.MainFrame:RefreshCurrentView(true)
            return
        end

        local created, message = addon.GroupsService:CreateGroup(payload.eventName, payload)

        if not created then
            addon:Print(message or "Could not create activity.")
            return
        end

        addon.MainFrame:SetSelectedGroup(created.id)
        closeGroupsEditor()
        addon.MainFrame:RefreshCurrentView(true)
    end

    openGroupsEditor = function(group)
        applyEditorState(getInitialEditorState(group))
        editorOverlay:Show()
    end

    local eventLabel = editorDialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    eventLabel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -72)
    eventLabel:SetText("Event")
    styleHighlightText(eventLabel)

    eventInput = createInput(editorDialog, 298, 22, "TOPLEFT", editorDialog, "TOPLEFT", 18, -90)
    table.insert(self.groupsInputs, eventInput)

    local typeLabel = editorDialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    typeLabel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 338, -72)
    typeLabel:SetText("Type")
    styleHighlightText(typeLabel)

    activityKindButtons.mythicplus = createButton(editorDialog, "Mythic+", 96, 22, "TOPLEFT", editorDialog, "TOPLEFT", 338, -90, function()
        editorState.activityKind = "mythicplus"
        refreshTypeControls()
    end)
    activityKindButtons.mythicplus._baseText = "Mythic+"

    activityKindButtons.raid = createButton(editorDialog, "Raid", 74, 22, "LEFT", activityKindButtons.mythicplus, "RIGHT", 8, 0, function()
        editorState.activityKind = "raid"
        refreshTypeControls()
    end)
    activityKindButtons.raid._baseText = "Raid"

    activityKindButtons.party = createButton(editorDialog, "Party", 78, 22, "LEFT", activityKindButtons.raid, "RIGHT", 8, 0, function()
        editorState.activityKind = "party"
        refreshTypeControls()
    end)
    activityKindButtons.party._baseText = "Party"

    typeHint = editorDialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    typeHint:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 338, -118)
    typeHint:SetWidth(356)
    typeHint:SetJustifyH("LEFT")
    styleBodyText(typeHint, true)

    mythicPanel = CreateFrame("Frame", nil, editorDialog)
    mythicPanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -134)
    mythicPanel:SetSize(210, 44)

    local mythicLabel = mythicPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    mythicLabel:SetPoint("TOPLEFT", mythicPanel, "TOPLEFT", 0, 0)
    mythicLabel:SetText("M+ Level")
    styleHighlightText(mythicLabel)

    mythicLevelInput = createInput(mythicPanel, 58, 22, "TOPLEFT", mythicPanel, "TOPLEFT", 0, -18)
    mythicLevelInput:SetNumeric(true)
    table.insert(self.groupsInputs, mythicLevelInput)
    mythicLevelInput:SetScript("OnTextChanged", function(self)
        editorState.mythicLevel = clampMythicLevel(self:GetText())
    end)

    local mythicNote = mythicPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    mythicNote:SetPoint("LEFT", mythicLevelInput, "RIGHT", 10, 0)
    mythicNote:SetWidth(130)
    mythicNote:SetJustifyH("LEFT")
    mythicNote:SetText("2 to 20")
    styleBodyText(mythicNote, true)

    raidDifficultyPanel = CreateFrame("Frame", nil, editorDialog)
    raidDifficultyPanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -134)
    raidDifficultyPanel:SetSize(320, 44)

    local difficultyLabel = raidDifficultyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    difficultyLabel:SetPoint("TOPLEFT", raidDifficultyPanel, "TOPLEFT", 0, 0)
    difficultyLabel:SetText("Raid Difficulty")
    styleHighlightText(difficultyLabel)

    raidDifficultyButtons.Normal = createButton(raidDifficultyPanel, "Normal", 82, 22, "TOPLEFT", raidDifficultyPanel, "TOPLEFT", 0, -18, function()
        editorState.raidDifficulty = "Normal"
        refreshRaidDifficultyButtons()
    end)
    raidDifficultyButtons.Normal._baseText = "Normal"

    raidDifficultyButtons.Heroic = createButton(raidDifficultyPanel, "Heroic", 82, 22, "LEFT", raidDifficultyButtons.Normal, "RIGHT", 8, 0, function()
        editorState.raidDifficulty = "Heroic"
        refreshRaidDifficultyButtons()
    end)
    raidDifficultyButtons.Heroic._baseText = "Heroic"

    raidDifficultyButtons.Mythic = createButton(raidDifficultyPanel, "Mythic", 82, 22, "LEFT", raidDifficultyButtons.Heroic, "RIGHT", 8, 0, function()
        editorState.raidDifficulty = "Mythic"
        refreshRaidDifficultyButtons()
    end)
    raidDifficultyButtons.Mythic._baseText = "Mythic"

    local schedulePanel = createPanel(editorDialog)
    schedulePanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -194)
    schedulePanel:SetSize(684, 154)

    local scheduleTitle = schedulePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    scheduleTitle:SetPoint("TOPLEFT", schedulePanel, "TOPLEFT", 12, -12)
    scheduleTitle:SetText("Schedule")
    styleTitleText(scheduleTitle, 15)

    scheduleTimezoneNote = schedulePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    scheduleTimezoneNote:SetPoint("TOPLEFT", schedulePanel, "TOPLEFT", 12, -34)
    scheduleTimezoneNote:SetWidth(660)
    scheduleTimezoneNote:SetJustifyH("LEFT")
    styleBodyText(scheduleTimezoneNote, true)

    local dateCell = createEditorTableCell(schedulePanel, 208, 54, "TOPLEFT", schedulePanel, "TOPLEFT", 12, -54, "Date")

    dateButton = createButton(dateCell, "Pick Date", 176, 22, "TOPLEFT", dateCell, "TOPLEFT", 10, -26, function()
        calendarPanel:SetShown(not calendarPanel:IsShown())
        refreshCalendar()
    end)

    local timeCell = createEditorTableCell(schedulePanel, 208, 54, "LEFT", dateCell, "RIGHT", 8, 0, "Time")

    timeInput = createInput(timeCell, 100, 22, "TOPLEFT", timeCell, "TOPLEFT", 10, -26)
    table.insert(self.groupsInputs, timeInput)
    timeInput:SetScript("OnTextChanged", function(self)
        editorState.startTime = self:GetText() or ""
        refreshScheduleTimezoneNote()
    end)

    local timeHint = timeCell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    timeHint:SetPoint("LEFT", timeInput, "RIGHT", 10, 0)
    timeHint:SetText("24-hour HH:MM")
    styleBodyText(timeHint, true)

    local durationCell = createEditorTableCell(schedulePanel, 220, 54, "LEFT", timeCell, "RIGHT", 8, 0, "Duration")

    durationHoursInput = createInput(durationCell, 68, 22, "TOPLEFT", durationCell, "TOPLEFT", 10, -26)
    durationHoursInput:SetNumeric(true)
    table.insert(self.groupsInputs, durationHoursInput)

    local durationHint = durationCell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    durationHint:SetPoint("LEFT", durationHoursInput, "RIGHT", 10, 0)
    durationHint:SetText("Hours")
    styleBodyText(durationHint, true)

    local repeatCell = createEditorTableCell(schedulePanel, 660, 52, "TOPLEFT", schedulePanel, "TOPLEFT", 12, -116, "Repeat")

    recurrenceButtons.none = createButton(repeatCell, "None", 88, 22, "TOPLEFT", repeatCell, "TOPLEFT", 10, -24, function()
        editorState.recurrenceMode = "none"
        refreshRecurrenceControls()
    end)
    recurrenceButtons.none._baseText = "None"

    recurrenceButtons.daily = createButton(repeatCell, "Daily", 88, 22, "LEFT", recurrenceButtons.none, "RIGHT", 8, 0, function()
        editorState.recurrenceMode = "daily"
        refreshRecurrenceControls()
    end)
    recurrenceButtons.daily._baseText = "Daily"

    recurrenceButtons.weekly = createButton(repeatCell, "Weekly", 96, 22, "LEFT", recurrenceButtons.daily, "RIGHT", 8, 0, function()
        editorState.recurrenceMode = "weekly"
        refreshRecurrenceControls()
    end)
    recurrenceButtons.weekly._baseText = "Weekly"

    dailyPanel = CreateFrame("Frame", nil, editorDialog)
    dailyPanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -360)
    dailyPanel:SetSize(332, 54)

    local dailyLabel = dailyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    dailyLabel:SetPoint("TOPLEFT", dailyPanel, "TOPLEFT", 0, 0)
    dailyLabel:SetText("Daily Pattern")
    styleHighlightText(dailyLabel)

    dailyPresetButtons.weekdays = createButton(dailyPanel, "Mon-Fri", 98, 22, "TOPLEFT", dailyPanel, "TOPLEFT", 0, -20, function()
        editorState.dailyPreset = "weekdays"
        refreshRecurrenceControls()
    end)
    dailyPresetButtons.weekdays._baseText = "Mon-Fri"

    dailyPresetButtons.alldays = createButton(dailyPanel, "Mon-Sun", 106, 22, "LEFT", dailyPresetButtons.weekdays, "RIGHT", 8, 0, function()
        editorState.dailyPreset = "alldays"
        refreshRecurrenceControls()
    end)
    dailyPresetButtons.alldays._baseText = "Mon-Sun"

    weeklyPanel = CreateFrame("Frame", nil, editorDialog)
    weeklyPanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -360)
    weeklyPanel:SetSize(684, 82)

    local weeklyLabel = weeklyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    weeklyLabel:SetPoint("TOPLEFT", weeklyPanel, "TOPLEFT", 0, 0)
    weeklyLabel:SetText("Weekly Days")
    styleHighlightText(weeklyLabel)

    for index, dayInfo in ipairs({
        { day = 2, label = "Mon" },
        { day = 3, label = "Tue" },
        { day = 4, label = "Wed" },
        { day = 5, label = "Thu" },
        { day = 6, label = "Fri" },
        { day = 7, label = "Sat" },
        { day = 1, label = "Sun" },
    }) do
        local button = createButton(weeklyPanel, dayInfo.label, 78, 22, "TOPLEFT", weeklyPanel, "TOPLEFT", ((index - 1) * 86), -22, function()
            if arrayContains(editorState.weeklyDays, dayInfo.day) then
                removeArrayValue(editorState.weeklyDays, dayInfo.day)
            else
                editorState.weeklyDays[#editorState.weeklyDays + 1] = dayInfo.day
                table.sort(editorState.weeklyDays)
            end

            refreshRecurrenceControls()
        end)
        button._baseText = dayInfo.label
        weeklyDayButtons[dayInfo.day] = button
    end

    local requirementsPanel = createPanel(editorDialog)
    requirementsPanel:SetPoint("TOPLEFT", editorDialog, "TOPLEFT", 18, -434)
    requirementsPanel:SetSize(684, 110)

    local requirementsTitle = requirementsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    requirementsTitle:SetPoint("TOPLEFT", requirementsPanel, "TOPLEFT", 12, -12)
    requirementsTitle:SetText("Requirements")
    styleTitleText(requirementsTitle, 15)

    local function createRequirementControl(parent, titleText, width, point, relativeTo, relativePoint, x, y)
        local cell = createEditorTableCell(parent, width, 54, point, relativeTo, relativePoint, x, y, titleText)
        local input = createInput(cell, math.min(width - 20, 54), 22, "TOPLEFT", cell, "TOPLEFT", 10, -26)
        input:SetNumeric(true)
        table.insert(self.groupsInputs, input)
        return input, cell
    end

    local tanksCell
    local healersCell
    local meleeCell
    local rangedCell

    tanksInput, tanksCell = createRequirementControl(requirementsPanel, "Tanks", 86, "TOPLEFT", requirementsPanel, "TOPLEFT", 12, -34)
    healersInput, healersCell = createRequirementControl(requirementsPanel, "Heals", 86, "LEFT", tanksCell, "RIGHT", 8, 0)
    meleeInput, meleeCell = createRequirementControl(requirementsPanel, "Melee", 86, "LEFT", healersCell, "RIGHT", 8, 0)
    rangedInput, rangedCell = createRequirementControl(requirementsPanel, "Ranged", 86, "LEFT", meleeCell, "RIGHT", 8, 0)

    local dpsCell = createEditorTableCell(requirementsPanel, 152, 54, "LEFT", rangedCell, "RIGHT", 8, 0, "Total DPS")
    requirementHint = dpsCell:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    requirementHint:SetPoint("TOPLEFT", dpsCell, "TOPLEFT", 10, -30)
    requirementHint:SetWidth(132)
    requirementHint:SetJustifyH("LEFT")
    styleBodyText(requirementHint, true)

    meleeInput:SetScript("OnTextChanged", refreshRequirementHint)
    rangedInput:SetScript("OnTextChanged", refreshRequirementHint)

    local minIlvlCell = createEditorTableCell(requirementsPanel, 126, 54, "LEFT", dpsCell, "RIGHT", 8, 0, "Min iLvl")
    minItemLevelInput = createInput(minIlvlCell, 82, 22, "TOPLEFT", minIlvlCell, "TOPLEFT", 10, -26)
    minItemLevelInput:SetNumeric(true)
    table.insert(self.groupsInputs, minItemLevelInput)

    local calendarYearInput

    local function applyGroupsCalendarYearJump()
        local requestedYear = math.floor(tonumber(calendarYearInput and calendarYearInput:GetText()) or 0)

        if requestedYear < 1900 or requestedYear > 2100 then
            if calendarYearInput then
                calendarYearInput:SetText(tostring(editorState.calendarYear or tonumber(date("%Y")) or 2000))
            end
            return
        end

        editorState.calendarYear = requestedYear
        refreshCalendar()
    end

    calendarPanel = createPanel(editorDialog)
    calendarPanel:SetPoint("TOPLEFT", dateButton, "BOTTOMLEFT", 0, -6)
    calendarPanel:SetSize(252, 242)
    calendarPanel:SetFrameStrata("FULLSCREEN_DIALOG")
    calendarPanel:SetFrameLevel(editorDialog:GetFrameLevel() + 30)
    calendarPanel:SetToplevel(true)
    calendarPanel:Hide()

    calendarTitle = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    calendarTitle:SetPoint("TOP", calendarPanel, "TOP", 0, -12)
    styleTitleText(calendarTitle, 15)

    local groupCalendarYearLabel = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    groupCalendarYearLabel:SetPoint("TOPLEFT", calendarPanel, "TOPLEFT", 16, -40)
    groupCalendarYearLabel:SetWidth(34)
    groupCalendarYearLabel:SetJustifyH("LEFT")
    groupCalendarYearLabel:SetText("Year")
    styleBodyText(groupCalendarYearLabel, true)

    calendarYearInput = createInput(calendarPanel, 58, 20, "LEFT", groupCalendarYearLabel, "RIGHT", 6, 0)
    calendarYearInput:SetNumeric(true)
    calendarYearInput:SetMaxLetters(4)
    table.insert(self.groupsInputs, calendarYearInput)
    calendarYearInput:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
        applyGroupsCalendarYearJump()
    end)

    createButton(calendarPanel, "Jump", 48, 20, "LEFT", calendarYearInput, "RIGHT", 6, 0, function()
        applyGroupsCalendarYearJump()
    end)

    createButton(calendarPanel, "<", 24, 22, "TOPLEFT", calendarPanel, "TOPLEFT", 12, -10, function()
        editorState.calendarMonth = editorState.calendarMonth - 1
        refreshCalendar()
    end)

    createButton(calendarPanel, ">", 24, 22, "TOPRIGHT", calendarPanel, "TOPRIGHT", -12, -10, function()
        editorState.calendarMonth = editorState.calendarMonth + 1
        refreshCalendar()
    end)

    for columnIndex, labelText in ipairs({ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }) do
        local label = calendarPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", calendarPanel, "TOPLEFT", 14 + ((columnIndex - 1) * 32), -70)
        label:SetWidth(28)
        label:SetJustifyH("CENTER")
        label:SetText(labelText)
        styleBodyText(label, true)
    end

    for rowIndex = 1, 6 do
        for columnIndex = 1, 7 do
            local buttonIndex = ((rowIndex - 1) * 7) + columnIndex
            local button = createButton(calendarPanel, "", 28, 22, "TOPLEFT", calendarPanel, "TOPLEFT", 12 + ((columnIndex - 1) * 32), -86 - ((rowIndex - 1) * 24), function(self)
                if not self._selectedDate then
                    return
                end

                editorState.startDate = self._selectedDate
                refreshDateButton()
                refreshScheduleTimezoneNote()
                calendarPanel:Hide()
                refreshCalendar()
            end)
            calendarDayButtons[buttonIndex] = button
        end
    end

    createButton(editorDialog, "Cancel", 88, 24, "BOTTOMRIGHT", editorDialog, "BOTTOMRIGHT", -112, 14, function()
        closeGroupsEditor()
    end)

    createButton(editorDialog, "Save", 88, 24, "BOTTOMRIGHT", editorDialog, "BOTTOMRIGHT", -16, 14, function()
        saveEditor()
    end)

    applyEditorState(editorState)

    if #groups == 0 then
        local empty = listContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        empty:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
        empty:SetWidth(182)
        empty:SetJustifyH("LEFT")
        empty:SetText(canManageGroups and "No matching activities. Use Create Activity to add one." or "No matching activities yet.")
        styleBodyText(empty, true)

        local detailEmpty = detailContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        detailEmpty:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -14)
        detailEmpty:SetWidth(620)
        detailEmpty:SetJustifyH("LEFT")
        detailEmpty:SetText(canManageGroups
            and "Select Create Activity to add a new event. Activity details now open in a popup instead of inline fields."
            or "No activity is selected. When officers or the guild master create events, they will appear here with signup actions.")
        styleBodyText(detailEmpty)

        self:RestoreGroupsScrollState()
        return
    end

    for index, group in ipairs(groups) do
        local button = CreateFrame("Button", nil, listContent, "BackdropTemplate")
        button:SetSize(194, 44)
        button:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 48))
        applySurfaceStyle(button, group.id == selectedGroupId and THEME.card or THEME.panel)
        button:SetScript("OnClick", function()
            addon.MainFrame:SetSelectedGroup(group.id)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        local title = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        title:SetPoint("TOPLEFT", button, "TOPLEFT", 8, -7)
        title:SetWidth(canManageGroups and 154 or 178)
        title:SetJustifyH("LEFT")
        title:SetText(cropText(group.eventName or group.name or "Activity", 24))
        styleHighlightText(title)

        local meta = button:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        meta:SetPoint("TOPLEFT", button, "TOPLEFT", 8, -23)
        meta:SetWidth(canManageGroups and 154 or 178)
        meta:SetJustifyH("LEFT")
        meta:SetText(string.format(
            "%s | %s | %s",
            getGroupTypeLabel(group),
            getGroupDifficultyLabel(group),
            group.recurrence and group.recurrence.mode ~= "none" and (group.recurrenceSummary or "Recurring") or formatStartLabel(group, group.nextStartAt or group.startAt)
        ))
        styleBodyText(meta, true)

        if canManageGroups then
            local deleteButton = createButton(button, "X", 22, 18, "TOPRIGHT", button, "TOPRIGHT", -6, -8, function()
                addon.GroupsService:DeleteGroup(group.id)

                if addon.MainFrame:GetSelectedGroupId() == group.id then
                    addon.MainFrame:SetSelectedGroup(nil)
                end

                addon.MainFrame:RefreshCurrentView(true)
            end)
            deleteButton:SetFrameLevel(button:GetFrameLevel() + 3)
        end
    end

    local selectedGroup = selectedGroupId and addon.GroupsService:GetGroup(selectedGroupId) or nil

    if not selectedGroup then
        self:RestoreGroupsScrollState()
        return
    end

    local canManage = addon.GroupsService:CanManageGroup(selectedGroup)
    local activeOccurrenceStart = getActiveOccurrenceStart(selectedGroup)
    local occurrenceDisplay = addon.GroupsService:GetOccurrenceDisplay(selectedGroup, activeOccurrenceStart)
    local planner = addon.GroupsService:GetPlannerData(selectedGroup.id, activeOccurrenceStart)
    local isSignedUp = addon.GroupsService:IsSignedUp(selectedGroup.id, nil, activeOccurrenceStart)
    local signupSearchText = tostring(self.state.groupsSignupSearchText or "")
    local playerName = addon.Store:GetPlayerName()
    local filteredCandidates = {}

    for _, member in ipairs(sortedCandidates) do
        if matchesSignupSearch(member, signupSearchText) then
            filteredCandidates[#filteredCandidates + 1] = member
        end
    end

    local filteredMyToons = {}

    for _, member in ipairs(myToons) do
        if matchesSignupSearch(member, signupSearchText) then
            filteredMyToons[#filteredMyToons + 1] = member
        end
    end

    local quickSignupCandidates = {}
    local quickSignupSeen = {}

    local function addQuickSignupCandidate(memberId, labelText)
        local resolvedMember = type(memberId) == "table" and memberId or addon.RosterService:GetMember(memberId)
        local key = resolvedMember and (resolvedMember.id or resolvedMember.fullName) or tostring(memberId or "")

        if key == "" or quickSignupSeen[key] then
            return
        end

        quickSignupSeen[key] = true
        quickSignupCandidates[#quickSignupCandidates + 1] = {
            member = resolvedMember,
            memberId = key,
            label = labelText or (resolvedMember and resolvedMember.fullName) or key,
            signedUp = addon.GroupsService:IsSignedUp(selectedGroup.id, key, activeOccurrenceStart),
        }
    end

    addQuickSignupCandidate(playerName, "Me")

    for _, member in ipairs(filteredMyToons) do
        local label = member.id == playerName and "Me" or cropText(member.fullName or member.id or "Alt", 18)
        addQuickSignupCandidate(member, label)

        if #quickSignupCandidates >= 5 then
            break
        end
    end

    local title = detailContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -14)
    title:SetText(selectedGroup.eventName or selectedGroup.name or "Activity")
    styleTitleText(title, 18)

    local meta = detailContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    meta:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -36)
    meta:SetWidth(638)
    meta:SetJustifyH("LEFT")
    meta:SetText(string.format(
        "Owner %s | %s | %s | %s",
        selectedGroup.owner or "unknown",
        canManage and "Officer edit access" or "Signup access",
        selectedGroup.recurrence and selectedGroup.recurrence.mode ~= "none"
            and (planner and planner.recurrenceSummary or selectedGroup.recurrenceSummary or "Recurring")
            or formatStartLabel(selectedGroup, selectedGroup.startAt),
        occurrenceDisplay and occurrenceDisplay.creatorTimezoneLabel ~= "" and ("Creator " .. occurrenceDisplay.creatorTimezoneLabel) or "Shown in your local time"
    ))
    styleBodyText(meta, true)

    local occurrenceButton

    if selectedGroup.recurrence and selectedGroup.recurrence.mode ~= "none" then
        local occurrences = planner and planner.upcomingOccurrences or addon.GroupsService:GetUpcomingOccurrences(selectedGroup, 5)

        occurrenceButton = createButton(detailContent, "Occurrence: " .. formatOccurrenceLabel(selectedGroup, activeOccurrenceStart), 190, 24, "TOPLEFT", detailContent, "TOPLEFT", 14, -58, function()
            if #occurrences == 0 then
                return
            end

            local nextIndex = 1

            for index, occurrence in ipairs(occurrences) do
                if tonumber(occurrence.startAt) == tonumber(activeOccurrenceStart) then
                    nextIndex = (index % #occurrences) + 1
                    break
                end
            end

            addon.MainFrame.state.groupsSelectedOccurrenceGroupId = selectedGroup.id
            addon.MainFrame.state.groupsSelectedOccurrenceStart = occurrences[nextIndex] and occurrences[nextIndex].startAt or activeOccurrenceStart
            addon.MainFrame:RefreshCurrentView(true)
        end)
    end

    local signupAnchor = occurrenceButton and 210 or 14
    local signupButton = createButton(detailContent, isSignedUp and "Withdraw" or "Sign Up", 110, 24, "TOPLEFT", detailContent, "TOPLEFT", signupAnchor, -58, function()
        local ok = isSignedUp and addon.GroupsService:WithdrawPlayer(selectedGroup.id, activeOccurrenceStart) or addon.GroupsService:SignupPlayer(selectedGroup.id, activeOccurrenceStart)

        if not ok then
            addon:Print(isSignedUp and "Could not withdraw from that activity." or "Could not sign up for that activity.")
            return
        end

        addon.MainFrame:RefreshCurrentView(true)
    end)

    if canManage then
        createButton(detailContent, "Edit", 80, 24, "LEFT", signupButton, "RIGHT", 8, 0, function()
            openGroupsEditor(selectedGroup)
        end)

        createButton(detailContent, "Invite Best", 110, 24, "LEFT", signupButton, "RIGHT", 96, 0, function()
            local ok = addon.GroupsService:InviteSuggestedGroup(selectedGroup.id, activeOccurrenceStart)

            if not ok then
                addon:Print("No suggested lineup available for that activity yet.")
            end
        end)

        createButton(detailContent, "Invite All", 110, 24, "LEFT", signupButton, "RIGHT", 214, 0, function()
            addon.GroupsService:InviteGroup(selectedGroup.id, activeOccurrenceStart)
        end)

        createButton(detailContent, "Delete", 82, 24, "LEFT", signupButton, "RIGHT", 332, 0, function()
            addon.GroupsService:DeleteGroup(selectedGroup.id)
            addon.MainFrame:SetSelectedGroup(nil)
            addon.MainFrame:RefreshCurrentView(true)
        end)
    else
        local note = detailContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        note:SetPoint("LEFT", signupButton, "RIGHT", 12, 0)
        note:SetWidth(500)
        note:SetJustifyH("LEFT")
        note:SetText("Signup is available here. Only officers and the guild master can open the activity editor.")
        styleBodyText(note, true)
    end

    local summaryCardOptions = {
        insetX = 12,
        subFontSize = 10,
        subGap = 8,
        subSpacing = -2,
        titleSize = 14,
        titleTop = 10,
        valueGap = 10,
        valueSize = 17,
    }

    createCard(detailContent, 152, 82, "Type", getGroupTypeLabel(selectedGroup), getGroupDifficultyLabel(selectedGroup), 14, -94, summaryCardOptions)
    createCard(detailContent, 152, 82, selectedGroup.recurrence and selectedGroup.recurrence.mode ~= "none" and "Next Run" or "Start", occurrenceDisplay and occurrenceDisplay.localDateText ~= "" and occurrenceDisplay.localDateText:sub(6) or "Unset", occurrenceDisplay and occurrenceDisplay.localTimeText ~= "" and (occurrenceDisplay.localTimeText .. " local") or "Add a start time", 172, -94, summaryCardOptions)
    createCard(detailContent, 152, 82, "Signups", tostring(planner and planner.totalSignups or 0), string.format("Qualified %d", planner and planner.qualifiedCount or 0), 330, -94, summaryCardOptions)
    createCard(detailContent, 152, 82, "Needs", tostring((planner and planner.deficits and (planner.deficits.tanks + planner.deficits.healers + planner.deficits.melee + planner.deficits.ranged)) or 0), buildCompactNeedsSummary(planner and planner.deficits), 488, -94, summaryCardOptions)

    local infoPanel = createPanel(detailContent)
    infoPanel:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -186)
    infoPanel:SetSize(636, 236)

    local infoTitle = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    infoTitle:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 12, -12)
    infoTitle:SetText("Activity Details")
    styleTitleText(infoTitle, 15)

    local gridHost = CreateFrame("Frame", nil, infoPanel)
    gridHost:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 8, -38)
    gridHost:SetSize(620, 184)

    local gridColumns = 4
    local gridRows = 4
    local cellWidth = 155
    local cellHeight = 46
    local durationHours = math.max(tonumber(selectedGroup.durationHours) or 1, 1)
    local gridEntries = {
        { label = "Event", value = selectedGroup.eventName or selectedGroup.name or "Activity" },
        { label = "Duration", value = string.format("%d hour%s", durationHours, durationHours == 1 and "" or "s") },
        { label = "Type", value = getGroupTypeLabel(selectedGroup) },
        { label = "Difficulty", value = getGroupDifficultyLabel(selectedGroup) },
        { label = "Min iLvl", value = tostring(selectedGroup.minItemLevel or 0) },
        { label = "Repeat", value = selectedGroup.recurrenceSummary or "One-time" },
        { label = "Local Time", value = occurrenceDisplay and occurrenceDisplay.localLabel or "Not scheduled" },
        { label = "Creator Time", value = occurrenceDisplay and occurrenceDisplay.creatorLabel or "Viewer local" },
        { label = "Roles", value = string.format("T%s H%s M%s R%s", tostring(selectedGroup.requirements and selectedGroup.requirements.tanks or 0), tostring(selectedGroup.requirements and selectedGroup.requirements.healers or 0), tostring(selectedGroup.requirements and selectedGroup.requirements.melee or 0), tostring(selectedGroup.requirements and selectedGroup.requirements.ranged or 0)) },
        { label = "Signups", value = tostring(planner and planner.totalSignups or 0) },
        { label = "Qualified", value = tostring(planner and planner.qualifiedCount or 0) },
        { label = "Updated", value = selectedGroup.updatedAt and date("%m/%d %H:%M", selectedGroup.updatedAt) or "Unknown" },
        { label = "Time Zone", value = occurrenceDisplay and occurrenceDisplay.creatorTimezoneLabel ~= "" and occurrenceDisplay.creatorTimezoneLabel or "Viewer local" },
        { label = "Event Managed By", value = "Officers / GMs" },
        { label = "Owner", value = tostring(selectedGroup.owner or "Unknown") },
        { label = "Updated By", value = tostring(selectedGroup.updatedBy or "Unknown") },
        { label = "Next Reminder", value = "Same day login + 5m" },
    }

    for rowIndex = 1, gridRows do
        for columnIndex = 1, gridColumns do
            local entry = gridEntries[((rowIndex - 1) * gridColumns) + columnIndex] or { label = "", value = "" }
            local cell = CreateFrame("Frame", nil, gridHost, "BackdropTemplate")
            cell:SetSize(cellWidth, cellHeight)
            cell:SetPoint("TOPLEFT", gridHost, "TOPLEFT", (columnIndex - 1) * cellWidth, -((rowIndex - 1) * cellHeight))
            applySurfaceStyle(cell, THEME.card)

            local label = cell:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            label:SetPoint("TOPLEFT", cell, "TOPLEFT", 8, -6)
            label:SetWidth(cellWidth - 14)
            label:SetJustifyH("LEFT")
            label:SetText(entry.label or "")
            styleHighlightText(label)

            local value = cell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            value:SetPoint("TOPLEFT", cell, "TOPLEFT", 8, -24)
            value:SetWidth(cellWidth - 14)
            value:SetJustifyH("LEFT")
            value:SetText(cropText(entry.value or "", 20))
            styleBodyText(value, true)
        end
    end

    local suggestedPanel = createPanel(detailContent)
    suggestedPanel:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -436)
    suggestedPanel:SetSize(636, 102)

    local suggestedTitle = suggestedPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    suggestedTitle:SetPoint("TOPLEFT", suggestedPanel, "TOPLEFT", 12, -12)
    suggestedTitle:SetText("Suggested Lineup")
    styleTitleText(suggestedTitle, 15)

    createReadOnlyMultiline(suggestedPanel, 612, 64, "TOPLEFT", suggestedPanel, "TOPLEFT", 12, -30, buildSuggestedText(planner))

    local signupsPanel = createPanel(detailContent)
    signupsPanel:SetPoint("TOPLEFT", detailContent, "TOPLEFT", 14, -550)
    signupsPanel:SetSize(636, 536)

    local signupsTitle = signupsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    signupsTitle:SetPoint("TOPLEFT", signupsPanel, "TOPLEFT", 12, -12)
    signupsTitle:SetText("Signups")
    styleTitleText(signupsTitle, 15)

    local signupSearchInput

    local clearSearchButton = createButton(signupsPanel, "Clear", 50, 22, "TOPRIGHT", signupsPanel, "TOPRIGHT", -12, -10, function()
        addon.MainFrame.state.groupsSignupSearchText = ""
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local searchButton = createButton(signupsPanel, "Find", 50, 22, "RIGHT", clearSearchButton, "LEFT", -6, 0, function()
        addon.MainFrame.state.groupsSignupSearchText = signupSearchInput:GetText() or ""
        addon.MainFrame:RefreshCurrentView(true)
    end)

    signupSearchInput = createInput(signupsPanel, 170, 22, "RIGHT", searchButton, "LEFT", -8, 0)
    signupSearchInput:SetText(signupSearchText)
    table.insert(self.groupsInputs, signupSearchInput)
    signupSearchInput:SetScript("OnTextChanged", function(self)
        addon.MainFrame.state.groupsSignupSearchText = self:GetText() or ""
    end)
    signupSearchInput:SetScript("OnEnterPressed", function(self)
        addon.MainFrame.state.groupsSignupSearchText = self:GetText() or ""
        addon.MainFrame:RefreshCurrentView(true)
        self:ClearFocus()
    end)
    signupSearchInput:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)

    local signupSearchLabel = signupsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    signupSearchLabel:SetPoint("RIGHT", signupSearchInput, "LEFT", -8, 0)
    signupSearchLabel:SetText("Search")
    styleHighlightText(signupSearchLabel)

    local suggestionLabel = signupsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    suggestionLabel:SetPoint("TOPLEFT", signupsPanel, "TOPLEFT", 12, -48)
    suggestionLabel:SetWidth(612)
    suggestionLabel:SetJustifyH("LEFT")
    suggestionLabel:SetText(signupSearchText ~= "" and "Quick matches" or "Quick signup")
    styleBodyText(suggestionLabel, true)

    local quickAnchor = 12
    local quickRowWidth = 596

    for _, candidate in ipairs(quickSignupCandidates) do
        local buttonText = candidate.signedUp and (candidate.label .. " Signed") or ("Add " .. candidate.label)
        local buttonWidth = math.max(78, math.min(126, 18 + (string.len(buttonText) * 6)))

        if quickAnchor + buttonWidth > quickRowWidth then
            break
        end

        local quickButton = createButton(signupsPanel, buttonText, buttonWidth, 20, "TOPLEFT", signupsPanel, "TOPLEFT", quickAnchor, -64, function()
            if candidate.signedUp and candidate.memberId == playerName then
                addon.GroupsService:WithdrawPlayer(selectedGroup.id, activeOccurrenceStart)
            elseif not candidate.signedUp then
                addon.GroupsService:AddMember(selectedGroup.id, candidate.member or candidate.memberId, activeOccurrenceStart)
            end

            addon.MainFrame:RefreshCurrentView(true)
        end)

        if candidate.signedUp then
            styleActionButton(quickButton, true)

            if candidate.memberId ~= playerName then
                quickButton:Disable()
            end
        end

        quickAnchor = quickAnchor + buttonWidth + 8
    end

    local summaryText = signupsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    summaryText:SetPoint("TOPLEFT", signupsPanel, "TOPLEFT", 12, -92)
    summaryText:SetWidth(600)
    summaryText:SetJustifyH("LEFT")
    summaryText:SetText(string.format(
        "%s | Qualified %d of %d | Suggested %d | Ready bench %d | Low iLvl %d | %s",
        selectedGroup.recurrence and selectedGroup.recurrence.mode ~= "none" and formatOccurrenceLabel(selectedGroup, activeOccurrenceStart) or "One-time activity",
        planner and planner.qualifiedCount or 0,
        planner and planner.totalSignups or 0,
        planner and #(planner.suggested or {}) or 0,
        planner and (planner.statusCounts.ready or 0) or 0,
        planner and (planner.statusCounts.lowItemLevel or 0) or 0,
        buildNeedsSummary(planner and planner.deficits)
    ))
    styleBodyText(summaryText, true)

    local signupFooterHeight = canManage and 74 or 40

    local signupScroll = CreateFrame("ScrollFrame", nil, signupsPanel, "UIPanelScrollFrameTemplate")
    signupScroll:SetPoint("TOPLEFT", signupsPanel, "TOPLEFT", 12, -112)
    signupScroll:SetPoint("BOTTOMRIGHT", signupsPanel, "BOTTOMRIGHT", -8, signupFooterHeight)
    self.groupsSignupScrollFrame = signupScroll
    signupScroll:HookScript("OnVerticalScroll", function(frame, offset)
        addon.MainFrame:EnsureState()
        addon.MainFrame.state.groupsSignupScrollOffset = tonumber(offset) or tonumber(frame:GetVerticalScroll()) or 0
    end)

    local signupContent = CreateFrame("Frame", nil, signupScroll)
    signupContent:SetSize(608, math.max(20 + ((planner and #planner.evaluations or 0) * 24), 1))
    signupScroll:SetScrollChild(signupContent)

    local function createCell(parent, x, width, textValue)
        local cell = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        cell:SetSize(width, 20)
        cell:SetPoint("TOPLEFT", parent, "TOPLEFT", x, 0)
        cell:SetBackdrop({
            bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = false,
            edgeSize = 16,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        cell:SetBackdropColor(0, 0, 0, 1)

        local text = cell:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        text:SetPoint("LEFT", cell, "LEFT", 4, 0)
        text:SetPoint("RIGHT", cell, "RIGHT", -4, 0)
        text:SetJustifyH("LEFT")
        text:SetText(cropText(textValue or "", math.max(6, math.floor((width - 10) / 6))))
        styleBodyText(text, true)
        return cell, text
    end

    local headers = {
        { title = "Toon", width = 140 },
        { title = "Type", width = 44 },
        { title = "On", width = 42 },
        { title = "Role", width = 54 },
        { title = "iLvl", width = 48 },
        { title = "Status", width = 76 },
        { title = "Reason", width = 122 },
    }
    local headerX = 0

    for _, header in ipairs(headers) do
        local label = signupContent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("TOPLEFT", signupContent, "TOPLEFT", headerX + 4, 0)
        label:SetWidth(header.width - 8)
        label:SetJustifyH("LEFT")
        label:SetText(header.title)
        styleHighlightText(label)
        headerX = headerX + header.width
    end

    local rowY = -20

    local function getSignupCharacterType(rosterMember, entry)
        local fullName = addon.Store:NormalizeFullName((rosterMember and (rosterMember.fullName or rosterMember.id)) or (entry and (entry.fullName or entry.memberId)) or "", GetRealmName() or "Unknown")
        local mainCharacter = addon.Store:NormalizeFullName(rosterMember and rosterMember.mainCharacter or fullName, GetRealmName() or "Unknown")

        if mainCharacter ~= "" and fullName ~= "" and mainCharacter ~= fullName then
            return "Alt"
        end

        return "Main"
    end

    for _, evaluation in ipairs(planner and planner.evaluations or {}) do
        local entry = evaluation.entry
        local row = CreateFrame("Frame", nil, signupContent)
        row:SetSize(608, 20)
        row:SetPoint("TOPLEFT", signupContent, "TOPLEFT", 0, rowY)

        local rosterMember = entry.memberId and (addon.RosterService:GetMyToonMember(entry.memberId) or addon.RosterService:GetMember(entry.memberId)) or nil
        local _, toonText = createCell(row, 0, 140, entry.fullName or "Unknown")
        createCell(row, 140, 44, getSignupCharacterType(rosterMember, entry))
        local _, onlineText = createCell(row, 184, 42, rosterMember and rosterMember.online and "On" or "Off")
        createCell(row, 226, 54, entry.role or "?")
        createCell(row, 280, 48, tostring(entry.itemLevel or 0))
        local _, statusText = createCell(row, 328, 76, evaluation.status or "-")
        createCell(row, 404, 122, evaluation.reason or "-")

        local classColor = RAID_CLASS_COLORS and RAID_CLASS_COLORS[(rosterMember and rosterMember.classFile) or entry.classFile or ""] or nil
        if classColor then
            toonText:SetTextColor(classColor.r, classColor.g, classColor.b)
        end

        onlineText:SetTextColor(rosterMember and rosterMember.online and 0.72 or 0.72, rosterMember and rosterMember.online and 0.92 or 0.62, rosterMember and rosterMember.online and 0.72 or 0.62, 1)

        if evaluation.status == "Suggested" then
            statusText:SetTextColor(0.82, 0.92, 0.62, 1)
        elseif evaluation.status == "Ready" then
            statusText:SetTextColor(0.72, 0.84, 0.98, 1)
        elseif evaluation.status == "Low iLvl" then
            statusText:SetTextColor(0.96, 0.76, 0.38, 1)
        else
            statusText:SetTextColor(0.9, 0.58, 0.58, 1)
        end

        if canManage then
            local actionX = 530
            local inviteButton = createButton(row, "I", 20, 20, "TOPLEFT", row, "TOPLEFT", actionX, 0, function()
                addon.GroupsService:InviteMember(selectedGroup.id, entry.memberId or entry.fullName, activeOccurrenceStart)
            end)
            styleActionButton(inviteButton)

            if not (rosterMember and rosterMember.online) then
                inviteButton:Disable()
            end

            if rosterMember and rosterMember.battleTag then
                local bnetButton = createButton(row, "B", 20, 20, "TOPLEFT", row, "TOPLEFT", actionX + 24, 0, function()
                    local ok = addon.GroupsService:InviteMemberBattleNet(selectedGroup.id, entry.memberId or entry.fullName, activeOccurrenceStart)

                    if not ok then
                        addon:Print("Could not send a Battle.net invite for that signup.")
                    end
                end)
                styleActionButton(bnetButton)

                if not (rosterMember and rosterMember.online) then
                    bnetButton:Disable()
                end
            end

            local removeButton = createButton(row, "X", 20, 20, "TOPLEFT", row, "TOPLEFT", actionX + (rosterMember and rosterMember.battleTag and 48 or 24), 0, function()
                addon.GroupsService:RemoveMember(selectedGroup.id, entry.memberId or entry.fullName, activeOccurrenceStart)
                addon.MainFrame:RefreshCurrentView(true)
            end)
            styleActionButton(removeButton)
        end

        rowY = rowY - 24
    end

    if canManage then
        local footerPanel = CreateFrame("Frame", nil, signupsPanel)
        footerPanel:SetSize(612, 54)
        footerPanel:SetPoint("BOTTOMLEFT", signupsPanel, "BOTTOMLEFT", 8, 8)

        local remindButton = createButton(footerPanel, "Remind", 72, 22, "TOPLEFT", footerPanel, "TOPLEFT", 0, 0, function()
            local ok, sent = addon.GroupsService:SendSuggestedReminder(selectedGroup.id, activeOccurrenceStart)

            if not ok then
                addon:Print("No online accepted signups were available for a reminder.")
                return
            end

            addon:Print(string.format("Sent %d event reminder(s).", sent or 0))
        end)

        local inviteAcceptedButton = createButton(footerPanel, "Invite All", 82, 22, "LEFT", remindButton, "RIGHT", 4, 0, function()
            local ok, invited = addon.GroupsService:InviteSuggestedOnline(selectedGroup.id, activeOccurrenceStart)

            if not ok then
                addon:Print("No online accepted signups were available to invite.")
                return
            end

            addon:Print(string.format("Sent %d invite(s) to the accepted lineup.", invited or 0))
        end)

        local addMeButton = createButton(footerPanel, "Add Me", 74, 22, "LEFT", inviteAcceptedButton, "RIGHT", 4, 0, function()
            addon.GroupsService:AddMember(selectedGroup.id, addon.Store:GetPlayerName(), activeOccurrenceStart)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        local myToonsDropdown = createMenuButton(footerPanel, "Add My Toon", 162, 24, "TOPLEFT", footerPanel, "TOPLEFT", 0, -26)
        myToonsDropdown:SetScript("OnClick", function(buttonSelf)
            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                if #filteredMyToons == 0 then
                    menuOptions[#menuOptions + 1] = {
                        text = "No matching toon",
                        disabled = true,
                        notCheckable = true,
                    }
                else
                    for _, toon in ipairs(filteredMyToons) do
                        menuOptions[#menuOptions + 1] = {
                            text = toon.fullName,
                            value = toon,
                            onClick = function()
                                addon.GroupsService:AddMember(selectedGroup.id, toon, activeOccurrenceStart)
                                addon.MainFrame:RefreshCurrentView(true)
                            end,
                        }
                    end
                end

                return menuOptions
            end)
        end)
        setMenuButtonText(myToonsDropdown, #filteredMyToons > 0 and "Add My Toon" or "No matching toon")

        local rosterDropdown = createMenuButton(footerPanel, "Add Signup from Guild", 218, 24, "LEFT", myToonsDropdown, "RIGHT", 6, 0)
        rosterDropdown:SetScript("OnClick", function(buttonSelf)
            showMenuButton(buttonSelf, function(level, menuList)
                local chunkSize = 20
                local menuOptions = {}

                if not level or level == 1 then
                    local totalChunks = math.ceil(#filteredCandidates / chunkSize)

                    if totalChunks == 0 then
                        menuOptions[#menuOptions + 1] = {
                            text = signupSearchText ~= "" and "No matches" or "No candidates",
                            disabled = true,
                            notCheckable = true,
                        }
                        return menuOptions
                    end

                    for chunkIndex = 1, totalChunks do
                        local startIndex = ((chunkIndex - 1) * chunkSize) + 1
                        local endIndex = math.min(chunkIndex * chunkSize, #filteredCandidates)
                        menuOptions[#menuOptions + 1] = {
                            text = string.format("Toon %d-%d", startIndex, endIndex),
                            hasArrow = true,
                            notCheckable = true,
                            menuList = { startIndex = startIndex, endIndex = endIndex },
                        }
                    end

                    return menuOptions
                end

                local range = menuList or {}

                for candidateIndex = range.startIndex or 1, range.endIndex or 0 do
                    local candidate = filteredCandidates[candidateIndex]

                    if candidate then
                        menuOptions[#menuOptions + 1] = {
                            text = candidate.fullName,
                            value = candidate,
                            onClick = function()
                                addon.GroupsService:AddMember(selectedGroup.id, candidate, activeOccurrenceStart)
                                addon.MainFrame:RefreshCurrentView(true)
                            end,
                        }
                    end
                end

                return menuOptions
            end)
        end)
        setMenuButtonText(rosterDropdown, signupSearchText ~= "" and string.format("Guild Matches (%d)", #filteredCandidates) or "Add Signup from Guild")

        local partyDropdown = createMenuButton(footerPanel, "Add Party/Raid", 176, 24, "LEFT", rosterDropdown, "RIGHT", 6, 0)
        partyDropdown:SetScript("OnClick", function(buttonSelf)
            showMenuButton(buttonSelf, function()
                local menuOptions = {}
                local groupPrefix = IsInRaid() and "raid" or "party"
                local groupCount = GetNumGroupMembers()

                if UnitExists("player") and not IsInRaid() then
                    menuOptions[#menuOptions + 1] = {
                        text = addon.Store:GetPlayerName(),
                        onClick = function()
                            addon.GroupsService:AddPartyOrRaidMember(selectedGroup.id, addon.Store:GetPlayerName(), activeOccurrenceStart)
                            addon.MainFrame:RefreshCurrentView(true)
                        end,
                    }
                end

                for partyIndex = 1, groupCount do
                    local unitId = groupPrefix .. partyIndex

                    if UnitExists(unitId) then
                        local unitName = UnitName(unitId)
                        local unitRealm = GetRealmName()
                        local fullName = unitName and unitRealm and (unitName .. "-" .. unitRealm) or unitName

                        if fullName then
                            menuOptions[#menuOptions + 1] = {
                                text = fullName,
                                value = fullName,
                                onClick = function()
                                    addon.GroupsService:AddPartyOrRaidMember(selectedGroup.id, fullName, activeOccurrenceStart)
                                    addon.MainFrame:RefreshCurrentView(true)
                                end,
                            }
                        end
                    end
                end

                if #menuOptions == 0 then
                    menuOptions[#menuOptions + 1] = {
                        text = "No party or raid members",
                        disabled = true,
                        notCheckable = true,
                    }
                end

                return menuOptions
            end)
        end)
        setMenuButtonText(partyDropdown, "Add Party/Raid")
    end

    self:RestoreGroupsScrollState()
end

function addon.MainFrame:RenderSync()
    local host = self:GetViewHost()
    local sessions = addon.SessionManager:ListActive()
    local peers = addon.Reconciler:GetPeerList()
    local conflicts = addon.Reconciler:ListConflicts()
    local localStatus = addon.Reconciler:BuildLocalStatus()
    local syncDiagnostics = addon.Reconciler:BuildSyncDiagnostics()
    local selectedPeer = self:GetSelectedSyncPeer(peers)
    local disabledSyncFeatures = {}

    local function findPeerRecord(peerName)
        for _, peer in ipairs(peers or {}) do
            if peer.sender == peerName or peer.player == peerName then
                return peer
            end
        end

        return nil
    end

    if not addon:IsFeatureEnabled("mythics") then
        disabledSyncFeatures[#disabledSyncFeatures + 1] = "Mythic+ auto-refresh and party mythic autosync"
    end

    if not addon:IsFeatureEnabled("groups") then
        disabledSyncFeatures[#disabledSyncFeatures + 1] = "Groups background refresh handling"
    end

    local info = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    info:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -14)
    info:SetText(syncDiagnostics.manualOnly and "Manual-only mode is enabled. Incoming peer sync requests are ignored until you sync on demand." or "Peer status sync is available. Use Refresh Peers to compare visible guild members against your local table state.")
    styleBodyText(info)

    if #disabledSyncFeatures > 0 then
        local warningPanel = createPanel(host)
        warningPanel:SetPoint("TOPRIGHT", host, "TOPRIGHT", -18, -38)
        warningPanel:SetSize(452, 52)

        local warningTitle = warningPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        warningTitle:SetPoint("TOPLEFT", warningPanel, "TOPLEFT", 12, -10)
        warningTitle:SetText("Settings Warning")
        styleTitleText(warningTitle, 15)

        local warningBody = warningPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        warningBody:SetPoint("TOPLEFT", warningTitle, "BOTTOMLEFT", 0, -4)
        warningBody:SetWidth(426)
        warningBody:SetJustifyH("LEFT")
        warningBody:SetText("Disabled by Settings: " .. table.concat(disabledSyncFeatures, " and ") .. ". Lower activity for those features is expected.")
        styleBodyText(warningBody, true)
    end

    local function getActiveSyncPeer()
        return addon.MainFrame:GetSelectedSyncPeer(addon.Reconciler:GetPeerList())
    end

    local function getProbePeers()
        local peers = {}
        local seen = {}
        local localGuildKey = addon.Store:EnsureHomeGuild()

        for _, discoveredPeer in ipairs(addon.Reconciler:GetPeerList()) do
            local isValidPeer, fullName = addon.Store:IsLikelyPlayerFullName(discoveredPeer.sender or discoveredPeer.player, GetRealmName() or "Unknown")

            if isValidPeer and not seen[fullName] and discoveredPeer.homeGuild == localGuildKey then
                seen[fullName] = true
                peers[#peers + 1] = {
                    fullName = fullName,
                    guildKey = discoveredPeer.homeGuild,
                    source = "guild",
                }
            end
        end

        local unitIds = {}

        if IsInRaid() then
            for index = 1, (GetNumGroupMembers() or 0) do
                unitIds[#unitIds + 1] = "raid" .. index
            end
        else
            for index = 1, (GetNumSubgroupMembers() or 0) do
                unitIds[#unitIds + 1] = "party" .. index
            end
        end

        for _, unitId in ipairs(unitIds) do
            if UnitExists(unitId) and UnitIsPlayer(unitId) and not UnitIsUnit(unitId, "player") then
                local unitName, unitRealm = UnitFullName(unitId)
                local isValidPeer, fullName = addon.Store:IsLikelyPlayerFullName(unitName, unitRealm or GetRealmName() or "Unknown")
                local guildName, _, _, guildRealm = GetGuildInfo(unitId)
                local normalizedGuildRealm = addon.Store:NormalizeRealmName(guildRealm, unitRealm or GetRealmName() or "Unknown")
                local guildKey = (guildName and guildName ~= "" and normalizedGuildRealm and normalizedGuildRealm ~= "") and (guildName .. "-" .. normalizedGuildRealm) or nil

                if isValidPeer and not seen[fullName] then
                    seen[fullName] = true
                    peers[#peers + 1] = {
                        fullName = fullName,
                        guildKey = guildKey,
                        source = "group",
                    }
                end
            end
        end

        table.sort(peers, function(left, right)
            return (left.fullName or "") < (right.fullName or "")
        end)
        return peers
    end

    local refreshPeersButton = createButton(host, "Refresh Peers", 104, 24, "TOPLEFT", host, "TOPLEFT", 18, -76, function()
        local ok, message = addon.Reconciler:RequestGuildStatus()

        if not ok and message then
            addon:Print(message)
        end
    end)

    local syncRosterButton = createButton(host, "Sync Roster", 90, 24, "TOPLEFT", host, "TOPLEFT", 18, -44, function()
        local ok, message, startedSync = addon.Reconciler:RequestBidirectionalSnapshot(getActiveSyncPeer(), "roster")

        if not ok then
            addon:Print(message)
        elseif startedSync == false and message then
            addon:Print(message)
        elseif message and type(message) == "string" and message:find("Peer sync%-back request failed", 1, false) then
            addon:Print(message)
        end
    end)

    local syncMyToonsButton = createButton(host, "Sync Toons+Mythics", 132, 24, "LEFT", syncRosterButton, "RIGHT", 8, 0, function()
        local ok, message = addon.Reconciler:RequestMyToonsBundle(getActiveSyncPeer())

        if not ok then
            addon:Print(message)
        elseif message and type(message) == "string" and (message:find("Already matched", 1, true) or message:find("Issues:", 1, true)) then
            addon:Print(message)
        elseif message and type(message) == "string" and message:find("Peer sync%-back request failed", 1, false) then
            addon:Print(message)
        end
    end)

    local syncBoardButton = createButton(host, "Sync Board", 90, 24, "LEFT", syncMyToonsButton, "RIGHT", 8, 0, function()
        local ok, message, startedSync = addon.Reconciler:RequestBidirectionalSnapshot(getActiveSyncPeer(), "content")

        if not ok then
            addon:Print(message)
        elseif startedSync == false and message then
            addon:Print(message)
        elseif message and type(message) == "string" and message:find("Peer sync%-back request failed", 1, false) then
            addon:Print(message)
        end
    end)

    local syncGroupsButton = createButton(host, "Sync Groups", 94, 24, "LEFT", syncBoardButton, "RIGHT", 8, 0, function()
        local ok, message, startedSync = addon.Reconciler:RequestBidirectionalSnapshot(getActiveSyncPeer(), "groups")

        if not ok then
            addon:Print(message)
        elseif startedSync == false and message then
            addon:Print(message)
        elseif message and type(message) == "string" and message:find("Peer sync%-back request failed", 1, false) then
            addon:Print(message)
        end
    end)

    local syncFederationButton = createButton(host, "Sync Federation", 112, 24, "LEFT", syncGroupsButton, "RIGHT", 8, 0, function()
        local ok, message, startedSync = addon.Reconciler:RequestBidirectionalSnapshot(getActiveSyncPeer(), "federation")

        if not ok then
            addon:Print(message)
        elseif startedSync == false and message then
            addon:Print(message)
        elseif message and type(message) == "string" and message:find("Peer sync%-back request failed", 1, false) then
            addon:Print(message)
        end
    end)

    createButton(host, "Sync All", 82, 24, "LEFT", syncFederationButton, "RIGHT", 8, 0, function()
        local ok, message = addon.Reconciler:RequestAllBidirectionalSnapshots(getActiveSyncPeer())

        if message then
            addon:Print(message)
        end
    end)

    local groupedPeerLabel = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    groupedPeerLabel:SetPoint("LEFT", refreshPeersButton, "RIGHT", 14, 0)
    groupedPeerLabel:SetText("Peer:")
    styleBodyText(groupedPeerLabel, true)

    local groupedPeers = getProbePeers()
    local groupedPeerDropdown = createMenuButton(host, #groupedPeers > 0 and "Choose Peer" or "No discovered peers", 240, 24, "LEFT", groupedPeerLabel, "RIGHT", 8, 0)
    groupedPeerDropdown.selectedValue = nil
    groupedPeerDropdown.selectedGuildKey = nil
    groupedPeerDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, peer in ipairs(groupedPeers) do
                local sourceLabel = peer.source == "group" and "grouped" or "guild"
                menuOptions[#menuOptions + 1] = {
                    text = string.format("%s (%s)", peer.fullName, sourceLabel),
                    value = peer.fullName,
                    checked = groupedPeerDropdown.selectedValue == peer.fullName,
                    onClick = function()
                        groupedPeerDropdown.selectedValue = peer.fullName
                        groupedPeerDropdown.selectedGuildKey = peer.guildKey
                        setMenuButtonText(groupedPeerDropdown, peer.fullName)
                    end,
                }
            end

            if #menuOptions == 0 then
                menuOptions[#menuOptions + 1] = {
                    text = "No discovered peers",
                    disabled = true,
                    notCheckable = true,
                }
            end

            return menuOptions
        end)
    end)

    local probePeerButton = createButton(host, "Probe Peer", 90, 24, "LEFT", groupedPeerDropdown, "RIGHT", 12, 1, function()
        local selectedPeerName = groupedPeerDropdown.selectedValue

        if not selectedPeerName or selectedPeerName == "" then
            addon:Print("Select a discovered guild peer or grouped linked-guild peer first.")
            return
        end

        addon.Reconciler:StagePeerStatusProbe(selectedPeerName, groupedPeerDropdown.selectedGuildKey)
        addon.MainFrame:SetSelectedSyncPeer(selectedPeerName)
        addon.MainFrame:RefreshCurrentView()

        local ok, message = addon.Reconciler:RequestPeerStatus(selectedPeerName)

        if not ok and message then
            addon:Print(message)
            return
        end

        addon:Print("Requested peer status from " .. selectedPeerName .. ".")
    end)

    setMenuButtonText(groupedPeerDropdown, #groupedPeers > 0 and "Choose Peer" or "No discovered peers")

    createButton(host, "Clear Conflicts", 120, 24, "LEFT", probePeerButton, "RIGHT", 12, 0, function()
        addon.Reconciler:ClearConflicts()
        addon.MainFrame:RefreshCurrentView()
    end)

    local peerSummary = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    peerSummary:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -112)
    peerSummary:SetWidth(420)
    peerSummary:SetJustifyH("LEFT")
    do
        local selectedPeerRecord = findPeerRecord(selectedPeer)
        local peerSummaryText = "Selected peer: " .. (selectedPeer or "None discovered yet")

        if selectedPeerRecord then
            peerSummaryText = string.format("%s | GH3 %s | sync %d%s",
                peerSummaryText,
                safeTrim(selectedPeerRecord.addonVersion) ~= "" and safeTrim(selectedPeerRecord.addonVersion) or "0",
                tonumber(selectedPeerRecord.syncVersion) or 0,
                addon.Reconciler:IsPeerOnNewerAddonVersion(selectedPeerRecord) and " | newer available" or "")
        end

        peerSummary:SetText(peerSummaryText)
    end
    styleBodyText(peerSummary)

    local localSummary = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    localSummary:SetPoint("TOPLEFT", host, "TOPLEFT", 430, -112)
    localSummary:SetWidth(500)
    localSummary:SetJustifyH("LEFT")
    localSummary:SetText(string.format(
        "Local GH3 %s | sync %d | roster %d | toons %d | mythics %d | board %d | groups %d | federation %d",
        safeTrim(localStatus.addonVersion) ~= "" and safeTrim(localStatus.addonVersion) or addon.Reconciler:GetLocalAddonVersion(),
        tonumber(localStatus.syncVersion) or addon.Reconciler:GetSyncProtocolVersion(),
        localStatus.tables.roster and localStatus.tables.roster.count or 0,
        localStatus.tables.mytoons and localStatus.tables.mytoons.count or 0,
        localStatus.tables.mythics and localStatus.tables.mythics.count or 0,
        localStatus.tables.content and localStatus.tables.content.count or 0,
        localStatus.tables.groups and localStatus.tables.groups.count or 0,
        localStatus.tables.federation and localStatus.tables.federation.count or 0
    ))
    styleBodyText(localSummary)

    local listHost = createPanel(host)
    listHost:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -142)
    listHost:SetSize(574, 250)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listHost, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listHost, "TOPLEFT", 0, 0)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -26, 0)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(548, 250)
    listScrollFrame:SetScrollChild(listContent)

    local diagnosticsPanel = createPanel(host)
    diagnosticsPanel:SetPoint("TOPLEFT", listHost, "TOPRIGHT", 16, 0)
    diagnosticsPanel:SetSize(324, 250)

    local diagnosticsTitle = diagnosticsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    diagnosticsTitle:SetPoint("TOPLEFT", diagnosticsPanel, "TOPLEFT", 14, -12)
    diagnosticsTitle:SetText("Sync Diagnostics")
    styleTitleText(diagnosticsTitle, 16)

    local diagnosticsLines = {
        string.format("Peers %d | conflicts %d", syncDiagnostics.peerCount or 0, syncDiagnostics.conflictCount or 0),
        string.format("Sessions active %d | failed %d | cancelled %d", syncDiagnostics.sessions.active or 0, syncDiagnostics.sessions.failed or 0, syncDiagnostics.sessions.cancelled or 0),
        string.format("Queue depth %d | inbound buffers %d", syncDiagnostics.transport.outboundQueueDepth or 0, syncDiagnostics.transport.inboundBufferCount or 0),
        string.format("Buffered chunks %d | task queue %d", syncDiagnostics.transport.inboundBufferedChunks or 0, syncDiagnostics.transport.inboundTaskQueueDepth or 0),
        string.format("Largest buffer %d | task pump %s", syncDiagnostics.transport.largestInboundBuffer or 0, syncDiagnostics.transport.inboundTaskPumpActive and "running" or "idle"),
        string.format("Oldest active %.0fs | send pump %s", syncDiagnostics.sessions.oldestActiveAge or 0, syncDiagnostics.transport.sendPumpActive and "running" or "idle"),
    }

    for index, line in ipairs(diagnosticsLines) do
        local text = diagnosticsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        text:SetPoint("TOPLEFT", diagnosticsPanel, "TOPLEFT", 14, -34 - ((index - 1) * 16))
        text:SetText(line)
        styleBodyText(text, true)
    end

    local function showLastRosterSyncDialog(reportText)
        local frame = self.lastRosterSyncFrame

        if not frame then
            frame = CreateFrame("Frame", "GH3LastRosterSyncFrame", UIParent, "BackdropTemplate")
            frame:SetFrameStrata("FULLSCREEN_DIALOG")
            frame:SetFrameLevel(200)
            frame:SetToplevel(true)
            frame:SetClampedToScreen(true)
            frame:SetMovable(true)
            frame:EnableMouse(true)
            frame:RegisterForDrag("LeftButton")
            frame:SetScript("OnDragStart", frame.StartMoving)
            frame:SetScript("OnDragStop", function(dialog)
                dialog:StopMovingOrSizing()
            end)
            frame:SetBackdrop({
                bgFile = "Interface\\Buttons\\WHITE8X8",
                edgeFile = "Interface\\Buttons\\WHITE8X8",
                edgeSize = 1,
                insets = { left = 1, right = 1, top = 1, bottom = 1 },
            })
            frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
            frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

            local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
            title:SetText("Last Roster Sync")
            title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
            title:SetTextColor(0.93, 0.84, 0.63, 1)
            frame.title = title

            createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
                frame:Hide()
            end)

            local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
            scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
            scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)

            local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
            editBox:SetMultiLine(true)
            editBox:SetFontObject("ChatFontNormal")
            editBox:SetAutoFocus(false)
            editBox:SetWidth(540)
            editBox:SetScript("OnEscapePressed", function(edit)
                edit:ClearFocus()
            end)
            scrollFrame:SetScrollChild(editBox)
            frame.editBox = editBox

            createButton(frame, "Close", 120, 24, "BOTTOM", frame, "BOTTOM", 0, 14, function()
                frame:Hide()
            end)
            self.lastRosterSyncFrame = frame
        end

        frame:SetSize(640, 380)
        frame:SetPoint("CENTER")
        frame.editBox:SetText(reportText or "No roster sync summary captured yet.")
        frame.editBox:HighlightText()
        frame.editBox:SetFocus()
        frame:Raise()
        frame:Show()
    end

    createButton(diagnosticsPanel, syncDiagnostics.manualOnly and "Mode: Manual" or "Mode: Automatic", 144, 22, "BOTTOMLEFT", diagnosticsPanel, "BOTTOMLEFT", 14, 42, function()
        local nextValue = not addon.Reconciler:IsManualOnlySync()
        addon.Reconciler:SetManualOnlySync(nextValue)
    end)

    createButton(diagnosticsPanel, "Cancel Active", 144, 22, "BOTTOMRIGHT", diagnosticsPanel, "BOTTOMRIGHT", -14, 42, function()
        local cancelled = addon.Reconciler:CancelActiveSyncs()

        if cancelled == 0 then
            addon:Print("No active sync sessions to cancel.")
        end
    end)

    createButton(diagnosticsPanel, "Roster Report", 144, 22, "BOTTOMLEFT", diagnosticsPanel, "BOTTOMLEFT", 14, 14, function()
        showLastRosterSyncDialog(addon.Reconciler:BuildLastRosterSyncReport())
    end)

    createButton(diagnosticsPanel, "Remove Peer", 144, 22, "BOTTOMRIGHT", diagnosticsPanel, "BOTTOMRIGHT", -14, 14, function()
        local peerToRemove = getActiveSyncPeer()

        if not peerToRemove then
            addon:Print("No sync peer selected.")
            return
        end

        local ok, message = addon.Reconciler:RemovePeer(peerToRemove)

        if not ok then
            addon:Print(message)
            return
        end

        addon.MainFrame:SetSelectedSyncPeer(nil)
        addon.MainFrame:RefreshCurrentView()
        addon:Print("Removed peer " .. peerToRemove .. ".")
    end)

    local rows = {}

    for _, session in ipairs(sessions) do
        local waitingForPeerReply = addon.SessionManager:IsWaitingForPeerReply(session)
        local directionLabel = session.metadata and session.metadata.direction == "incoming" and "incoming" or "outgoing"
        local stateLabel = session.status or "-"
        local progressLabel = string.format("%d/%d", session.seq or 0, session.total or 0)

        if waitingForPeerReply then
            stateLabel = addon.SessionManager:GetWaitingForPeerReplyLabel(session) or "waiting for peer reply"
            progressLabel = "awaiting"
        end

        if session.lastError and session.lastError ~= "" then
            stateLabel = stateLabel .. ": " .. session.lastError
        end

        table.insert(rows, {
            session.peer or "-",
            directionLabel,
            session.tableName or "-",
            progressLabel,
            stateLabel,
        })
    end

    for _, peer in ipairs(peers) do
        local rosterStatus = peer.tables and peer.tables.roster or {}
        local myToonsStatus = peer.tables and peer.tables.mytoons or {}
        local mythicsStatus = peer.tables and peer.tables.mythics or {}
        local modeLabel = peer.pending and "pending-probe" or (peer.probeTimedOut and "probe-timeout" or "peer-status")
        local versionLabel = string.format("GH3 %s | sync %d%s",
            safeTrim(peer.addonVersion) ~= "" and safeTrim(peer.addonVersion) or "0",
            tonumber(peer.syncVersion) or 0,
            addon.Reconciler:IsPeerOnNewerAddonVersion(peer) and " | newer" or "")
        local progressLabel = peer.pending
            and "Waiting for peer reply"
            or (peer.probeTimedOut and "No peer reply yet; manual sync still available")
            or string.format("roster %d | toons %d | mythics %d", rosterStatus.count or 0, myToonsStatus.count or 0, mythicsStatus.count or 0)
        table.insert(rows, {
            values = {
                peer.sender or peer.player or "-",
                modeLabel,
                peer.homeGuild or "-",
                versionLabel,
                progressLabel,
            },
            selected = peer.sender == selectedPeer,
            onClick = function()
                addon.MainFrame:SetSelectedSyncPeer(peer.sender)
                addon.MainFrame:RefreshCurrentView()
            end,
        })
    end

    listContent:SetWidth(548)
    listContent:SetHeight(math.max(250, 26 + (#rows * 24)))

    addon.TableView:Render(listContent, {
        headers = { "Peer", "Mode", "Guild", "Version", "Progress" },
        rows = rows,
        headerHeight = 26,
        rowHeight = 24,
    })

    local conflictPanel = createPanel(host)
    conflictPanel:SetPoint("TOPLEFT", listHost, "BOTTOMLEFT", 0, -12)
    conflictPanel:SetSize(916, 220)

    local conflictScrollFrame = CreateFrame("ScrollFrame", nil, conflictPanel, "UIPanelScrollFrameTemplate")
    conflictScrollFrame:SetPoint("TOPLEFT", conflictPanel, "TOPLEFT", 14, -52)
    conflictScrollFrame:SetPoint("BOTTOMRIGHT", conflictPanel, "BOTTOMRIGHT", -30, 12)

    local conflictContent = CreateFrame("Frame", nil, conflictScrollFrame)
    conflictContent:SetSize(872, 150)
    conflictScrollFrame:SetScrollChild(conflictContent)

    local conflictTitle = conflictPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    conflictTitle:SetPoint("TOPLEFT", conflictPanel, "TOPLEFT", 14, -12)
    conflictTitle:SetText("Conflict Queue")
    styleTitleText(conflictTitle, 16)

    local conflictSummary = addon.Reconciler:SummarizeConflicts()
    local summaryText = string.format(
        "Total %d | roster %d | my toons %d | board %d | groups %d",
        conflictSummary.total,
        conflictSummary.byTable.roster or 0,
        conflictSummary.byTable.mytoons or 0,
        conflictSummary.byTable.content or 0,
        conflictSummary.byTable.groups or 0
    )

    local conflictMeta = conflictPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    conflictMeta:SetPoint("TOPLEFT", conflictPanel, "TOPLEFT", 14, -30)
    conflictMeta:SetText(summaryText)
    styleBodyText(conflictMeta, true)

    createButton(conflictPanel, "Keep Local All", 116, 22, "TOPRIGHT", conflictPanel, "TOPRIGHT", -272, -12, function()
        local ok, resolved = addon.Reconciler:ResolveAllConflictsKeepLocal()

        if not ok then
            addon:Print(resolved)
            return
        end

        addon:Print(string.format("Kept %d local conflict records.", resolved or 0))
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(conflictPanel, "Accept Remote All", 132, 22, "TOPRIGHT", conflictPanel, "TOPRIGHT", -132, -12, function()
        local ok, message = addon.Reconciler:ResolveAllConflictsAcceptRemote()

        if not ok then
            addon:Print(message)
            return
        end

        addon:Print(message)
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(conflictPanel, "Clear All", 84, 22, "TOPRIGHT", conflictPanel, "TOPRIGHT", -14, -12, function()
        local cleared = addon.Reconciler:ClearConflicts()
        addon:Print(string.format("Cleared %d conflicts.", cleared or 0))
        addon.MainFrame:RefreshCurrentView()
    end)

    if #conflicts == 0 then
        local empty = conflictContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", conflictContent, "TOPLEFT", 0, 0)
        empty:SetText("No ambiguous record conflicts detected.")
        styleBodyText(empty)
        conflictContent:SetHeight(24)
        return
    end

    conflictContent:SetWidth(872)
    conflictContent:SetHeight(math.max(150, (#conflicts * 42) - 8))

    local function showConflictCompareDialog(conflict)
        local frame = self.conflictCompareFrame

        if not frame then
            frame = CreateFrame("Frame", "GH3ConflictCompareFrame", UIParent, "BackdropTemplate")
            frame:SetFrameStrata("FULLSCREEN_DIALOG")
            frame:SetFrameLevel(200)
            frame:SetToplevel(true)
            frame:SetClampedToScreen(true)
            frame:SetMovable(true)
            frame:EnableMouse(true)
            frame:RegisterForDrag("LeftButton")
            frame:SetScript("OnDragStart", frame.StartMoving)
            frame:SetScript("OnDragStop", function(dialog)
                dialog:StopMovingOrSizing()
            end)
            frame:SetBackdrop({
                bgFile = "Interface\\Buttons\\WHITE8X8",
                edgeFile = "Interface\\Buttons\\WHITE8X8",
                edgeSize = 1,
                insets = { left = 1, right = 1, top = 1, bottom = 1 },
            })
            frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
            frame:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)

            local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
            title:SetText("Conflict Compare")
            title:SetFont("Fonts\\MORPHEUS.ttf", 18, "OUTLINE")
            title:SetTextColor(0.93, 0.84, 0.63, 1)
            frame.title = title

            createCloseButton(frame, "TOPRIGHT", frame, "TOPRIGHT", -8, -8, function()
                frame:Hide()
            end)

            local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
            scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
            scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 48)

            local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
            editBox:SetMultiLine(true)
            editBox:SetFontObject("ChatFontNormal")
            editBox:SetAutoFocus(false)
            editBox:SetWidth(520)
            editBox:SetScript("OnEscapePressed", function(edit)
                edit:ClearFocus()
            end)
            scrollFrame:SetScrollChild(editBox)
            frame.editBox = editBox

            createButton(frame, "Close", 120, 24, "BOTTOM", frame, "BOTTOM", 0, 14, function()
                frame:Hide()
            end)
            self.conflictCompareFrame = frame
        end

        frame:SetSize(620, 360)
        frame:SetPoint("CENTER")
        frame.editBox:SetText(conflict.compareText or "No compare text is stored for this conflict yet. Clear it and reproduce the sync once to capture the detailed compare output.")
        frame.editBox:HighlightText()
        frame.editBox:SetFocus()
        frame:Raise()
        frame:Show()
    end

    for index, conflict in ipairs(conflicts) do
        local row = CreateFrame("Frame", nil, conflictContent, "BackdropTemplate")
        row:SetSize(888, 34)
        row:SetPoint("TOPLEFT", conflictContent, "TOPLEFT", 0, -((index - 1) * 42))
        row:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
        })
        row:SetBackdropColor(0.09, 0.11, 0.15, 0.95)
        row:SetBackdropBorderColor(0.16, 0.22, 0.3, 1)

        local label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", row, "LEFT", 8, 0)
        label:SetWidth(486)
        label:SetJustifyH("LEFT")
        label:SetText(string.format(
            "%s | %s | %s | %s | L%d/R%d | %s",
            conflict.tableName or "-",
            normalizeDisplayName(conflict.recordId or "-"),
            normalizeDisplayName(conflict.sender or "-"),
            conflict.reason or "-",
            conflict.localRevision or 0,
            conflict.remoteRevision or 0,
            formatConflictTime(conflict.detectedAt)
        ))
        styleBodyText(label, true)

        createButton(row, "C", 28, 20, "RIGHT", row, "RIGHT", -286, 0, function()
            showConflictCompareDialog(conflict)
        end)

        createButton(row, "Keep Local", 92, 20, "RIGHT", row, "RIGHT", -190, 0, function()
            local ok, message = addon.Reconciler:ResolveConflictKeepLocal(conflict.tableName, conflict.recordId, conflict.sender)

            if not ok then
                addon:Print(message)
                return
            end
            addon.MainFrame:RefreshCurrentView()
        end)

        createButton(row, "Accept Remote", 104, 20, "RIGHT", row, "RIGHT", -78, 0, function()
            local ok, message = addon.Reconciler:ResolveConflictAcceptRemote(conflict.tableName, conflict.recordId, conflict.sender)

            if not ok then
                addon:Print(message)
                return
            end
            addon.MainFrame:RefreshCurrentView()
        end)

        createButton(row, "Clear", 64, 20, "RIGHT", row, "RIGHT", -8, 0, function()
            addon.Reconciler:ClearConflict(conflict.tableName, conflict.recordId, conflict.sender)
            addon.MainFrame:RefreshCurrentView()
        end)
    end
end

function addon.MainFrame:RenderSetup()
    local host = self:GetViewHost()
    local linkedGuilds = addon.FederationService:GetLinkedGuilds()
    local canManageLinkedGuilds = addon.FederationService:CanManageLinkedGuilds()
    local partyPeers = {}

    if canManageLinkedGuilds and addon.FederationService.RequestPartyPeerDiscovery then
        addon.FederationService:RequestPartyPeerDiscovery(false)
        partyPeers = addon.FederationService:GetPartyHandshakePeers()
    end

    createSectionTitle(host, "Setup", 18, -18)

    local homeLabel = host:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    homeLabel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -44)
    homeLabel:SetText("Home Guild: " .. (addon.FederationService:GetHomeGuild() or "Not available"))
    styleHighlightText(homeLabel)

    local accessLabel = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    accessLabel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -68)
    accessLabel:SetText(canManageLinkedGuilds
        and "Guild Master access: group with the other guild master, discover their guild, then request and approve the link together."
        or "View only: only the guild master can add or remove linked guilds.")
    styleBodyText(accessLabel, true)

    if canManageLinkedGuilds then
        local setupHelp = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        setupHelp:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -92)
        setupHelp:SetWidth(916)
        setupHelp:SetJustifyH("LEFT")
        setupHelp:SetText("Setup rule: both guild masters must be grouped together. Use Refresh Party GMs to discover each other automatically, then Request Link and Approve from the party list below. Federation sync is only for distributing approved links to members of your own guild.")
        styleBodyText(setupHelp, true)

        createButton(host, "Refresh Party GMs", 148, 24, "TOPLEFT", host, "TOPLEFT", 18, -134, function()
            local ok, message = addon.FederationService:RequestPartyPeerDiscovery(true)

            if not ok and message then
                addon:Print(message)
            end

            addon.MainFrame:RefreshCurrentView()
        end)

        local partyPanel = createPanel(host)
        partyPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -166)
        partyPanel:SetSize(916, 88)

        local partyTitle = partyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        partyTitle:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -12)
        partyTitle:SetText("Party Guild Masters")
        styleTitleText(partyTitle, 15)

        if #partyPeers == 0 then
            local emptyPeers = partyPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            emptyPeers:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -36)
            emptyPeers:SetWidth(888)
            emptyPeers:SetJustifyH("LEFT")
            emptyPeers:SetText("No grouped guild masters discovered yet. Group with the other guild master, then click Refresh Party GMs.")
            styleBodyText(emptyPeers, true)
        else
            for index, peer in ipairs(partyPeers) do
                local row = CreateFrame("Frame", nil, partyPanel, "BackdropTemplate")
                row:SetSize(888, 24)
                row:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -32 - ((index - 1) * 26))
                row:SetBackdrop({
                    bgFile = "Interface\\Buttons\\WHITE8X8",
                    edgeFile = "Interface\\Buttons\\WHITE8X8",
                    edgeSize = 1,
                    insets = { left = 1, right = 1, top = 1, bottom = 1 },
                })
                row:SetBackdropColor(0.06, 0.08, 0.12, 0.95)
                row:SetBackdropBorderColor(0.28, 0.34, 0.42, 1)

                local record = addon.FederationService:GetLinkedGuildRecord(peer.homeGuild)
                local linkStatus = addon.FederationService:GetLinkStatus(record)

                local peerText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                peerText:SetPoint("LEFT", row, "LEFT", 8, 0)
                peerText:SetWidth(420)
                peerText:SetJustifyH("LEFT")
                peerText:SetText(string.format("%s | %s", peer.homeGuild or "Unknown guild", peer.peer or "Unknown peer"))
                styleBodyText(peerText, true)

                local statusText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                statusText:SetPoint("LEFT", row, "LEFT", 440, 0)
                statusText:SetWidth(220)
                statusText:SetJustifyH("LEFT")
                statusText:SetText(linkStatus.label)
                styleBodyText(statusText, true)

                if (not record or not (record.requestedAt or record.addedAt)) then
                    createButton(row, "Request Link", 98, 18, "RIGHT", row, "RIGHT", -110, 0, function()
                        local ok, message = addon.FederationService:RequestLinkWithPeer(peer.peer, peer.homeGuild)

                        if ok then
                            addon.MainFrame:RefreshCurrentView()
                        elseif message then
                            addon:Print(message)
                        end
                    end)
                elseif (record.remoteRequestedAt or record.remoteApprovedAt) and not record.approvedAt then
                    createButton(row, "Approve", 84, 18, "RIGHT", row, "RIGHT", -110, 0, function()
                        local ok, message = addon.FederationService:ApproveLinkWithPeer(peer.peer, peer.homeGuild)

                        if ok then
                            addon.MainFrame:RefreshCurrentView()
                        elseif message then
                            addon:Print(message)
                        end
                    end)
                end
            end
        end
    end

    local listHost = createPanel(host)
    listHost:SetPoint("TOPLEFT", host, "TOPLEFT", 18, canManageLinkedGuilds and -264 or -102)
    listHost:SetSize(916, canManageLinkedGuilds and 370 or 500)

    local headerRow = CreateFrame("Frame", nil, listHost)
    headerRow:SetSize(888, 24)
    headerRow:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -14)

    local linkedHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    linkedHeader:SetPoint("TOPLEFT", headerRow, "TOPLEFT", 0, -2)
    linkedHeader:SetWidth(270)
    linkedHeader:SetJustifyH("LEFT")
    linkedHeader:SetText("Linked Guild")
    styleHighlightText(linkedHeader)

    local statusHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    statusHeader:SetPoint("TOPLEFT", headerRow, "TOPLEFT", 282, -2)
    statusHeader:SetWidth(210)
    statusHeader:SetJustifyH("LEFT")
    statusHeader:SetText("Status")
    styleHighlightText(statusHeader)

    local requestedHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    requestedHeader:SetPoint("TOPLEFT", headerRow, "TOPLEFT", 504, -2)
    requestedHeader:SetWidth(154)
    requestedHeader:SetJustifyH("LEFT")
    requestedHeader:SetText("Requested")
    styleHighlightText(requestedHeader)

    local approvalHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    approvalHeader:SetPoint("TOPLEFT", headerRow, "TOPLEFT", 670, -2)
    approvalHeader:SetWidth(96)
    approvalHeader:SetJustifyH("LEFT")
    approvalHeader:SetText("Approval")
    styleHighlightText(approvalHeader)

    if canManageLinkedGuilds then
        local actionsHeader = headerRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        actionsHeader:SetPoint("TOPLEFT", headerRow, "TOPLEFT", 778, -2)
        actionsHeader:SetWidth(98)
        actionsHeader:SetJustifyH("LEFT")
        actionsHeader:SetText("Actions")
        styleHighlightText(actionsHeader)
    end

    if #linkedGuilds == 0 then
        local empty = listHost:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -46)
        empty:SetText("No linked guild relationships stored.")
        styleBodyText(empty)
        return
    end

    for index, record in ipairs(linkedGuilds) do
        local row = CreateFrame("Frame", nil, listHost, "BackdropTemplate")
        row:SetSize(888, 30)
        row:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -42 - ((index - 1) * 36))
        row:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })

        if index % 2 == 0 then
            row:SetBackdropColor(0.08, 0.1, 0.14, 0.96)
        else
            row:SetBackdropColor(0.05, 0.07, 0.1, 0.96)
        end

        row:SetBackdropBorderColor(0.3, 0.38, 0.48, 1)

        local linkStatus = addon.FederationService:GetLinkStatus(record)
        local localRequestedBy = record.requestedBy or record.addedBy or "-"
        local remoteRequestedBy = record.remoteRequestedBy or "-"
        local requestedSummary = string.format("L:%s R:%s", shortPlayerName(localRequestedBy), shortPlayerName(remoteRequestedBy))
        local approvalSummary = string.format("L:%s R:%s", record.approvedAt and "Yes" or "-", record.remoteApprovedAt and "Yes" or "-")

        local guildText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        guildText:SetPoint("LEFT", row, "LEFT", 8, 0)
        guildText:SetWidth(260)
        guildText:SetJustifyH("LEFT")
        guildText:SetText(record.guildKey or record.name or "-")
        styleBodyText(guildText, true)

        local statusText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        statusText:SetPoint("LEFT", row, "LEFT", 290, 0)
        statusText:SetWidth(202)
        statusText:SetJustifyH("LEFT")
        statusText:SetText(linkStatus.label)
        styleBodyText(statusText, true)

        if linkStatus.key == "approved" then
            statusText:SetTextColor(0.74, 0.9, 0.62, 1)
        elseif linkStatus.key == "needs-your-approval" or linkStatus.key == "needs-both-approval" then
            statusText:SetTextColor(0.96, 0.8, 0.38, 1)
        elseif linkStatus.key == "remote-requested" then
            statusText:SetTextColor(0.72, 0.84, 0.98, 1)
        else
            statusText:SetTextColor(0.8, 0.79, 0.74, 1)
        end

        local requestedText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        requestedText:SetPoint("LEFT", row, "LEFT", 512, 0)
        requestedText:SetWidth(150)
        requestedText:SetJustifyH("LEFT")
        requestedText:SetText(requestedSummary)
        styleBodyText(requestedText, true)

        local approvalText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        approvalText:SetPoint("LEFT", row, "LEFT", 678, 0)
        approvalText:SetWidth(92)
        approvalText:SetJustifyH("LEFT")
        approvalText:SetText(approvalSummary)
        styleBodyText(approvalText, true)

        if canManageLinkedGuilds then
            createButton(row, "Remove", 72, 20, "RIGHT", row, "RIGHT", -8, 0, function()
                if addon.FederationService:RemoveLinkedGuild(record.guildKey or record.name) then
                    addon.MainFrame:RefreshCurrentView()
                end
            end)
        end
    end
end

function addon.MainFrame:RenderMaintenance()
    local host = self:GetViewHost()
    self:EnsureState()

    if self.HideRecruiterInviteBatchButton then
        self:HideRecruiterInviteBatchButton()
    end

    createSectionTitle(host, "Maintenance", 18, -8)

    local activeTab = (
        self.state.maintenanceTab == "invites"
        or self.state.maintenanceTab == "audit"
    ) and self.state.maintenanceTab or "cleanup"
    local tabRow = CreateFrame("Frame", nil, host)
    tabRow:SetSize(414, 24)
    tabRow:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -30)

    createButton(tabRow, activeTab == "cleanup" and "Clean-up *" or "Clean-up", 126, 24, "TOPLEFT", tabRow, "TOPLEFT", 0, 0, function()
        addon.MainFrame.state.maintenanceTab = "cleanup"
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(tabRow, activeTab == "invites" and "Invites *" or "Invites", 126, 24, "TOPLEFT", tabRow, "TOPLEFT", 134, 0, function()
        addon.MainFrame.state.maintenanceTab = "invites"
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(tabRow, activeTab == "audit" and "Audit Logs *" or "Audit Logs", 126, 24, "TOPLEFT", tabRow, "TOPLEFT", 268, 0, function()
        addon.MainFrame.state.maintenanceTab = "audit"
        addon.MainFrame:RefreshCurrentView()
    end)

    if activeTab == "invites" then
        local service = addon.MaintenanceService
        local summary = service:GetInviteSummary()
        local settings = service:GetInviteSettings()
        local scanState = service:GetInviteScanState()
        local entries = service:ListInviteScanEntries()
        local pageSize = 8
        local totalPages = math.max(1, math.ceil(#entries / pageSize))
        local currentPage = math.min(self.state.maintenanceInvitePage or 1, totalPages)
        local pageStart = ((currentPage - 1) * pageSize) + 1
        local pageEnd = math.min(pageStart + pageSize - 1, #entries)
        local visibleEntries = {}

        self.state.maintenanceInvitePage = currentPage

        for index = pageStart, pageEnd do
            visibleEntries[#visibleEntries + 1] = entries[index]
        end

        local function ensureInviteSettingsDialog()
            local function buildMessagePreviewLines(label, templateText)
                local preview = service:FormatInviteMessage(templateText, "Recruit")

                return {
                    "Available auto replace tokens:",
                    "[name] -> Recruit",
                    string.format("[guild] -> %s", tostring(GetGuildInfo("player") or addon.Store:GetDisplayGuildKey() or "the guild")),
                    string.format("[guildlink] -> %s", tostring(GetGuildInfo("player") or addon.Store:GetDisplayGuildKey() or "the guild")),
                    string.format("[officer] -> %s", tostring(addon.Store:SplitFullName(addon.Store:GetPlayerName() or "Officer", GetRealmName() or "Unknown"))),
                    string.format("[realm] -> %s", tostring(addon.Store:NormalizeRealmName(GetRealmName() or "Unknown") or "Unknown")),
                    " ",
                    string.format("Preview %s:", label),
                    preview ~= "" and preview or "No message saved yet.",
                }
            end

            if self.maintenanceInviteSettingsDialog then
                local dialog = self.maintenanceInviteSettingsDialog
                dialog.batchSizeValue = tonumber(settings.batchSize) or 5
                dialog.currentZoneOnly = settings.currentZoneOnly and true or false
                dialog.whisperOnly = settings.whisperOnly and true or false
                dialog.usePrimaryWhisper = settings.usePrimaryWhisper and true or false
                dialog.announceJoins = settings.announceJoins and true or false
                dialog.blacklistLeavers = settings.blacklistLeavers and true or false
                dialog.syncBlacklist = settings.syncBlacklist and true or false
                dialog.syncInviteHistory = settings.syncInviteHistory and true or false
                dialog.minLevelInput:SetText(tostring(settings.lowLevel or 10))
                dialog.maxLevelInput:SetText(tostring(settings.highLevel or 80))
                dialog.cooldownInput:SetText(tostring(settings.repeatInviteCooldownDays or 30))
                dialog.batchSizeButton:SetText(string.format("Batch: %d", settings.batchSize or 5))
                dialog.zoneScopeButton:SetText(settings.currentZoneOnly and "Zone: Current" or "Zone: All")
                dialog.whisperOnlyButton:SetText(settings.whisperOnly and "Whisper Only: On" or "Whisper Only: Off")
                dialog.whisperButton:SetText(settings.usePrimaryWhisper and "Invite Whisper: On" or "Invite Whisper: Off")
                dialog.announceButton:SetText(settings.announceJoins and "Announce: On" or "Announce: Off")
                dialog.leaverButton:SetText(settings.blacklistLeavers and "Blacklist Leavers: On" or "Blacklist Leavers: Off")
                dialog.syncBlacklistButton:SetText(settings.syncBlacklist and "Sync Blacklist: On" or "Sync Blacklist: Off")
                dialog.syncHistoryButton:SetText(settings.syncInviteHistory and "Sync Invites: On" or "Sync Invites: Off")
                dialog.querySuffixInput:SetText(tostring(settings.querySuffix or ""))
                dialog.primaryMessageInput:SetText(tostring(settings.primaryMessage or ""))
                dialog.welcomeMessageInput:SetText(tostring(settings.welcomeMessage or ""))
                return dialog
            end

            local dialog = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
            dialog:SetFrameStrata("FULLSCREEN_DIALOG")
            dialog:SetFrameLevel((self.frame and self.frame:GetFrameLevel() or 200) + 40)
            dialog:SetSize(472, 566)
            dialog:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
            applySurfaceStyle(dialog, THEME.window)
            dialog:Hide()
            dialog.batchSizeValue = tonumber(settings.batchSize) or 5
            dialog.currentZoneOnly = settings.currentZoneOnly and true or false
            dialog.whisperOnly = settings.whisperOnly and true or false
            dialog.usePrimaryWhisper = settings.usePrimaryWhisper and true or false
            dialog.announceJoins = settings.announceJoins and true or false
            dialog.blacklistLeavers = settings.blacklistLeavers and true or false
            dialog.syncBlacklist = settings.syncBlacklist and true or false
            dialog.syncInviteHistory = settings.syncInviteHistory and true or false

            local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -14)
            title:SetText("Invite Settings")
            styleTitleText(title, 16)

            local meta = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            meta:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
            meta:SetWidth(420)
            meta:SetJustifyH("LEFT")
            meta:SetText("WHO scans and queue state stay local. Optional blacklist and sent-invite history sync only merge on officer and guild-master GH3 clients.")
            styleBodyText(meta, true)

            createCloseButton(dialog, "TOPRIGHT", dialog, "TOPRIGHT", -10, -10, function()
                dialog:Hide()
            end)

            local minLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            minLabel:SetPoint("TOPLEFT", meta, "BOTTOMLEFT", 0, -14)
            minLabel:SetText("Min Level")
            styleBodyText(minLabel, true)

            dialog.minLevelInput = createInput(dialog, 52, 22, "LEFT", minLabel, "RIGHT", 8, 0)
            dialog.minLevelInput:SetNumeric(true)
            dialog.minLevelInput:SetMaxLetters(3)

            local maxLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            maxLabel:SetPoint("LEFT", dialog.minLevelInput, "RIGHT", 24, 0)
            maxLabel:SetText("Max Level")
            styleBodyText(maxLabel, true)

            dialog.maxLevelInput = createInput(dialog, 52, 22, "LEFT", maxLabel, "RIGHT", 8, 0)
            dialog.maxLevelInput:SetNumeric(true)
            dialog.maxLevelInput:SetMaxLetters(3)

            local cooldownLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            cooldownLabel:SetPoint("LEFT", dialog.maxLevelInput, "RIGHT", 24, 0)
            cooldownLabel:SetText("Retry (days)")
            styleBodyText(cooldownLabel, true)

            dialog.cooldownInput = createInput(dialog, 52, 22, "LEFT", cooldownLabel, "RIGHT", 8, 0)
            dialog.cooldownInput:SetNumeric(true)
            dialog.cooldownInput:SetMaxLetters(3)

            dialog.batchSizeButton = createButton(dialog, "Batch: 5", 92, 22, "TOPLEFT", dialog, "TOPLEFT", 14, -96, function()
                local options = { 1, 5, 10, 15, 20, 25 }
                local currentValue = tonumber(dialog.batchSizeValue) or 5
                local currentIndex = 1

                for index, value in ipairs(options) do
                    if value == currentValue then
                        currentIndex = index
                        break
                    end
                end

                local nextValue = options[(currentIndex % #options) + 1]
                dialog.batchSizeValue = nextValue
                dialog.batchSizeButton:SetText(string.format("Batch: %d", nextValue))
            end)

            dialog.zoneScopeButton = createButton(dialog, "Zone: All", 108, 22, "LEFT", dialog.batchSizeButton, "RIGHT", 10, 0, function()
                dialog.currentZoneOnly = not dialog.currentZoneOnly
                dialog.zoneScopeButton:SetText(dialog.currentZoneOnly and "Zone: Current" or "Zone: All")
            end)

            dialog.whisperButton = createButton(dialog, "Invite Whisper: Off", 128, 22, "LEFT", dialog.zoneScopeButton, "RIGHT", 10, 0, function()
                dialog.usePrimaryWhisper = not dialog.usePrimaryWhisper
                dialog.whisperButton:SetText(dialog.usePrimaryWhisper and "Invite Whisper: On" or "Invite Whisper: Off")
            end)

            dialog.announceButton = createButton(dialog, "Announce: Off", 108, 22, "TOPLEFT", dialog, "TOPLEFT", 14, -126, function()
                dialog.announceJoins = not dialog.announceJoins
                dialog.announceButton:SetText(dialog.announceJoins and "Announce: On" or "Announce: Off")
            end)

            dialog.leaverButton = createButton(dialog, "Blacklist Leavers: Off", 148, 22, "LEFT", dialog.announceButton, "RIGHT", 10, 0, function()
                dialog.blacklistLeavers = not dialog.blacklistLeavers
                dialog.leaverButton:SetText(dialog.blacklistLeavers and "Blacklist Leavers: On" or "Blacklist Leavers: Off")
            end)

            dialog.whisperOnlyButton = createButton(dialog, "Whisper Only: Off", 124, 22, "LEFT", dialog.leaverButton, "RIGHT", 10, 0, function()
                dialog.whisperOnly = not dialog.whisperOnly
                dialog.whisperOnlyButton:SetText(dialog.whisperOnly and "Whisper Only: On" or "Whisper Only: Off")
            end)

            dialog.syncBlacklistButton = createButton(dialog, "Sync Blacklist: Off", 128, 22, "TOPLEFT", dialog, "TOPLEFT", 14, -156, function()
                dialog.syncBlacklist = not dialog.syncBlacklist
                dialog.syncBlacklistButton:SetText(dialog.syncBlacklist and "Sync Blacklist: On" or "Sync Blacklist: Off")
            end)

            dialog.syncHistoryButton = createButton(dialog, "Sync Invites: Off", 128, 22, "LEFT", dialog.syncBlacklistButton, "RIGHT", 10, 0, function()
                dialog.syncInviteHistory = not dialog.syncInviteHistory
                dialog.syncHistoryButton:SetText(dialog.syncInviteHistory and "Sync Invites: On" or "Sync Invites: Off")
            end)

            local suffixLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            suffixLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -190)
            suffixLabel:SetText("Extra WHO Filter")
            styleBodyText(suffixLabel, true)

            dialog.querySuffixInput = createInput(dialog, 420, 22, "TOPLEFT", dialog, "TOPLEFT", 14, -208)

            local whisperLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            whisperLabel:SetPoint("TOPLEFT", dialog.querySuffixInput, "BOTTOMLEFT", 0, -16)
            whisperLabel:SetText("Invite Whisper")
            styleBodyText(whisperLabel, true)

            dialog.primaryMessageInput, dialog.primaryMessageContainer = createMultilineInput(dialog, 420, 72, "TOPLEFT", whisperLabel, "BOTTOMLEFT", 0, -8)

            local inviteTokensNote = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            inviteTokensNote:SetPoint("TOPLEFT", dialog.primaryMessageContainer, "BOTTOMLEFT", 4, -4)
            inviteTokensNote:SetWidth(412)
            inviteTokensNote:SetJustifyH("LEFT")
            inviteTokensNote:SetText("Auto replace: [name] [guild] [guildlink] [officer] [realm]. Hover for preview.")
            styleBodyText(inviteTokensNote, true)

            local welcomeLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            welcomeLabel:SetPoint("TOPLEFT", inviteTokensNote, "BOTTOMLEFT", -4, -12)
            welcomeLabel:SetText("Welcome Whisper")
            styleBodyText(welcomeLabel, true)

            dialog.welcomeMessageInput, dialog.welcomeMessageContainer = createMultilineInput(dialog, 420, 72, "TOPLEFT", welcomeLabel, "BOTTOMLEFT", 0, -8)

            local welcomeTokensNote = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            welcomeTokensNote:SetPoint("TOPLEFT", dialog.welcomeMessageContainer, "BOTTOMLEFT", 4, -4)
            welcomeTokensNote:SetWidth(412)
            welcomeTokensNote:SetJustifyH("LEFT")
            welcomeTokensNote:SetText("Auto replace: [name] [guild] [guildlink] [officer] [realm]. Hover for preview.")
            styleBodyText(welcomeTokensNote, true)

            attachHoverNote(inviteTokensNote, "Invite Whisper Preview", function()
                return buildMessagePreviewLines("for invite whisper", dialog.primaryMessageInput:GetText())
            end)
            attachHoverNote(dialog.primaryMessageContainer, "Invite Whisper Preview", function()
                return buildMessagePreviewLines("for invite whisper", dialog.primaryMessageInput:GetText())
            end)
            attachHoverNote(welcomeTokensNote, "Welcome Whisper Preview", function()
                return buildMessagePreviewLines("for welcome whisper", dialog.welcomeMessageInput:GetText())
            end)
            attachHoverNote(dialog.welcomeMessageContainer, "Welcome Whisper Preview", function()
                return buildMessagePreviewLines("for welcome whisper", dialog.welcomeMessageInput:GetText())
            end)

            local saveButton = createButton(dialog, "Save", 88, 24, "BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -14, 14, function()
                service:UpdateInviteSettings({
                    announceJoins = dialog.announceJoins,
                    batchSize = dialog.batchSizeValue,
                    blacklistLeavers = dialog.blacklistLeavers,
                    currentZoneOnly = dialog.currentZoneOnly,
                    highLevel = dialog.maxLevelInput:GetText(),
                    lowLevel = dialog.minLevelInput:GetText(),
                    primaryMessage = dialog.primaryMessageInput:GetText(),
                    querySuffix = dialog.querySuffixInput:GetText(),
                    repeatInviteCooldownDays = dialog.cooldownInput:GetText(),
                    syncBlacklist = dialog.syncBlacklist,
                    syncInviteHistory = dialog.syncInviteHistory,
                    whisperOnly = dialog.whisperOnly,
                    usePrimaryWhisper = dialog.usePrimaryWhisper,
                    welcomeMessage = dialog.welcomeMessageInput:GetText(),
                })
                dialog:Hide()
                addon.MainFrame:RefreshCurrentView(true)
            end)

            createButton(dialog, "Close", 88, 24, "RIGHT", saveButton, "LEFT", -10, 0, function()
                dialog:Hide()
            end)

            self.maintenanceInviteSettingsDialog = dialog
            return ensureInviteSettingsDialog()
        end

        local invitesPanel = createPanel(host)
        invitesPanel:SetPoint("TOPLEFT", tabRow, "BOTTOMLEFT", 0, -12)
        invitesPanel:SetSize(916, 560)

        local title = invitesPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", invitesPanel, "TOPLEFT", 14, -14)
        title:SetText("Invites")
        styleTitleText(title, 18)

        local helpButton = createButton(invitesPanel, "?", 22, 20, "LEFT", title, "RIGHT", 8, -1, function()
            addon.MainFrame:OpenHelpArticle("recruiter")
        end)
        attachHoverNote(helpButton, "Recruiter Help", {
            "Open the Recruiter Invites help article.",
            "Covers scan flow, cooldowns, queue review, and batch invites.",
        })

        local suppressionNote = invitesPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        suppressionNote:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
        suppressionNote:SetWidth(860)
        suppressionNote:SetJustifyH("LEFT")
        suppressionNote:SetText("Note: players can suppress random guild invites in the Blizzard game options. If someone says they never saw the invite, have them check their Social settings before you keep retrying.")
        styleBodyText(suppressionNote, true)

        local toolbarRow = CreateFrame("Frame", nil, invitesPanel)
        toolbarRow:SetSize(880, 24)
        toolbarRow:SetPoint("TOPLEFT", suppressionNote, "BOTTOMLEFT", 0, -12)
        local scanCooldownSeconds = summary.scanCooldownRemainingSeconds or 0
        local canSendInviteAction = summary.canSendContact and true or false
        local inviteActionLabel = settings.whisperOnly and "Whisper" or "Invite"
        local invitePastTenseLabel = settings.whisperOnly and "Whispered" or "Invited"
        local inviteBatchPayload = canSendInviteAction and service:BuildInviteBatchMacro() or {}
        local inviteBatchQueuedCount = tonumber(inviteBatchPayload and inviteBatchPayload.includedCount) or 0
        local inviteBatchPreparedMacro = nil
        local inviteBatchMacroError = nil

        if not settings.whisperOnly and summary.canInvite and inviteBatchQueuedCount > 0 and not (InCombatLockdown and InCombatLockdown()) then
            inviteBatchPreparedMacro, inviteBatchMacroError = service:PrepareExecutionMacro(inviteBatchPayload.body, RECRUITER_INVITE_MACRO_NAME)
        end

        local function queueRecruiterRefresh()
            if addon.MainFrame and addon.MainFrame.MarkBackgroundRefreshPending then
                addon.MainFrame:MarkBackgroundRefreshPending()
            end

            if C_Timer and C_Timer.After then
                C_Timer.After(0, function()
                    if addon.MainFrame
                        and addon.MainFrame.frame
                        and addon.MainFrame.frame:IsShown()
                        and addon.Router
                        and addon.Router.GetCurrentView
                        and addon.Router:GetCurrentView() == "maintenance"
                        and not (InCombatLockdown and InCombatLockdown()) then
                        addon.MainFrame:RefreshCurrentView(true)
                    end
                end)
            end
        end

        local startScanButton = createButton(
            toolbarRow,
            (scanCooldownSeconds > 0 and not scanState.sessionActive) and string.format("Wait %d sec", scanCooldownSeconds) or "Start Scan",
            92,
            24,
            "TOPLEFT",
            toolbarRow,
            "TOPLEFT",
            0,
            0,
            function()
            local ok, message = service:StartInviteScan()

            if not ok then
                addon:Print(message or "Unable to start the invite scan.")
                return
            end

            addon.MainFrame.state.maintenanceInvitePage = 1
            addon:Print("Recruiter scan session started.")
            queueRecruiterRefresh()
        end)
        startScanButton:SetEnabled(summary.scanStatus ~= "scanning" and scanCooldownSeconds <= 0)

        local scanNextButton = createButton(
            toolbarRow,
            (scanCooldownSeconds > 0 and scanState.sessionActive and not scanState.sessionCompleted and summary.scanStatus ~= "scanning") and string.format("Wait %d sec", scanCooldownSeconds) or "Scan Next",
            92,
            24,
            "LEFT",
            toolbarRow,
            "LEFT",
            100,
            0,
            function()
            local ok, message = service:ScanNextInviteQuery()

            if not ok then
                addon:Print(message or "No additional recruiter scans are available.")
                return
            end

            addon:Print("Running the next recruiter scan.")
            queueRecruiterRefresh()
        end)
        scanNextButton:SetEnabled((scanState.sessionActive and not scanState.sessionCompleted and summary.scanStatus ~= "scanning" and scanCooldownSeconds <= 0) and true or false)

        local inviteNextButton = createButton(toolbarRow, string.format("%s Next", inviteActionLabel), 104, 24, "LEFT", toolbarRow, "LEFT", 200, 0, function()
            local result = service:InviteNextCandidate()

            if (result.invitedCount or 0) <= 0 then
                addon:Print(result.errors[1] or "No eligible selected candidates are ready for invite.")
            else
                addon:Print(string.format("%s %d candidate%s.", invitePastTenseLabel, result.invitedCount or 0, (result.invitedCount or 0) == 1 and "" or "s"))
            end

            queueRecruiterRefresh()
        end)
        inviteNextButton:SetEnabled(canSendInviteAction)

        local inviteBatchButton = createButton(toolbarRow, string.format("%s Batch", inviteActionLabel), 112, 24, "LEFT", toolbarRow, "LEFT", 312, 0, function()
            if inviteBatchQueuedCount <= 0 then
                addon:Print("No eligible selected candidates are ready for invite.")
                return
            end

            if settings.whisperOnly then
                local result = service:InviteSelectedCandidates()

                if (result.invitedCount or 0) <= 0 then
                    addon:Print(result.errors[1] or "No eligible selected candidates are ready for invite.")
                else
                    addon:Print(string.format("%s %d candidate%s.", invitePastTenseLabel, result.invitedCount or 0, (result.invitedCount or 0) == 1 and "" or "s"))
                end

                queueRecruiterRefresh()
                return
            end

            addon:Print(inviteBatchMacroError or "Recruiter batch invites are not ready yet.")
        end)
        inviteBatchButton:SetEnabled(
            (settings.whisperOnly and canSendInviteAction and inviteBatchQueuedCount > 0)
            or (not settings.whisperOnly and not inviteBatchPreparedMacro and summary.canInvite and inviteBatchQueuedCount > 0 and not (InCombatLockdown and InCombatLockdown()))
        )

        if self.PositionRecruiterInviteBatchButton then
            self:PositionRecruiterInviteBatchButton(inviteBatchButton, inviteBatchPayload, inviteBatchPreparedMacro)
        end

        createButton(toolbarRow, "Select All", 92, 24, "LEFT", toolbarRow, "LEFT", 428, 0, function()
            service:SetAllInviteCandidateSelected(true)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        createButton(toolbarRow, "Select None", 92, 24, "LEFT", toolbarRow, "LEFT", 524, 0, function()
            service:SetAllInviteCandidateSelected(false)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        createButton(toolbarRow, "Settings", 88, 24, "LEFT", toolbarRow, "LEFT", 620, 0, function()
            ensureInviteSettingsDialog():Show()
        end)

        createButton(toolbarRow, "Reset Scan", 92, 24, "LEFT", toolbarRow, "LEFT", 712, 0, function()
            service:ClearInviteScan()
            addon.MainFrame.state.maintenanceInvitePage = 1
            queueRecruiterRefresh()
        end)

        local statusText = invitesPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        statusText:SetPoint("TOPLEFT", toolbarRow, "BOTTOMLEFT", 0, -10)
        statusText:SetWidth(860)
        statusText:SetJustifyH("LEFT")
        statusText:SetText(table.concat({
            string.format(
                "Status: %s | Scan: %d/%d | Last: %s | Next Scan: %d sec | Retry: %d day(s) | Batch: %d",
                summary.scanStatus or "idle",
                summary.sessionProgress or 0,
                summary.sessionTotalQueries or 0,
                tostring(summary.sessionQueryLabel or "No recruiter session yet."),
                scanCooldownSeconds,
                settings.repeatInviteCooldownDays or 30,
                settings.batchSize or 5
            ),
            string.format(
                "Queue: %d | Selected: %d | Sending: %d | Mode: %s | Sync B/H: %s/%s",
                summary.scanCount or 0,
                summary.selectedCount or 0,
                summary.activeSendCount or 0,
                settings.whisperOnly and "Whisper Only" or "Guild Invite",
                settings.syncBlacklist and "On" or "Off",
                settings.syncInviteHistory and "On" or "Off"
            ),
            string.format(
                "Session Stats: Found %d | %s: %d | Joined %d | Declined %d | CD Filtered %d",
                summary.foundCount or 0,
                invitePastTenseLabel,
                summary.invitedCount or 0,
                summary.acceptedCount or 0,
                summary.declinedCount or 0,
                summary.cooldownFilteredCount or 0
            )
        }, "\n"))
        styleBodyText(statusText, true)

        local queuePanel = createPanel(invitesPanel)
        queuePanel:SetPoint("TOPLEFT", statusText, "BOTTOMLEFT", 0, -8)
        queuePanel:SetSize(888, 232)

        local queueTitle = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        queueTitle:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 14, -12)
        queueTitle:SetText("Scan Queue")
        styleTitleText(queueTitle, 16)

        local pageLabel = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        pageLabel:SetPoint("TOPRIGHT", queuePanel, "TOPRIGHT", -110, -16)
        pageLabel:SetWidth(90)
        pageLabel:SetJustifyH("CENTER")
        pageLabel:SetText(string.format("Page %d/%d", currentPage, totalPages))
        styleBodyText(pageLabel, true)

        createButton(queuePanel, "< Prev", 72, 22, "TOPRIGHT", queuePanel, "TOPRIGHT", -196, -12, function()
            addon.MainFrame.state.maintenanceInvitePage = math.max(1, currentPage - 1)
            addon.MainFrame:RefreshCurrentView(true)
        end):SetEnabled(currentPage > 1)

        createButton(queuePanel, "Next >", 72, 22, "TOPRIGHT", queuePanel, "TOPRIGHT", -14, -12, function()
            addon.MainFrame.state.maintenanceInvitePage = math.min(totalPages, currentPage + 1)
            addon.MainFrame:RefreshCurrentView(true)
        end):SetEnabled(currentPage < totalPages)

        local queueScroll = CreateFrame("ScrollFrame", nil, queuePanel, "UIPanelScrollFrameTemplate")
        queueScroll:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 14, -42)
        queueScroll:SetPoint("BOTTOMRIGHT", queuePanel, "BOTTOMRIGHT", -30, 14)

        local queueContent = CreateFrame("Frame", nil, queueScroll)
        queueContent:SetWidth(844)
        queueScroll:SetScrollChild(queueContent)

        if #entries <= 0 then
            local emptyText = queueContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            emptyText:SetPoint("TOPLEFT", queueContent, "TOPLEFT", 0, 0)
            emptyText:SetWidth(844)
            emptyText:SetJustifyH("LEFT")
            emptyText:SetText(scanState.status == "scanning" and "A recruiter WHO scan is in progress. Results will be appended here when WHO_LIST_UPDATE arrives." or "No recruiter queue yet. Click Start Scan to begin, then Scan Next to append more candidates.")
            styleBodyText(emptyText, true)
            queueContent:SetHeight(40)
        else
            local totalHeight = 0

            for visibleIndex, entry in ipairs(visibleEntries) do
                local row = CreateFrame("Frame", nil, queueContent, "BackdropTemplate")
                row:SetSize(844, 34)
                row:SetPoint("TOPLEFT", queueContent, "TOPLEFT", 0, -((visibleIndex - 1) * 40))
                applySurfaceStyle(row, entry.isSelected and THEME.card or THEME.panel)

                local shortName = addon.Store:SplitFullName(entry.candidateName, GetRealmName() or "Unknown")
                local rowText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                rowText:SetPoint("LEFT", row, "LEFT", 8, 0)
                rowText:SetWidth(490)
                rowText:SetJustifyH("LEFT")
                rowText:SetText(string.format(
                    "%s | L%d %s %s | %s%s%s%s",
                    shortName,
                    tonumber(entry.level) or 0,
                    tostring(entry.className or "?"),
                    tostring(entry.raceName or "?"),
                    tostring(entry.zoneName or "Unknown Zone"),
                    entry.isPending and " | Sending" or "",
                    entry.isBlacklisted and " | Blocked" or "",
                    entry.isSuppressed and string.format(" | Retry in %dd", tonumber(entry.remainingDays) or 0) or ""
                ))
                styleBodyText(rowText, true)

                local selectButton = createButton(row, entry.isSelected and "Selected" or "Select", 72, 20, "RIGHT", row, "RIGHT", -190, 0, function()
                    service:ToggleInviteCandidateSelected(entry.candidateName)
                    queueRecruiterRefresh()
                end)
                selectButton:SetEnabled(not entry.isBlacklisted and not entry.isPending)

                local inviteButton = createButton(row, inviteActionLabel, 60, 20, "RIGHT", row, "RIGHT", -126, 0, function()
                    local whisperMessage = (settings.whisperOnly or settings.usePrimaryWhisper) and service:FormatInviteMessage(settings.primaryMessage, entry.candidateName) or ""
                    local ok, message = service:SendGuildInvite(entry.candidateName, whisperMessage, "", { whisperOnly = settings.whisperOnly })

                    if ok then
                        service:SkipInviteCandidate(entry.candidateName)
                        addon:Print(message or string.format("%s sent.", inviteActionLabel))
                    else
                        addon:Print(message or string.format("Unable to send the %s.", string.lower(inviteActionLabel)))
                    end

                    queueRecruiterRefresh()
                end)
                inviteButton:SetEnabled(not entry.isBlacklisted and not entry.isSuppressed and not entry.isPending and canSendInviteAction)

                createButton(row, entry.isBlacklisted and "Blocked" or "Block", 54, 20, "RIGHT", row, "RIGHT", -70, 0, function()
                    service:BlockInviteName(entry.candidateName, "scan")
                    service:SkipInviteCandidate(entry.candidateName)
                    queueRecruiterRefresh()
                end):SetEnabled(not entry.isBlacklisted and not entry.isPending)

                createButton(row, "Skip", 46, 20, "RIGHT", row, "RIGHT", -10, 0, function()
                    service:SkipInviteCandidate(entry.candidateName)
                    queueRecruiterRefresh()
                end):SetEnabled(not entry.isPending)

                totalHeight = totalHeight + 40
            end

            queueContent:SetHeight(math.max(totalHeight, 34))
        end

        local whisperPanel = createPanel(invitesPanel)
        whisperPanel:SetPoint("TOPLEFT", queuePanel, "BOTTOMLEFT", 0, -12)
        whisperPanel:SetSize(436, 116)

        local whisperTitle = whisperPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        whisperTitle:SetPoint("TOPLEFT", whisperPanel, "TOPLEFT", 14, -12)
        whisperTitle:SetText("Invite Whisper")
        styleTitleText(whisperTitle, 16)

        local whisperBody = whisperPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        whisperBody:SetPoint("TOPLEFT", whisperPanel, "TOPLEFT", 14, -34)
        whisperBody:SetWidth(408)
        whisperBody:SetJustifyH("LEFT")
        whisperBody:SetText(string.format(
            "%s\n%s",
            settings.whisperOnly and "Whisper-only mode is enabled. Invite actions send this whisper without a guild invite."
                or (settings.usePrimaryWhisper and "Enabled for Invite Next and Invite Batch." or "Disabled for Invite Next and Invite Batch."),
            settings.primaryMessage ~= "" and service:FormatInviteMessage(settings.primaryMessage, "Recruit-" .. (GetRealmName() or "Unknown")) or "No custom Invite Whisper saved yet."
        ))
        styleBodyText(whisperBody, true)

        local actionsPanel = createPanel(invitesPanel)
        actionsPanel:SetPoint("TOPLEFT", whisperPanel, "TOPRIGHT", 16, 0)
        actionsPanel:SetSize(436, 116)

        local actionsTitle = actionsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        actionsTitle:SetPoint("TOPLEFT", actionsPanel, "TOPLEFT", 14, -12)
        actionsTitle:SetText("Session Stats")
        styleTitleText(actionsTitle, 16)

        local actionsBody = actionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        actionsBody:SetPoint("TOPLEFT", actionsPanel, "TOPLEFT", 14, -34)
        actionsBody:SetWidth(408)
        actionsBody:SetJustifyH("LEFT")
        actionsBody:SetText(table.concat({
            string.format("Scans: %d/%d | Found: %d | Queue: %d", summary.sessionProgress or 0, summary.sessionTotalQueries or 0, summary.foundCount or 0, summary.scanCount or 0),
            string.format("%s: %d | Joined: %d | Declined: %d", invitePastTenseLabel, summary.invitedCount or 0, summary.acceptedCount or 0, summary.declinedCount or 0),
            string.format("Cooldown Filtered: %d | Blocked: %d | Sending: %d", summary.cooldownFilteredCount or 0, summary.blockedDuringSessionCount or 0, summary.activeSendCount or 0),
            string.format("Timeouts: %d | Last Status: %s", summary.timeoutCount or 0, tostring(summary.scanStatus or "idle")),
            string.format("Mode: %s | Welcome: %s | Announce: %s | Leavers: %s", settings.whisperOnly and "Whisper Only" or "Guild Invite", settings.welcomeMessage ~= "" and "Saved" or "Off", settings.announceJoins and "On" or "Off", settings.blacklistLeavers and "On" or "Off")
        }, "\n"))
        styleBodyText(actionsBody, true)

        return
    end

    if activeTab == "audit" then
        local service = addon.MaintenanceService
        local settings = service:GetSettings()
        local auditSummary = service:GetAuditSummary()
        local entries = service:ListAuditEntries()
        local pageSize = 10
        local totalPages = math.max(1, math.ceil(#entries / pageSize))
        local currentPage = math.min(self.state.maintenanceAuditPage or 1, totalPages)
        local startIndex = ((currentPage - 1) * pageSize) + 1
        local endIndex = math.min(#entries, startIndex + pageSize - 1)

        self.state.maintenanceAuditPage = currentPage

        local contentWidth = math.max(918, math.floor((host:GetWidth() or 954) - 36))

        local summaryText = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        summaryText:SetPoint("TOPLEFT", tabRow, "BOTTOMLEFT", 0, -4)
        summaryText:SetWidth(contentWidth)
        summaryText:SetJustifyH("LEFT")
        summaryText:SetText(string.format(
            "%d audit row(s) kept. Joins %d | Leaves %d | Kicks %d | Rank %d | Notes %d | Officer Notes %d.%s",
            auditSummary.totalCount or 0,
            auditSummary.byType and auditSummary.byType.join or 0,
            auditSummary.byType and auditSummary.byType.leave or 0,
            auditSummary.byType and auditSummary.byType.kick or 0,
            auditSummary.byType and auditSummary.byType.rank or 0,
            auditSummary.byType and auditSummary.byType.note or 0,
            auditSummary.byType and auditSummary.byType.officerNote or 0,
            settings.auditEnabled and "" or " Logging is currently off."
        ))
        styleBodyText(summaryText, true)

        local privacyNote = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        privacyNote:SetPoint("TOPLEFT", summaryText, "BOTTOMLEFT", 0, -4)
        privacyNote:SetWidth(contentWidth)
        privacyNote:SetJustifyH("LEFT")
        privacyNote:SetText("Officer note history records that a note changed, was set, or was cleared, but it does not store officer note text in the audit log.")
        styleBodyText(privacyNote, true)

        local settingsPanel = CreateFrame("Frame", nil, host, "BackdropTemplate")
        settingsPanel:SetSize(contentWidth, 116)
        settingsPanel:SetPoint("TOPLEFT", privacyNote, "BOTTOMLEFT", 0, -8)
        applySurfaceStyle(settingsPanel, THEME.panel)

        local function refreshAuditView(resetPage)
            if resetPage then
                addon.MainFrame.state.maintenanceAuditPage = 1
            end

            addon.MainFrame:RefreshCurrentView(true)
        end

        local function buildPendingAuditTextColors(source)
            local pendingColors = {}

            for _, selector in ipairs(AUDIT_TEXT_COLOR_SELECTOR_GROUPS) do
                pendingColors[selector.key] = getAuditTextColor(source, selector.key)
            end

            return pendingColors
        end

        local function ensureAuditColorDialog()
            local function syncDialogState(dialog)
                local currentSettings = service:GetSettings()
                dialog.pendingColors = buildPendingAuditTextColors(currentSettings.auditTextColors)

                for _, selector in ipairs(AUDIT_TEXT_COLOR_SELECTOR_GROUPS) do
                    local button = dialog.colorButtons[selector.key]

                    if button then
                        setAuditColorButtonPreview(button, dialog.pendingColors[selector.key])
                    end
                end
            end

            if self.maintenanceAuditColorDialog then
                local dialog = self.maintenanceAuditColorDialog
                syncDialogState(dialog)
                return dialog
            end

            local dialog = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
            dialog:SetFrameStrata("FULLSCREEN_DIALOG")
            dialog:SetFrameLevel((self.frame and self.frame:GetFrameLevel() or 200) + 40)
            dialog:SetSize(432, 316)
            dialog:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
            applySurfaceStyle(dialog, THEME.window)
            dialog:Hide()
            dialog.colorButtons = {}
            dialog.pendingColors = buildPendingAuditTextColors(settings.auditTextColors)

            local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -14)
            title:SetText("Audit Log Colors")
            styleTitleText(title, 16)

            local meta = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            meta:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
            meta:SetWidth(388)
            meta:SetJustifyH("LEFT")
            meta:SetText("Choose a log type, open the Blizzard color picker, then Save to keep the new audit text colors.")
            styleBodyText(meta, true)

            createCloseButton(dialog, "TOPRIGHT", dialog, "TOPRIGHT", -10, -10, function()
                dialog:Hide()
            end)

            local previewLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            previewLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, -62)
            previewLabel:SetText("Log Type")
            styleHighlightText(previewLabel)

            local actionLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            actionLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 250, -62)
            actionLabel:SetText("Color")
            styleHighlightText(actionLabel)

            for index, selector in ipairs(AUDIT_TEXT_COLOR_SELECTOR_GROUPS) do
                local eventType = selector.key
                local eventLabel = selector.label
                local rowOffset = -84 - ((index - 1) * 28)

                local label = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                label:SetPoint("TOPLEFT", dialog, "TOPLEFT", 14, rowOffset)
                label:SetWidth(188)
                label:SetJustifyH("LEFT")
                label:SetText(eventLabel)
                styleBodyText(label, true)

                local colorButton
                colorButton = createButton(dialog, "Color", 86, 22, "TOPLEFT", dialog, "TOPLEFT", 246, rowOffset + 4, function()
                    local startingColor = getAuditTextColor(dialog.pendingColors, eventType)

                    local function updateDialogColor(nextColor)
                        dialog.pendingColors[eventType] = copyAuditTextColor(nextColor, AUDIT_TEXT_COLOR_DEFAULTS[eventType])
                        setAuditColorButtonPreview(colorButton, dialog.pendingColors[eventType])
                        refreshAuditColorButtonPreviewNextFrame(colorButton, dialog.pendingColors[eventType])
                    end

                    local opened = openAuditColorPicker(startingColor, function(chosenColor)
                        updateDialogColor(chosenColor)
                    end, function()
                        updateDialogColor(startingColor)
                    end, dialog)

                    if not opened then
                        addon:Print("Blizzard color picker is not available on this client.")
                    end
                end)

                setAuditColorButtonPreview(colorButton, dialog.pendingColors[eventType])
                attachHoverNote(colorButton, eventLabel .. " Color", {
                    "Open the Blizzard color picker for this audit log type.",
                    "Changes are saved when you click Save.",
                })
                dialog.colorButtons[eventType] = colorButton
            end

            local saveButton = createButton(dialog, "Save", 88, 24, "BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -14, 14, function()
                service:UpdateSettings({ auditTextColors = dialog.pendingColors })
                dialog:Hide()
                refreshAuditView(false)
            end)

            createButton(dialog, "Close", 88, 24, "RIGHT", saveButton, "LEFT", -10, 0, function()
                dialog:Hide()
            end)

            self.maintenanceAuditColorDialog = dialog
            syncDialogState(dialog)
            return dialog
        end

        createCheckbox(settingsPanel, "Enable Audit Logging", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -10, settings.auditEnabled ~= false, function(checked)
            service:UpdateSettings({ auditEnabled = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Use Log Colors", "TOPLEFT", settingsPanel, "TOPLEFT", 178, -10, settings.auditUseColors ~= false, function(checked)
            service:UpdateSettings({ auditUseColors = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Joins", "TOPLEFT", settingsPanel, "TOPLEFT", 324, -10, settings.auditLogJoins ~= false, function(checked)
            service:UpdateSettings({ auditLogJoins = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Leaves", "TOPLEFT", settingsPanel, "TOPLEFT", 412, -10, settings.auditLogLeaves ~= false, function(checked)
            service:UpdateSettings({ auditLogLeaves = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Kicks", "TOPLEFT", settingsPanel, "TOPLEFT", 506, -10, settings.auditLogKicks ~= false, function(checked)
            service:UpdateSettings({ auditLogKicks = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Rank Changes", "TOPLEFT", settingsPanel, "TOPLEFT", 12, -36, settings.auditLogRanks ~= false, function(checked)
            service:UpdateSettings({ auditLogRanks = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Public Notes", "TOPLEFT", settingsPanel, "TOPLEFT", 142, -36, settings.auditLogNotes ~= false, function(checked)
            service:UpdateSettings({ auditLogNotes = checked })
            refreshAuditView(false)
        end)

        createCheckbox(settingsPanel, "Officer Notes", "TOPLEFT", settingsPanel, "TOPLEFT", 272, -36, settings.auditLogOfficerNotes ~= false, function(checked)
            service:UpdateSettings({ auditLogOfficerNotes = checked })
            refreshAuditView(false)
        end)

        local rowsLabel = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        rowsLabel:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 12, -72)
        rowsLabel:SetText("Max Rows")
        styleBodyText(rowsLabel, true)

        local rowsInput = createInput(settingsPanel, 62, 22, "LEFT", rowsLabel, "RIGHT", 8, 0)
        rowsInput:SetNumeric(true)
        rowsInput:SetMaxLetters(4)
        rowsInput:SetText(tostring(settings.auditMaxRows or 500))

        local daysLabel = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        daysLabel:SetPoint("LEFT", rowsInput, "RIGHT", 18, 0)
        daysLabel:SetText("History Days")
        styleBodyText(daysLabel, true)

        local daysInput = createInput(settingsPanel, 62, 22, "LEFT", daysLabel, "RIGHT", 8, 0)
        daysInput:SetNumeric(true)
        daysInput:SetMaxLetters(4)
        daysInput:SetText(tostring(settings.auditRetentionDays or 90))

        local applyLimitsButton = createButton(settingsPanel, "Apply Limits", 94, 22, "LEFT", daysInput, "RIGHT", 12, 0, function()
            service:UpdateSettings({
                auditMaxRows = rowsInput:GetText(),
                auditRetentionDays = daysInput:GetText(),
            })
            refreshAuditView(true)
        end)

        local colorButton = createButton(settingsPanel, "Color", 76, 22, "LEFT", applyLimitsButton, "RIGHT", 12, 0, function()
            ensureAuditColorDialog():Show()
        end)
        attachHoverNote(colorButton, "Audit Colors", {
            "Open the audit log color settings popup.",
            "Save inside the popup to keep new colors.",
        })

        createButton(settingsPanel, "Clear Log", 86, 22, "LEFT", colorButton, "RIGHT", 12, 0, function()
            service:ClearAuditLog()
            refreshAuditView(true)
        end)

        local settingsHelp = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        settingsHelp:SetPoint("TOPLEFT", rowsLabel, "BOTTOMLEFT", 0, -8)
        settingsHelp:SetWidth(contentWidth - 24)
        settingsHelp:SetJustifyH("LEFT")
        settingsHelp:SetText("Turn event types on or off to control what new rows are recorded. Use the Color button to open the audit color popup and save per-event text colors. Existing rows stay until they age out or you clear the local log.")
        styleBodyText(settingsHelp, true)

        local listHost = createPanel(host)
        listHost:SetPoint("TOPLEFT", settingsPanel, "BOTTOMLEFT", 0, -6)
        listHost:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -18, 18)

        local header = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        header:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -12)
        header:SetText("Audit Log")
        styleTitleText(header, 16)

        local headerMeta = listHost:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        headerMeta:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -4)
        headerMeta:SetWidth(820)
        headerMeta:SetJustifyH("LEFT")
        headerMeta:SetText(string.format(
            "Showing newest first. Log colors: %s. Rows kept: %d. Last entry: %s.",
            settings.auditUseColors ~= false and "On" or "Off",
            auditSummary.totalCount or 0,
            (auditSummary.lastRecordedAt or 0) > 0 and formatTimestampText(auditSummary.lastRecordedAt) or "None yet"
        ))
        styleBodyText(headerMeta, true)

        local columnHeaderRow = CreateFrame("Frame", nil, listHost, "BackdropTemplate")
        columnHeaderRow:SetSize(860, 24)
        columnHeaderRow:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -54)
        applySurfaceStyle(columnHeaderRow, THEME.card)

        local function createAuditHeader(parent, text, width, point, relativeTo, relativePoint, x, y)
            local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            label:SetPoint(point, relativeTo, relativePoint, x, y)
            label:SetWidth(width)
            label:SetJustifyH("LEFT")
            label:SetText(text)
            styleHighlightText(label)
            return label
        end

        createAuditHeader(columnHeaderRow, "When", 126, "LEFT", columnHeaderRow, "LEFT", 8, 0)
        createAuditHeader(columnHeaderRow, "Event", 92, "LEFT", columnHeaderRow, "LEFT", 142, 0)
        createAuditHeader(columnHeaderRow, "Member", 162, "LEFT", columnHeaderRow, "LEFT", 242, 0)
        createAuditHeader(columnHeaderRow, "Detail", 394, "LEFT", columnHeaderRow, "LEFT", 412, 0)
        createAuditHeader(columnHeaderRow, "Src", 42, "LEFT", columnHeaderRow, "LEFT", 812, 0)

        local scrollFrame = CreateFrame("ScrollFrame", nil, listHost, "UIPanelScrollFrameTemplate")
        scrollFrame:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -82)
        scrollFrame:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -30, 48)

        local scrollChild = CreateFrame("Frame", nil, scrollFrame)
        scrollChild:SetWidth(860)
        scrollChild:SetHeight(1)
        scrollFrame:SetScrollChild(scrollChild)

        if #entries == 0 then
            local empty = scrollChild:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            empty:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 14, -8)
            empty:SetWidth(820)
            empty:SetJustifyH("LEFT")
            empty:SetText(settings.auditEnabled ~= false
                and "No audit rows have been recorded yet for the local guild history."
                or "Audit logging is currently disabled. Turn it back on to start recording new guild audit rows.")
            styleBodyText(empty)
            scrollChild:SetHeight(60)
        else
            local rowHeight = 38
            local rowPitch = 44
            local visibleCount = math.max(0, endIndex - startIndex + 1)

            for entryIndex = startIndex, endIndex do
                local index = entryIndex - startIndex + 1
                local entry = entries[entryIndex]
                local row = CreateFrame("Frame", nil, scrollChild, "BackdropTemplate")
                row:SetSize(860, rowHeight)
                row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, -((index - 1) * rowPitch))
                applySurfaceStyle(row, THEME.panel)

                local whenLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                whenLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -8)
                whenLabel:SetWidth(126)
                whenLabel:SetJustifyH("LEFT")
                whenLabel:SetText(formatTimestampText(entry.recordedAt))
                styleBodyText(whenLabel, true)

                local eventLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                eventLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 142, -7)
                eventLabel:SetWidth(92)
                eventLabel:SetJustifyH("LEFT")
                eventLabel:SetText(entry.eventLabel or "Audit")
                styleBodyText(eventLabel)

                local memberLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                memberLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 242, -7)
                memberLabel:SetWidth(162)
                memberLabel:SetJustifyH("LEFT")
                memberLabel:SetText(entry.displayName or "Unknown")
                styleBodyText(memberLabel)

                local detailLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                detailLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 412, -8)
                detailLabel:SetWidth(394)
                detailLabel:SetJustifyH("LEFT")
                detailLabel:SetText(entry.detail or "")
                styleBodyText(detailLabel, true)

                local sourceLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                sourceLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 812, -8)
                sourceLabel:SetWidth(42)
                sourceLabel:SetJustifyH("LEFT")
                sourceLabel:SetText(
                    entry.source == "system" and "Chat"
                    or ((entry.source == "gh3-helper" or entry.source == "batch-execute") and "Helper" or "Roster")
                )
                styleBodyText(sourceLabel, true)

                local rowLabels = {
                    whenLabel,
                    eventLabel,
                    memberLabel,
                    detailLabel,
                    sourceLabel,
                }

                if settings.auditUseColors ~= false then
                    applyAuditRowVisual(row, rowLabels, settings.auditTextColors, entry.eventType)
                end
            end

            scrollChild:SetHeight(math.max(60, (visibleCount * rowPitch) - (visibleCount > 0 and (rowPitch - rowHeight) or 0)))
        end

        local queueCountText = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        queueCountText:SetPoint("BOTTOM", listHost, "BOTTOM", 0, 35)
        queueCountText:SetText(string.format("Audit Rows: %d", #entries))
        styleHighlightText(queueCountText)

        local pageIndicator = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        pageIndicator:SetPoint("BOTTOM", listHost, "BOTTOM", 0, 10)
        pageIndicator:SetText(string.format("Page %d of %d", currentPage, totalPages))
        styleHighlightText(pageIndicator)

        createButton(listHost, "Previous", 100, 30, "BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -120, 10, function()
            if addon.MainFrame.state.maintenanceAuditPage > 1 then
                addon.MainFrame.state.maintenanceAuditPage = addon.MainFrame.state.maintenanceAuditPage - 1
                addon.MainFrame:RefreshCurrentView()
            end
        end):SetEnabled(currentPage > 1)

        createButton(listHost, "Next", 100, 30, "BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -10, 10, function()
            if addon.MainFrame.state.maintenanceAuditPage < totalPages then
                addon.MainFrame.state.maintenanceAuditPage = addon.MainFrame.state.maintenanceAuditPage + 1
                addon.MainFrame:RefreshCurrentView()
            end
        end):SetEnabled(currentPage < totalPages)

        createButton(listHost, "Reset", 100, 30, "BOTTOMLEFT", listHost, "BOTTOMLEFT", 10, 10, function()
            addon.MainFrame.state.maintenanceAuditPage = 1
            addon.MainFrame:RefreshCurrentView()
        end)

        return
    end

    local service = addon.MaintenanceService
    local summary = service:GetSummary()
    local settings = service:GetSettings()
    local purgeEntries = service:ListCleanupEntries("purge")
    local pageSize = 50

    local contentWidth = math.max(918, math.floor((host:GetWidth() or 954) - 36))

    local summaryText = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryText:SetPoint("TOPLEFT", tabRow, "BOTTOMLEFT", 0, -4)
    summaryText:SetWidth(contentWidth)
    summaryText:SetJustifyH("LEFT")
    summaryText:SetText(string.format("%d local roster record(s) are ready to purge after %d days. These are current-guild records already marked missing from roster or deleted.", summary.purgeCount or 0, summary.purgeAfterDays or 90))
    styleBodyText(summaryText, true)

    local helperNote = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    helperNote:SetPoint("TOPLEFT", summaryText, "BOTTOMLEFT", 0, -6)
    helperNote:SetWidth(contentWidth)
    helperNote:SetJustifyH("LEFT")
    helperNote:SetText("Kick and Promote live in the Guild & Communities GH3 Helper popup. This pane is only for purging stale local data records.")
    styleBodyText(helperNote, true)

    local actionRow = CreateFrame("Frame", nil, host)
    actionRow:SetSize(520, 24)
    actionRow:SetPoint("TOPLEFT", helperNote, "BOTTOMLEFT", 0, -8)

    createButton(actionRow, "Run Scan", 90, 24, "TOPLEFT", actionRow, "TOPLEFT", 0, 0, function()
        addon.MaintenanceService:ScanRoster()
        addon.MainFrame:RefreshCurrentView()
    end)

    createButton(actionRow, "Purge Ready", 110, 24, "TOPLEFT", actionRow, "TOPLEFT", 98, 0, function()
        local purged = addon.MaintenanceService:PurgeReadyMembers()
        addon:Print("Purged", purged, "ready record(s).")
        addon.MainFrame:RefreshCurrentView()
    end)

    if service.CanExportRawGuildRoster and service:CanExportRawGuildRoster() then
        createButton(actionRow, "Export Raw Roster CSV", 168, 24, "TOPLEFT", actionRow, "TOPLEFT", 216, 0, function()
            local entries, errorMessage = service:ListRawGuildRosterEntries()

            if type(entries) ~= "table" then
                addon:Print(errorMessage or "Unable to export the raw guild roster.")
                return
            end

            local hasJoinedDate = false

            for _, entry in ipairs(entries) do
                if tostring(entry.joinedDate or "") ~= "" then
                    hasJoinedDate = true
                    break
                end
            end

            if not hasJoinedDate then
                addon:Print("No join dates were available for this export. GH3 requested guild news; if it is still blank after retrying, Blizzard did not provide join-date history for these members and GH3 has no saved join date for them.")
            end

            addon.MainFrame:ShowCsvExportDialog(
                "Raw Guild Roster CSV Export",
                addon.MainFrame:BuildRawGuildRosterExportCsv(entries),
                "Copy this text and save it as a .csv file. This export uses the live Blizzard guild roster snapshot, includes officer-visible fields such as officer notes when your rank can access them, and adds a derived joinedDate column that prefers currently loaded guild news and otherwise falls back to any already-known GH3 roster join date. It does not write back to GH3 join-date fields."
            )
        end)
    end

    local settingsRow = CreateFrame("Frame", nil, host, "BackdropTemplate")
    settingsRow:SetSize(contentWidth, 54)
    settingsRow:SetPoint("TOPLEFT", actionRow, "BOTTOMLEFT", 0, -4)
    applySurfaceStyle(settingsRow, THEME.panel)

    local purgeDaysLabel = settingsRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    purgeDaysLabel:SetPoint("TOPLEFT", settingsRow, "TOPLEFT", 12, -10)
    purgeDaysLabel:SetText("Purge After Days")
    styleHighlightText(purgeDaysLabel)

    local purgeDaysInput = createInput(settingsRow, 52, 22, "TOPLEFT", settingsRow, "TOPLEFT", 12, -26)
    purgeDaysInput:SetNumeric(true)
    purgeDaysInput:SetMaxLetters(3)
    purgeDaysInput:SetText(tostring(settings.purgeAfterDays or 90))

    createButton(settingsRow, "Apply", 64, 22, "LEFT", purgeDaysInput, "RIGHT", 8, 0, function()
        service:UpdateSettings({ purgeAfterDays = purgeDaysInput:GetText() })
        addon.MainFrame:RefreshCurrentView()
    end)

    local purgeHelp = settingsRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    purgeHelp:SetPoint("LEFT", purgeDaysInput, "RIGHT", 86, 0)
    purgeHelp:SetWidth(540)
    purgeHelp:SetJustifyH("LEFT")
    purgeHelp:SetText("Purge removes old local roster records that already left the guild or were marked deleted. It does not remove live guild members.")
    styleBodyText(purgeHelp, true)

    local totalPages = math.max(1, math.ceil(#purgeEntries / pageSize))
    self.state.maintenancePage = math.max(1, math.min(self.state.maintenancePage or 1, totalPages))
    local startIndex = ((self.state.maintenancePage - 1) * pageSize) + 1
    local endIndex = math.min(#purgeEntries, startIndex + pageSize - 1)

    local listHost = createPanel(host)
    listHost:SetPoint("TOPLEFT", settingsRow, "BOTTOMLEFT", 0, -4)
    listHost:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -18, 18)

    local header = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    header:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -12)
    header:SetText("Action Queue")
    styleTitleText(header, 16)

    local columnHeaderRow = CreateFrame("Frame", nil, listHost, "BackdropTemplate")
    columnHeaderRow:SetSize(860, 24)
    columnHeaderRow:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -38)
    applySurfaceStyle(columnHeaderRow, THEME.card)

    local scrollFrame = CreateFrame("ScrollFrame", nil, listHost, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -66)
    scrollFrame:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -30, 48)

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(860)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)

    local function createMaintenanceHeader(parent, text, width, point, relativeTo, relativePoint, x, y)
        local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint(point, relativeTo, relativePoint, x, y)
        label:SetWidth(width)
        label:SetJustifyH("LEFT")
        label:SetText(text)
        styleHighlightText(label)
        return label
    end

    createMaintenanceHeader(columnHeaderRow, "Member", 190, "LEFT", columnHeaderRow, "LEFT", 8, 0)
    createMaintenanceHeader(columnHeaderRow, "Guild", 180, "LEFT", columnHeaderRow, "LEFT", 214, 0)
    createMaintenanceHeader(columnHeaderRow, "Status", 180, "LEFT", columnHeaderRow, "LEFT", 400, 0)
    createMaintenanceHeader(columnHeaderRow, "Age", 70, "LEFT", columnHeaderRow, "LEFT", 586, 0)
    createMaintenanceHeader(columnHeaderRow, "Last Seen", 110, "LEFT", columnHeaderRow, "LEFT", 662, 0)

    if #purgeEntries == 0 then
        local empty = scrollChild:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 14, -8)
        empty:SetText("No local roster records are currently ready to purge.")
        styleBodyText(empty)
        scrollChild:SetHeight(60)
        return
    end

    local rowHeight = 46
    local rowPitch = 52
    local visibleCount = math.max(0, endIndex - startIndex + 1)

    for entryIndex = startIndex, endIndex do
        local index = entryIndex - startIndex + 1
        local record = purgeEntries[entryIndex]
        local row = CreateFrame("Frame", nil, scrollChild, "BackdropTemplate")
        row:SetSize(860, rowHeight)
        row:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, -((index - 1) * rowPitch))
        applySurfaceStyle(row, THEME.panel)

        local memberLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        memberLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -7)
        memberLabel:SetWidth(190)
        memberLabel:SetJustifyH("LEFT")
        memberLabel:SetText(record.displayName or record.memberId or "Unknown")
        styleBodyText(memberLabel)

        local guildLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        guildLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 214, -8)
        guildLabel:SetWidth(180)
        guildLabel:SetJustifyH("LEFT")
        guildLabel:SetText(record.guildName or "Unknown")
        styleBodyText(guildLabel, true)

        local statusLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        statusLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 400, -7)
        statusLabel:SetWidth(180)
        statusLabel:SetJustifyH("LEFT")
        statusLabel:SetText(record.statusLabel or record.reason or "Purge")
        styleBodyText(statusLabel)

        local ageLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        ageLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 586, -7)
        ageLabel:SetWidth(70)
        ageLabel:SetJustifyH("LEFT")
        ageLabel:SetText(string.format("%d days", record.daysOld or 0))
        styleBodyText(ageLabel)

        local lastSeenLabel = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        lastSeenLabel:SetPoint("TOPLEFT", row, "TOPLEFT", 662, -8)
        lastSeenLabel:SetWidth(110)
        lastSeenLabel:SetJustifyH("LEFT")
        lastSeenLabel:SetText(formatTimestampText(record.lastSeenAt or record.updatedAt))
        styleBodyText(lastSeenLabel, true)

        createButton(row, "Purge", 64, 20, "RIGHT", row, "RIGHT", -8, 0, function()
            addon.MaintenanceService:PurgeMember(record.memberId)
            addon.MainFrame:RefreshCurrentView()
        end)
    end

    local queueCountText = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    queueCountText:SetPoint("BOTTOM", listHost, "BOTTOM", 0, 35)
    queueCountText:SetText(string.format("Purge-ready Records: %d", #purgeEntries))
    styleHighlightText(queueCountText)

    local pageIndicator = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    pageIndicator:SetPoint("BOTTOM", listHost, "BOTTOM", 0, 10)
    pageIndicator:SetText(string.format("Page %d of %d", self.state.maintenancePage, totalPages))
    styleHighlightText(pageIndicator)

    createButton(listHost, "Previous", 100, 30, "BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -120, 10, function()
        if addon.MainFrame.state.maintenancePage > 1 then
            addon.MainFrame.state.maintenancePage = addon.MainFrame.state.maintenancePage - 1
            addon.MainFrame:RefreshCurrentView()
        end
    end)

    createButton(listHost, "Next", 100, 30, "BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -10, 10, function()
        if addon.MainFrame.state.maintenancePage < totalPages then
            addon.MainFrame.state.maintenancePage = addon.MainFrame.state.maintenancePage + 1
            addon.MainFrame:RefreshCurrentView()
        end
    end)

    createButton(listHost, "Reset", 100, 30, "BOTTOMLEFT", listHost, "BOTTOMLEFT", 10, 10, function()
        addon.MainFrame.state.maintenancePage = 1
        addon.MainFrame:RefreshCurrentView()
    end)

    scrollChild:SetHeight(math.max(60, (visibleCount * rowPitch) - (visibleCount > 0 and (rowPitch - rowHeight) or 0)))
end

function addon.MainFrame:RenderSettings()
    local host = self:GetViewHost()
    local featureOptions = addon:GetFeatureToggleDefinitions() or {}
    local paneTitleFont = getConfiguredPaneFontOption("paneTitle")
    local paneBodyFont = getConfiguredPaneFontOption("paneBody")
    local fontSectionHeight = 132

    createSectionTitle(host, "Settings", 18, -18)

    local settingsPanel = createPanel(host)
    settingsPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    settingsPanel:SetSize(918, 560)

    local title = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -14)
    title:SetText("Menu Settings")
    styleTitleText(title, 18)

    local meta = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    meta:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    meta:SetWidth(874)
    meta:SetJustifyH("LEFT")
    meta:SetText("Turn menu features on or off to hide them and stop their background refreshes, alerts, and related processing. This is useful for troubleshooting performance or disabling tools you do not use.")
    styleBodyText(meta, true)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, settingsPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -58)
    listScrollFrame:SetPoint("BOTTOMRIGHT", settingsPanel, "BOTTOMRIGHT", -30, 14)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(860, math.max(220, fontSectionHeight + 12 + (#featureOptions * 108)))
    listScrollFrame:SetScrollChild(listContent)

    local fontRow = createPanel(listContent)
    fontRow:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
    fontRow:SetSize(844, fontSectionHeight)

    local fontRowTitle = fontRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    fontRowTitle:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -12)
    fontRowTitle:SetText("Pane Fonts")
    styleTitleText(fontRowTitle, 16)

    local fontRowMeta = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    fontRowMeta:SetPoint("TOPLEFT", fontRowTitle, "BOTTOMLEFT", 0, -4)
    fontRowMeta:SetWidth(640)
    fontRowMeta:SetJustifyH("LEFT")
    fontRowMeta:SetText("These font choices apply to pane titles, labels, help text, dropdown text, and input text inside the content pages. The sidebar menu, menu buttons, and the main GH3 window title stay unchanged.")
    styleBodyText(fontRowMeta, true)

    local titleFontLabel = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    titleFontLabel:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -62)
    titleFontLabel:SetText("Pane Title Font")
    styleBodyText(titleFontLabel, true)

    local titleFontDropdown = createMenuButton(fontRow, paneTitleFont and paneTitleFont.label or "Morpheus", 216, 24, "TOPLEFT", fontRow, "TOPLEFT", 14, -78)
    titleFontDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(PANE_TITLE_FONT_OPTIONS) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.key,
                    checked = paneTitleFont and paneTitleFont.key == option.key,
                    onClick = function(value)
                        setConfiguredPaneFont("paneTitle", value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(titleFontDropdown, paneTitleFont and paneTitleFont.label or "Morpheus")

    local bodyFontLabel = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bodyFontLabel:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 254, -62)
    bodyFontLabel:SetText("Pane Text Font")
    styleBodyText(bodyFontLabel, true)

    local bodyFontDropdown = createMenuButton(fontRow, paneBodyFont and paneBodyFont.label or "Default UI", 216, 24, "TOPLEFT", fontRow, "TOPLEFT", 254, -78)
    bodyFontDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(PANE_BODY_FONT_OPTIONS) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.key,
                    checked = paneBodyFont and paneBodyFont.key == option.key,
                    onClick = function(value)
                        setConfiguredPaneFont("paneBody", value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(bodyFontDropdown, paneBodyFont and paneBodyFont.label or "Default UI")

    createButton(fontRow, "Reset Fonts", 104, 24, "TOPRIGHT", fontRow, "TOPRIGHT", -16, -74, function()
        setConfiguredPaneFont("paneTitle", DEFAULT_PANE_TITLE_FONT_KEY)
        setConfiguredPaneFont("paneBody", DEFAULT_PANE_BODY_FONT_KEY)
        addon.MainFrame:RefreshCurrentView(true)
    end)

    for index, option in ipairs(featureOptions) do
        local row = createPanel(listContent)
        row:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, -(fontSectionHeight + 12 + ((index - 1) * 110)))
        row:SetSize(844, 96)

        local rowTitle = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        rowTitle:SetPoint("TOPLEFT", row, "TOPLEFT", 14, -12)
        rowTitle:SetText(option.label)
        styleTitleText(rowTitle, 16)

        local enabled = addon:IsFeatureEnabled(option.key)

        local status = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        status:SetPoint("TOPLEFT", rowTitle, "BOTTOMLEFT", 0, -4)
        status:SetText(enabled and "Status: Enabled" or "Status: Disabled")
        styleBodyText(status, not enabled)

        local description = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        description:SetPoint("TOPLEFT", status, "BOTTOMLEFT", 0, -8)
        description:SetWidth(640)
        description:SetJustifyH("LEFT")
        description:SetText(option.description or "")
        styleBodyText(description)

        local toggleButton = createButton(row, enabled and "On" or "Off", 92, 28, "TOPRIGHT", row, "TOPRIGHT", -16, -18, function()
            local newEnabled = not addon:IsFeatureEnabled(option.key)
            addon:SetFeatureEnabled(option.key, newEnabled)

            if not newEnabled and addon.Router and addon.Router.GetCurrentView and addon:GetFeatureKeyForView(addon.Router:GetCurrentView()) == option.key then
                addon.Store:GetProfile().ui.selectedView = "settings"
            end

            addon.MainFrame:RefreshCurrentView(true)
        end)

        if enabled then
            setButtonTone(toggleButton, { 0.08, 0.28, 0.12, 0.98 }, { 0.88, 0.98, 0.88, 1 })
        else
            setButtonTone(toggleButton, { 0.28, 0.11, 0.09, 0.98 }, { 0.98, 0.88, 0.84, 1 })
        end
    end
end

function addon.MainFrame:RenderHelp()
    self:EnsureState()

    local host = self:GetViewHost()
    local selectedArticle = getHelpArticleByKey(self.state.helpArticleKey)

    createSectionTitle(host, "Help Center", 18, -18)

    local intro = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    intro:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    intro:SetWidth(900)
    intro:SetJustifyH("LEFT")
    intro:SetText("Use Help Center like a guild KB. Pick a topic on the left for the recommended workflow, quick examples, and the notes most likely to answer common support questions.")
    styleBodyText(intro)

    local navPanel = createPanel(host)
    navPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -86)
    navPanel:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 18, 18)
    navPanel:SetWidth(248)

    local articlePanel = createPanel(host)
    articlePanel:SetPoint("TOPLEFT", navPanel, "TOPRIGHT", 12, 0)
    articlePanel:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -18, 18)

    local navTitle = navPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    navTitle:SetPoint("TOPLEFT", navPanel, "TOPLEFT", 14, -12)
    navTitle:SetText("Topics")
    styleTitleText(navTitle, 16)

    local navHint = navPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    navHint:SetPoint("TOPLEFT", navTitle, "BOTTOMLEFT", 0, -6)
    navHint:SetWidth(214)
    navHint:SetJustifyH("LEFT")
    navHint:SetText("Select the topic that best matches the question you are trying to answer.")
    styleBodyText(navHint, 11)

    local navScroll = CreateFrame("ScrollFrame", nil, navPanel, "UIPanelScrollFrameTemplate")
    navScroll:SetPoint("TOPLEFT", navPanel, "TOPLEFT", 10, -64)
    navScroll:SetPoint("BOTTOMRIGHT", navPanel, "BOTTOMRIGHT", -30, 10)

    local navContent = CreateFrame("Frame", nil, navScroll)
    navContent:SetWidth(194)
    navContent:SetHeight(1)
    navScroll:SetScrollChild(navContent)

    local navCursorY = -2

    for _, category in ipairs(HELP_CATEGORY_ORDER) do
        local categoryHasArticles = false

        for _, article in ipairs(HELP_ARTICLES) do
            if article.category == category then
                categoryHasArticles = true
                break
            end
        end

        if categoryHasArticles then
            local categoryLabel = navContent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            categoryLabel:SetPoint("TOPLEFT", navContent, "TOPLEFT", 4, navCursorY)
            categoryLabel:SetText(category)
            styleHighlightText(categoryLabel, 13)
            navCursorY = navCursorY - 22

            for _, article in ipairs(HELP_ARTICLES) do
                if article.category == category then
                    local articleKey = article.key
                    local button = createButton(navContent, cropText(article.title, 24), 184, 24, "TOPLEFT", navContent, "TOPLEFT", 4, navCursorY, function()
                        if self.state.helpArticleKey ~= articleKey then
                            self.state.helpArticleKey = articleKey
                            self:RefreshCurrentView(true)
                        end
                    end)

                    local label = button:GetFontString()
                    if label then
                        label:ClearAllPoints()
                        label:SetPoint("LEFT", button, "LEFT", 10, 0)
                        label:SetJustifyH("LEFT")
                    end

                    if self.state.helpArticleKey == articleKey then
                        setButtonTone(button, { 0.38, 0.22, 0.08, 0.98 }, { 1.0, 0.92, 0.78, 1 })
                    end

                    navCursorY = navCursorY - 28
                end
            end

            navCursorY = navCursorY - 10
        end
    end

    navContent:SetHeight(math.max(1, math.abs(navCursorY) + 12))

    local articleScroll = CreateFrame("ScrollFrame", nil, articlePanel, "UIPanelScrollFrameTemplate")
    articleScroll:SetPoint("TOPLEFT", articlePanel, "TOPLEFT", 10, -10)
    articleScroll:SetPoint("BOTTOMRIGHT", articlePanel, "BOTTOMRIGHT", -30, 10)

    local articleContent = CreateFrame("Frame", nil, articleScroll)
    articleContent:SetWidth(620)
    articleContent:SetHeight(1)
    articleScroll:SetScrollChild(articleContent)

    local articleTitle = articleContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    articleTitle:SetPoint("TOPLEFT", articleContent, "TOPLEFT", 4, -4)
    articleTitle:SetWidth(600)
    articleTitle:SetJustifyH("LEFT")
    articleTitle:SetText(selectedArticle.title)
    styleTitleText(articleTitle, 19)

    local articleSummary = articleContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    articleSummary:SetPoint("TOPLEFT", articleTitle, "BOTTOMLEFT", 0, -8)
    articleSummary:SetWidth(600)
    articleSummary:SetJustifyH("LEFT")
    articleSummary:SetJustifyV("TOP")
    articleSummary:SetText(selectedArticle.summary or "")
    styleBodyText(articleSummary)

    if articleSummary.SetSpacing then
        articleSummary:SetSpacing(2)
    end

    local articleCursorY = -(articleSummary:GetStringHeight() + 44)

    local quickStartCard = createCard(articleContent, 600, 72, "Quick Shortcuts", "/gh3  |  /gh3 mythics  |  /gh3 bis  |  /gh3 sync", "Use slash commands when you want to jump directly to the page that answers the question in front of you.", 4, articleCursorY, {
        insetX = 12,
        subFontSize = 11,
        subGap = 8,
        subSpacing = -2,
        titleSize = 14,
        titleTop = 10,
        valueGap = 8,
        valueSize = 15,
    })
    articleCursorY = articleCursorY - 86

    local function addArticleBlock(title, body)
        if not body or body == "" then
            return
        end

        local block = createPanel(articleContent)
        block:SetPoint("TOPLEFT", articleContent, "TOPLEFT", 4, articleCursorY)
        block:SetWidth(600)

        local titleText = block:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        titleText:SetPoint("TOPLEFT", block, "TOPLEFT", 12, -12)
        titleText:SetWidth(576)
        titleText:SetJustifyH("LEFT")
        titleText:SetText(title)
        styleHighlightText(titleText, 15)

        local bodyText = block:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        bodyText:SetPoint("TOPLEFT", titleText, "BOTTOMLEFT", 0, -8)
        bodyText:SetWidth(576)
        bodyText:SetJustifyH("LEFT")
        bodyText:SetJustifyV("TOP")
        bodyText:SetText(body)
        styleBodyText(bodyText)

        if bodyText.SetSpacing then
            bodyText:SetSpacing(2)
        end

        local blockHeight = math.max(74, bodyText:GetStringHeight() + 48)
        block:SetHeight(blockHeight)
        articleCursorY = articleCursorY - blockHeight - 10
    end

    addArticleBlock("Recommended Steps", formatHelpList(selectedArticle.steps, true))
    addArticleBlock("Examples", formatHelpList(selectedArticle.examples, false))
    addArticleBlock("Notes", formatHelpList(selectedArticle.notes, false))
    addArticleBlock("Disclaimer", tostring(selectedArticle.disclaimer or ""))

    articleContent:SetHeight(math.max(1, math.abs(articleCursorY) + 12))
end

function addon.MainFrame:RenderMythics()
    local host = self:GetViewHost()
    local service = addon.MythicPlusService

    self:EnsureState()
    self.mythicsSummaryKeysScrollFrame = nil
    self.mythicsMembersScrollFrame = nil
    self.mythicsPortalRailScrollFrame = nil

    if not service then
        createSectionTitle(host, "Mythic+", 18, -18)

        local unavailablePanel = createPanel(host)
        unavailablePanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        unavailablePanel:SetSize(918, 180)

        local title = unavailablePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        title:SetPoint("TOPLEFT", unavailablePanel, "TOPLEFT", 14, -14)
        title:SetText("Mythic+ Disabled")
        styleTitleText(title, 18)

        local body = unavailablePanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        body:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
        body:SetWidth(880)
        body:SetJustifyH("LEFT")
        body:SetJustifyV("TOP")
        body:SetText("The Mythic+ service is not loaded right now, so this view is temporarily unavailable. This was done intentionally to isolate the load error in Features/MythicPlusService.lua.")
        styleBodyText(body)

        local hint = unavailablePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        hint:SetPoint("TOPLEFT", body, "BOTTOMLEFT", 0, -12)
        hint:SetWidth(880)
        hint:SetJustifyH("LEFT")
        hint:SetText("If the startup error is gone with this service disabled, the Mythic+ file is the source of the failure and can be debugged in isolation.")
        hint:SetTextColor(0.78, 0.74, 0.68)

        return
    end

    local activePane = service:GetActivePane()
    local selectedToonId = service:GetSelectedToonId()
    local selectedSummary = nil
    local planner = nil
    local analysisMode = service:GetAnalysisMode()
    local carryMilestone = service:GetCarryMilestone()
    local carryKeyGoal = service:GetCarryKeyGoal()

    if activePane == "toons" or activePane == "season" or activePane == "party" then
        service:AutoRefreshLiveData()
    end

    local function tryGetMapTexture(dungeonId)
        if type(C_ChallengeMode) ~= "table" or type(C_ChallengeMode.GetMapUIInfo) ~= "function" then
            return nil
        end

        local cacheKey = string.format("%s:%s", service:GetSeasonId() or "current", tostring(dungeonId or ""))
        local cached = addon.MainFrame._mythicPortalTextureCache[cacheKey]

        if cached ~= nil then
            return cached ~= false and cached or nil
        end

        local ok, _, _, _, texture, backgroundTexture = pcall(C_ChallengeMode.GetMapUIInfo, tonumber(dungeonId))
        local resolvedTexture = texture or backgroundTexture or false

        if ok and resolvedTexture then
            addon.MainFrame._mythicPortalTextureCache[cacheKey] = resolvedTexture
            return resolvedTexture
        end

        addon.MainFrame._mythicPortalTextureCache[cacheKey] = false

        return nil
    end

    local function getPortalShortLabel(dungeonRow)
        if not dungeonRow then
            return "-"
        end

        if service and service.GetDungeonShortLabel then
            return service:GetDungeonShortLabel(dungeonRow.id, dungeonRow.name) or "-"
        end

        local dungeonName = tostring(dungeonRow.name or ""):match("^%s*(.-)%s*$") or ""

        if dungeonName == "" then
            return "-"
        end

        local ignoredWords = {
            ["a"] = true,
            ["an"] = true,
            ["and"] = true,
            ["of"] = true,
            ["the"] = true,
            ["to"] = true,
        }
        local parts = {}

        for word in dungeonName:gmatch("[%a%d']+") do
            local normalizedWord = string.lower(word)

            if not ignoredWords[normalizedWord] then
                parts[#parts + 1] = string.upper(string.sub(word, 1, 1))
            end
        end

        if #parts <= 0 then
            return string.upper(string.sub(dungeonName, 1, math.min(4, #dungeonName)))
        end

        return table.concat(parts, "")
    end

    local function getPortalSpellName(dungeonRow)
        if not (service and service.GetPortalSpellName) then
            local dungeonName = tostring(dungeonRow and dungeonRow.name or ""):match("^%s*(.-)%s*$") or ""

            if dungeonName == "" then
                return nil
            end

            return "Teleport: " .. dungeonName
        end

        return service:GetPortalSpellName(dungeonRow and dungeonRow.id or nil, dungeonRow and dungeonRow.name or nil)
    end

    local function getPortalSpellId(dungeonRow)
        if not (service and service.GetPortalSpellId) then
            return nil
        end

        return service:GetPortalSpellId(dungeonRow and dungeonRow.id or nil, dungeonRow and dungeonRow.name or nil)
    end

    local function getPortalCooldownInfo(dungeonRow)
        if not (service and service.GetPortalCooldownInfo) then
            return nil
        end

        return service:GetPortalCooldownInfo(dungeonRow and dungeonRow.id or nil, dungeonRow and dungeonRow.name or nil)
    end

    local function getPartyDungeonDetailLabel(toonId, dungeonId)
        if service and service.GetPartyBreakdownCellLabel then
            return service:GetPartyBreakdownCellLabel(
                toonId,
                dungeonId,
                analysisMode,
                planner.targetKeyLevel,
                carryMilestone,
                carryKeyGoal
            )
        end

        if service and service.GetPartyDungeonDetailLabel then
            return service:GetPartyDungeonDetailLabel(toonId, dungeonId, planner.targetKeyLevel)
        end

        return "-"
    end

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

    local function stripPortalSpellPrefix(value)
        local text = trimPortalAnnouncementText(value)

        text = text:gsub("^[Tt]eleport:%s*", "")
        text = text:gsub("^[Pp]ortal:%s*", "")

        return trimPortalAnnouncementText(text)
    end

    local function getPortalAnnouncementChannel()
        if LE_PARTY_CATEGORY_INSTANCE and IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
            return "INSTANCE_CHAT"
        end

        if IsInRaid and IsInRaid() then
            return "RAID"
        end

        if IsInGroup and IsInGroup() then
            return "PARTY"
        end

        return nil
    end

    local function getPortalAnnouncementDungeonName(dungeonRow, portalSpellName)
        local rowName = trimPortalAnnouncementText(dungeonRow and dungeonRow.name or "")
        local dungeonId = trimPortalAnnouncementText(dungeonRow and dungeonRow.id or "")

        if rowName == "" or rowName == dungeonId or rowName == "Unknown Dungeon" then
            rowName = stripPortalSpellPrefix(portalSpellName)
        end

        if rowName == "" then
            rowName = "Dungeon"
        end

        return rowName
    end

    local function announcePortalCastStarted(dungeonRow, portalSpellName)
        if not SendChatMessage then
            return false
        end

        local channel = getPortalAnnouncementChannel()

        if not channel then
            return false
        end

        local dungeonName = getPortalAnnouncementDungeonName(dungeonRow, portalSpellName)
        local message = string.format("[GH3][Porting to Dungeon %s]", dungeonName):gsub("|", "||")
        local ok = pcall(SendChatMessage, message, channel)

        return ok and true or false
    end

    local function renderPortalBadge(parent, dungeonRow, index, options)
        options = options or {}

        local columns = tonumber(options.columns) or 1
        local cardWidth = tonumber(options.cardWidth) or 64
        local cardHeight = tonumber(options.cardHeight) or 74
        local insetX = tonumber(options.insetX) or 0
        local insetY = tonumber(options.insetY) or 0
        local horizontalGap = tonumber(options.horizontalGap) or 8
        local verticalGap = tonumber(options.verticalGap) or 8
        local showName = options.showName and true or false
        local column = (index - 1) % columns
        local row = math.floor((index - 1) / columns)
        local isUnlocked = dungeonRow.portalLabel == "Yes"
        local portalSpellId = getPortalSpellId(dungeonRow)
        local portalSpellName = getPortalSpellName(dungeonRow)
        local portalCooldown = getPortalCooldownInfo(dungeonRow)
        local canCastPortal = isUnlocked and tonumber(portalSpellId) ~= nil
        local card = CreateFrame("Button", nil, parent, "BackdropTemplate,SecureActionButtonTemplate")
        local parentFrameLevel = parent and parent.GetFrameLevel and (tonumber(parent:GetFrameLevel()) or 0) or 0

        card:SetSize(cardWidth, cardHeight)
        card:SetPoint("TOPLEFT", parent, "TOPLEFT", insetX + (column * (cardWidth + horizontalGap)), -(insetY + (row * (cardHeight + verticalGap))))
        card:SetFrameLevel(parentFrameLevel + 2)
        card:EnableMouse(true)
        card:RegisterForClicks("AnyUp", "AnyDown")

        if card.SetText then
            card:SetText("")
        end

        if card.GetFontString and card:GetFontString() then
            card:GetFontString():SetText("")
            card:GetFontString():Hide()
        end

        if not (InCombatLockdown and InCombatLockdown()) then
            card:SetAttribute("useOnKeyDown", false)

            if canCastPortal then
                card:SetAttribute("type", "spell")
                card:SetAttribute("spell", portalSpellId)
                card:SetAttribute("type1", "spell")
                card:SetAttribute("spell1", portalSpellId)
            else
                card:SetAttribute("type", nil)
                card:SetAttribute("spell", nil)
                card:SetAttribute("type1", nil)
                card:SetAttribute("spell1", nil)
            end
        end

        local icon = card:CreateTexture(nil, "ARTWORK")
        icon:SetPoint("TOPLEFT", card, "TOPLEFT", 2, -2)
        icon:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -2, 2)
        if icon.SetTexCoord then
            icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
        end
        local texturePath = tryGetMapTexture(dungeonRow.id)

        if texturePath then
            icon:SetTexture(texturePath)
            if icon.SetDesaturated then
                icon:SetDesaturated(not isUnlocked)
            end

            if isUnlocked then
                icon:SetVertexColor(1, 1, 1, 1)
            else
                icon:SetVertexColor(0.42, 0.42, 0.42, 0.9)
            end
        else
            if isUnlocked then
                icon:SetColorTexture(0.42, 0.56, 0.78, 0.95)
            else
                icon:SetColorTexture(0.2, 0.22, 0.26, 0.95)
            end
        end

        local shade = card:CreateTexture(nil, "OVERLAY")
        shade:SetPoint("TOPLEFT", icon, "TOPLEFT", 0, 0)
        shade:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 0, 0)
        if shade.SetColorTexture then
            if isUnlocked then
                shade:SetColorTexture(0, 0, 0, 0.18)
            else
                shade:SetColorTexture(0, 0, 0, 0.36)
            end
        end

        local bevel = card:CreateTexture(nil, "OVERLAY")
        bevel:SetPoint("TOPLEFT", icon, "TOPLEFT", 0, 0)
        bevel:SetPoint("TOPRIGHT", icon, "TOPRIGHT", 0, 0)
        bevel:SetHeight(16)
        if bevel.SetColorTexture then
            bevel:SetColorTexture(1, 1, 1, isUnlocked and 0.08 or 0.04)
        end

        local flashOverlay = card:CreateTexture(nil, "OVERLAY")
        flashOverlay:SetPoint("TOPLEFT", icon, "TOPLEFT", 0, 0)
        flashOverlay:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 0, 0)
        flashOverlay:SetColorTexture(0.96, 0.8, 0.24, 0)
        flashOverlay:SetBlendMode("ADD")
        flashOverlay:Hide()

        local flashBorder = CreateFrame("Frame", nil, card, "BackdropTemplate")
        flashBorder:SetPoint("TOPLEFT", icon, "TOPLEFT", -2, 2)
        flashBorder:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 2, -2)
        flashBorder:SetFrameLevel(card:GetFrameLevel() + 5)
        flashBorder:SetBackdrop({
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 2,
            insets = { left = 0, right = 0, top = 0, bottom = 0 },
        })
        flashBorder:SetBackdropBorderColor(0.98, 0.82, 0.28, 1)
        flashBorder:SetAlpha(0)
        flashBorder:Hide()

        local castBadge = CreateFrame("Frame", nil, card, "BackdropTemplate")
        castBadge:SetPoint("TOP", icon, "TOP", 0, -6)
        castBadge:SetSize(math.max(44, cardWidth - 14), 14)
        castBadge:SetFrameLevel(card:GetFrameLevel() + 6)
        castBadge:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        castBadge:SetBackdropColor(0.12, 0.09, 0.03, 0.92)
        castBadge:SetBackdropBorderColor(0.92, 0.74, 0.22, 0.95)
        castBadge:Hide()

        local castBadgeText = castBadge:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        castBadgeText:SetPoint("CENTER", castBadge, "CENTER", 0, 0)
        castBadgeText:SetText("Casting...")
        castBadgeText:SetTextColor(0.98, 0.91, 0.66, 1)

        local flashAnimation = flashOverlay:CreateAnimationGroup()
        flashAnimation:SetLooping("REPEAT")
        local fadeIn = flashAnimation:CreateAnimation("Alpha")
        fadeIn:SetOrder(1)
        fadeIn:SetFromAlpha(0.12)
        fadeIn:SetToAlpha(0.5)
        fadeIn:SetDuration(0.18)
        local fadeOut = flashAnimation:CreateAnimation("Alpha")
        fadeOut:SetOrder(2)
        fadeOut:SetFromAlpha(0.5)
        fadeOut:SetToAlpha(0.12)
        fadeOut:SetDuration(0.18)

        local borderAnimation = flashBorder:CreateAnimationGroup()
        borderAnimation:SetLooping("REPEAT")
        local borderFadeIn = borderAnimation:CreateAnimation("Alpha")
        borderFadeIn:SetOrder(1)
        borderFadeIn:SetFromAlpha(0.3)
        borderFadeIn:SetToAlpha(1)
        borderFadeIn:SetDuration(0.18)
        local borderFadeOut = borderAnimation:CreateAnimation("Alpha")
        borderFadeOut:SetOrder(2)
        borderFadeOut:SetFromAlpha(1)
        borderFadeOut:SetToAlpha(0.3)
        borderFadeOut:SetDuration(0.18)

        local function hidePortalCastBadge()
            castBadge:Hide()
            card._gh3PortalCastBadgeVisible = false
        end

        local function showPortalCastBadge()
            castBadge:Show()
            card._gh3PortalCastBadgeVisible = true
        end

        local function stopPortalFlash()
            if flashAnimation:IsPlaying() then
                flashAnimation:Stop()
            end
            if borderAnimation:IsPlaying() then
                borderAnimation:Stop()
            end
            flashOverlay:SetAlpha(0)
            flashOverlay:Hide()
            flashBorder:SetAlpha(0)
            flashBorder:Hide()
            hidePortalCastBadge()
            card._gh3PortalFlashActive = false
            card._gh3PortalFlashPersist = false
        end

        local function startPortalFlash(persistUntilStop)
            card._gh3PortalFlashToken = (card._gh3PortalFlashToken or 0) + 1
            local currentToken = card._gh3PortalFlashToken

            card._gh3PortalFlashActive = true
            card._gh3PortalFlashPersist = persistUntilStop and true or false
            flashOverlay:SetAlpha(0.5)
            flashOverlay:Show()
            flashBorder:SetAlpha(1)
            flashBorder:Show()
            flashAnimation:Play()
            borderAnimation:Play()

            if not card._gh3PortalFlashPersist and C_Timer and C_Timer.After then
                C_Timer.After(1.2, function()
                    if card._gh3PortalFlashToken == currentToken and not card._gh3PortalFlashPersist then
                        stopPortalFlash()
                    end
                end)
            end
        end

        card._gh3Cleanup = function(control)
            stopPortalFlash()

            if control and control.UnregisterAllEvents then
                control:UnregisterAllEvents()
            end
        end

        if canCastPortal then
            card:RegisterUnitEvent("UNIT_SPELLCAST_START", "player")
            card:RegisterEvent("PLAYER_STARTED_MOVING")
            card:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
            card:RegisterUnitEvent("UNIT_SPELLCAST_INTERRUPTED", "player")
            card:RegisterUnitEvent("UNIT_SPELLCAST_STOP", "player")
            card:RegisterUnitEvent("UNIT_SPELLCAST_FAILED", "player")
            card:RegisterUnitEvent("UNIT_SPELLCAST_FAILED_QUIET", "player")
            card:SetScript("OnEvent", function(self, event, unitTarget, castGUID, spellId)
                if event == "UNIT_SPELLCAST_START" then
                    if tonumber(spellId) ~= nil and tonumber(portalSpellId) == tonumber(spellId) then
                        showPortalCastBadge()
                        startPortalFlash(true)

                        if castGUID ~= nil and self._gh3PortalAnnouncedCastGUID ~= castGUID then
                            self._gh3PortalAnnouncedCastGUID = castGUID
                            announcePortalCastStarted(dungeonRow, portalSpellName)
                        elseif castGUID == nil and not self._gh3PortalAnnouncedCastWithoutGUID then
                            self._gh3PortalAnnouncedCastWithoutGUID = true
                            announcePortalCastStarted(dungeonRow, portalSpellName)
                        end
                    end
                elseif event == "PLAYER_STARTED_MOVING"
                    or event == "UNIT_SPELLCAST_SUCCEEDED"
                    or event == "UNIT_SPELLCAST_INTERRUPTED"
                    or event == "UNIT_SPELLCAST_STOP"
                    or event == "UNIT_SPELLCAST_FAILED"
                    or event == "UNIT_SPELLCAST_FAILED_QUIET" then
                    self._gh3PortalAnnouncedCastWithoutGUID = false
                    stopPortalFlash()
                end
            end)
        end

        card:SetScript("OnMouseDown", function()
            if canCastPortal then
                flashOverlay:SetAlpha(0.24)
                flashOverlay:Show()
            end
        end)

        card:SetScript("OnMouseUp", function()
            local livePortalCooldown = getPortalCooldownInfo(dungeonRow)

            if canCastPortal and (not livePortalCooldown or not livePortalCooldown.isOnCooldown) then
                startPortalFlash(false)
            else
                stopPortalFlash()
            end
        end)

        if showName then
            local shadowName = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            shadowName:SetPoint("CENTER", icon, "CENTER", 1, -7)
            shadowName:SetWidth(cardWidth - 10)
            shadowName:SetJustifyH("CENTER")
            shadowName:SetText(cropText(dungeonRow.shortName or getPortalShortLabel(dungeonRow), 12))
            shadowName:SetFont(TITLE_FONT_PATH, 18, "OUTLINE")
            shadowName:SetTextColor(0, 0, 0, 0.95)

            local name = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
            name:SetPoint("CENTER", icon, "CENTER", 0, -8)
            name:SetWidth(cardWidth - 10)
            name:SetJustifyH("CENTER")
            name:SetText(cropText(dungeonRow.shortName or getPortalShortLabel(dungeonRow), 12))
            name:SetFont(TITLE_FONT_PATH, 18, "OUTLINE")
            name:SetShadowOffset(1, -1)
            name:SetShadowColor(0, 0, 0, 0.95)
            if isUnlocked then
                name:SetTextColor(0.98, 0.9, 0.62, 1)
            else
                name:SetTextColor(0.78, 0.78, 0.78, 0.92)
            end
        end

        if isUnlocked and portalCooldown and portalCooldown.isOnCooldown then
            local cooldownBadge = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            cooldownBadge:SetPoint("BOTTOM", icon, "BOTTOM", 0, 4)
            cooldownBadge:SetWidth(cardWidth - 8)
            cooldownBadge:SetJustifyH("CENTER")
            cooldownBadge:SetText("CD " .. tostring(portalCooldown.label or "-"))
            cooldownBadge:SetTextColor(0.94, 0.82, 0.52, 1)
        end

        card:SetScript("OnEnter", function(self)
            local configuredType = tostring(self:GetAttribute("type1") or self:GetAttribute("type") or "-")
            local configuredSpell = tostring(self:GetAttribute("spell1") or self:GetAttribute("spell") or "-")
            local showDebug = IsAltKeyDown and IsAltKeyDown()

            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            GameTooltip:SetText(dungeonRow.name or "Dungeon")
            GameTooltip:AddLine(string.format("Portal: %s", isUnlocked and "Unlocked" or "Locked"), isUnlocked and 0.88 or 0.65, isUnlocked and 0.78 or 0.65, isUnlocked and 0.56 or 0.65)
            GameTooltip:AddLine(string.format("Best Run: %s", dungeonRow.bestLabel or "-"), 0.8, 0.79, 0.74, true)
            GameTooltip:AddLine(string.format("State: %s", dungeonRow.timedLabel or "Missing"), 0.8, 0.79, 0.74, true)
            if selectedSummary and selectedSummary.fullName then
                GameTooltip:AddLine(string.format("Toon: %s", selectedSummary.fullName), 0.8, 0.79, 0.74, true)
            end
            if isUnlocked and portalCooldown then
                GameTooltip:AddLine(string.format("Cooldown: %s", portalCooldown.isOnCooldown and portalCooldown.label or "Ready"), 0.8, 0.79, 0.74, true)
            end
            if canCastPortal and portalSpellName then
                GameTooltip:AddLine("Click to cast teleport.", 0.42, 0.86, 0.52, true)
            elseif isUnlocked then
                GameTooltip:AddLine("This portal is unlocked for the selected toon or Warband, but the current character does not know that teleport.", 0.72, 0.72, 0.72, true)
            elseif not isUnlocked then
                GameTooltip:AddLine("Unlock this portal on any Warband toon to make it clickable.", 0.72, 0.72, 0.72, true)
            end

            if showDebug then
                GameTooltip:AddLine(string.format("Debug: dungeonId=%s spellId=%s", tostring(dungeonRow.id or "-"), tostring(portalSpellId or "-")), 0.58, 0.76, 0.92, true)
                GameTooltip:AddLine(string.format("Debug: spellName=%s", tostring(portalSpellName or "-")), 0.58, 0.76, 0.92, true)
                GameTooltip:AddLine(string.format("Debug: attr=%s spell=%s", configuredType, configuredSpell), 0.58, 0.76, 0.92, true)
            end

            GameTooltip:Show()
        end)
        card:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
        if isUnlocked then
            applySurfaceStyle(card, THEME.card)
        else
            applySurfaceStyle(card, THEME.panel)
        end
    end

    createSectionTitle(host, "Mythic+", 18, -18)

    local tabRow = CreateFrame("Frame", nil, host)
    tabRow:SetPoint("TOPLEFT", host, "TOPLEFT", 148, -20)
    tabRow:SetSize(760, 26)

    local toonsTab = createButton(tabRow, activePane == "toons" and "My Toons *" or "My Toons", 110, 24, "TOPLEFT", tabRow, "TOPLEFT", 0, 0, function()
        service:SetActivePane("toons")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local partyTab = createButton(tabRow, activePane == "party" and "Party Planning *" or "Party Planning", 132, 24, "LEFT", toonsTab, "RIGHT", 8, 0, function()
        service:SetActivePane("party")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local seasonTab = createButton(tabRow, activePane == "season" and "Season *" or "Season", 92, 24, "LEFT", partyTab, "RIGHT", 8, 0, function()
        service:SetActivePane("season")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local leaderboardTab = createButton(tabRow, activePane == "leaderboard" and "Leaderboard *" or "Leaderboard", 118, 24, "LEFT", seasonTab, "RIGHT", 8, 0, function()
        service:SetActivePane("leaderboard")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    if activePane == "party" then
        createButton(tabRow, service:IsOverlayEnabled() and "HUD On" or "HUD Off", 82, 24, "LEFT", leaderboardTab, "RIGHT", 18, 0, function()
            service:ToggleOverlayEnabled()
            addon.MainFrame:RefreshCurrentView(true)
        end)
    end

    if activePane == "toons" or activePane == "season" then
        createButton(tabRow, service:IsShowingUnratedAlts() and "Hide Unrated" or "Show Unrated", 112, 24, "LEFT", leaderboardTab, "RIGHT", 18, 0, function()
            service:ToggleShowUnratedAlts()
            addon.MainFrame:RefreshCurrentView(true)
        end)

        createButton(tabRow, "Use Current Character", 144, 24, "LEFT", leaderboardTab, "RIGHT", 138, 0, function()
            service:SetSelectedToonId(addon.Store:GetPlayerName())
            addon.MainFrame:RefreshCurrentView(true)
        end)
    end

    if activePane == "leaderboard" then
        local leaderboardRows = service:ListLeaderboardRows()
        local leaderboardSummary = service:GetLeaderboardSummary()
        local knownPartyDataCount = 0

        for _, row in ipairs(service:ListPartyPlannerRows(nil, 10)) do
            if row.status ~= "No Data" then
                knownPartyDataCount = knownPartyDataCount + 1
            end
        end

        local leaderboardPanel = createPanel(host)
        leaderboardPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        leaderboardPanel:SetSize(918, 560)

        local leaderboardTitle = leaderboardPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        leaderboardTitle:SetPoint("TOPLEFT", leaderboardPanel, "TOPLEFT", 14, -14)
        leaderboardTitle:SetText("Mythic+ Leaderboard")
        styleTitleText(leaderboardTitle, 18)

        local leaderboardMeta = leaderboardPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        leaderboardMeta:SetPoint("TOPLEFT", leaderboardTitle, "BOTTOMLEFT", 0, -4)
        leaderboardMeta:SetWidth(874)
        leaderboardMeta:SetJustifyH("LEFT")
        leaderboardMeta:SetText("Current season only. GH3 ranks synced Mythic+ score and current keys from your stored guild and party data so officers can see who is ready to host, push, or fill a scheduled run.")
        styleBodyText(leaderboardMeta, true)

        createCard(leaderboardPanel, 156, 88, "Tracked Toons", tostring(leaderboardSummary and leaderboardSummary.rowCount or 0), "", 14, -62, { valueSize = 24, subFontSize = 11 })
        createCard(leaderboardPanel, 156, 88, "Top Score", tostring(leaderboardSummary and leaderboardSummary.topScore or 0), "", 186, -62, { valueSize = 24, subFontSize = 11 })
        createCard(leaderboardPanel, 156, 88, "Key Holders", tostring(leaderboardSummary and leaderboardSummary.keyHolders or 0), "", 358, -62, { valueSize = 24, subFontSize = 11 })
        createCard(leaderboardPanel, 156, 88, "Party Data", tostring(knownPartyDataCount or 0), "", 530, -62, { valueSize = 24, subFontSize = 11 })

        local leaderboardTablePanel = createPanel(leaderboardPanel)
        leaderboardTablePanel:SetPoint("TOPLEFT", leaderboardPanel, "TOPLEFT", 14, -166)
        leaderboardTablePanel:SetSize(890, 380)

        local leaderboardTableTitle = leaderboardTablePanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        leaderboardTableTitle:SetPoint("TOPLEFT", leaderboardTablePanel, "TOPLEFT", 14, -12)
        leaderboardTableTitle:SetText("Leaderboard")
        styleTitleText(leaderboardTableTitle, 16)

        local leaderboardTableMeta = leaderboardTablePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        leaderboardTableMeta:SetPoint("TOPLEFT", leaderboardTableTitle, "BOTTOMLEFT", 0, -4)
        leaderboardTableMeta:SetWidth(846)
        leaderboardTableMeta:SetJustifyH("LEFT")
        leaderboardTableMeta:SetText("This is Phase 3 groundwork: current-season score and key data now have a shared leaderboard view. Roster overlays and Groups planner reuse can sit on top of this next.")
        styleBodyText(leaderboardTableMeta, true)

        local leaderboardScrollFrame = CreateFrame("ScrollFrame", nil, leaderboardTablePanel, "UIPanelScrollFrameTemplate")
        leaderboardScrollFrame:SetPoint("TOPLEFT", leaderboardTablePanel, "TOPLEFT", 14, -48)
        leaderboardScrollFrame:SetPoint("BOTTOMRIGHT", leaderboardTablePanel, "BOTTOMRIGHT", -30, 14)

        local leaderboardContent = CreateFrame("Frame", nil, leaderboardScrollFrame)
        leaderboardContent:SetSize(846, math.max(150, 26 + (#leaderboardRows * 24)))
        leaderboardScrollFrame:SetScrollChild(leaderboardContent)

        if #leaderboardRows == 0 then
            local emptyLeaderboardText = leaderboardContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            emptyLeaderboardText:SetPoint("TOPLEFT", leaderboardContent, "TOPLEFT", 0, 0)
            emptyLeaderboardText:SetWidth(846)
            emptyLeaderboardText:SetJustifyH("LEFT")
            emptyLeaderboardText:SetText("No synced current-season Mythic+ profiles are available yet. Party or peer sync will populate this table as records arrive.")
            styleBodyText(emptyLeaderboardText, true)
        else
            local rows = {}

            for index, row in ipairs(leaderboardRows) do
                rows[#rows + 1] = {
                    values = {
                        tostring(index),
                        colorizeClassName(cropText(row.shortName or row.fullName or "Unknown", 20), row.classFile),
                        cropText(row.spec ~= "" and row.spec or "-", 14),
                        tostring(math.floor(tonumber(row.itemLevel) or 0)),
                        tostring(math.floor(tonumber(row.overallRating) or 0)),
                        cropText(row.currentKeyText or "No Key", 24),
                        cropText(row.guildName ~= "" and row.guildName or "Synced", 20),
                    },
                    selected = row.isPlayer,
                }
            end

            addon.TableView:Render(leaderboardContent, {
                headers = { "#", "Toon", "Spec", "iLvl", "Score", "Current Key", "Guild" },
                rows = rows,
                headerHeight = 22,
                rowHeight = 20,
                columnWidths = { 34, 150, 118, 58, 72, 244, 170 },
            })
        end

        return
    end

    if activePane == "season" then
        local toons = service:ListToons()
        selectedSummary = selectedToonId and service:GetToonSummary(selectedToonId) or nil
        local seasonOverviewSummary = selectedToonId and service:GetSeasonOverviewSummary(selectedToonId) or nil
        local seasonOverviewRows = selectedToonId and service:ListSeasonOverviewRows(selectedToonId) or {}

        local summaryPanel = createPanel(host)
        summaryPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        summaryPanel:SetSize(918, 150)

        local panelTitle = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        panelTitle:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 14, -14)
        panelTitle:SetText((seasonOverviewSummary and seasonOverviewSummary.seasonName or "Current Season") .. " Overview")
        styleTitleText(panelTitle, 18)

        local panelMeta = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        panelMeta:SetPoint("TOPLEFT", panelTitle, "BOTTOMLEFT", 0, -4)
        panelMeta:SetWidth(540)
        panelMeta:SetJustifyH("LEFT")
        panelMeta:SetText(selectedSummary and (selectedSummary.fullName .. " season progress and dungeon coverage. Use My Toons when you want the fuller per-toon planner and run table.") or "Select a tracked character to review season progress.")
        styleBodyText(panelMeta, true)

        local toonDropdown = createMenuButton(summaryPanel, selectedSummary and cropText(selectedSummary.fullName or "Selected Toon", 22) or "Select Toon", 220, 24, "TOPRIGHT", summaryPanel, "TOPRIGHT", -18, -14)
        toonDropdown:SetScript("OnClick", function(buttonSelf)
            showMenuButton(buttonSelf, function()
                local menuOptions = {}

                for _, toon in ipairs(toons) do
                    menuOptions[#menuOptions + 1] = {
                        text = string.format("%s | %d", colorizeClassName(cropText(toon.fullName or "Unknown", 20), toon.classFile), math.floor(tonumber(toon.overallRating) or 0)),
                        value = toon.id,
                        checked = selectedSummary and selectedSummary.id == toon.id,
                        onClick = function(value)
                            service:SetSelectedToonId(value)
                            addon.MainFrame:RefreshCurrentView(true)
                        end,
                    }
                end

                return menuOptions
            end)
        end)
        setMenuButtonText(toonDropdown, selectedSummary and cropText(selectedSummary.fullName or "Selected Toon", 22) or "Select Toon")

        createCard(summaryPanel, 142, 88, "Rating", seasonOverviewSummary and tostring(seasonOverviewSummary.overallRating or 0) or "0", "", 14, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Timed", seasonOverviewSummary and tostring(seasonOverviewSummary.timedCount or 0) or "0", "", 170, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Highest Key", seasonOverviewSummary and ((tonumber(seasonOverviewSummary.highestKey) or 0) > 0 and ("+" .. tostring(seasonOverviewSummary.highestKey)) or "-") or "-", "", 326, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Missing", seasonOverviewSummary and tostring(seasonOverviewSummary.missingCount or 0) or "0", "", 482, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Current Key", selectedSummary and selectedSummary.currentKeyShortText or "No Key", "", 638, -58, { valueSize = 15, subFontSize = 11 })

        local tilesPanel = createPanel(host)
        tilesPanel:SetPoint("TOPLEFT", summaryPanel, "BOTTOMLEFT", 0, -12)
        tilesPanel:SetSize(918, 398)

        local tilesTitle = tilesPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        tilesTitle:SetPoint("TOPLEFT", tilesPanel, "TOPLEFT", 14, -12)
        tilesTitle:SetText("Season Dungeon Pool")
        styleTitleText(tilesTitle, 16)

        local tilesMeta = tilesPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        tilesMeta:SetPoint("TOPLEFT", tilesTitle, "BOTTOMLEFT", 0, -4)
        tilesMeta:SetWidth(874)
        tilesMeta:SetJustifyH("LEFT")
        tilesMeta:SetText("")
        styleBodyText(tilesMeta, true)

        local tilesScrollFrame = CreateFrame("ScrollFrame", nil, tilesPanel, "UIPanelScrollFrameTemplate")
        tilesScrollFrame:SetPoint("TOPLEFT", tilesPanel, "TOPLEFT", 14, -46)
        tilesScrollFrame:SetPoint("BOTTOMRIGHT", tilesPanel, "BOTTOMRIGHT", -30, 14)

        local tilesContent = CreateFrame("Frame", nil, tilesScrollFrame)
        tilesContent:SetSize(862, math.max(180, math.ceil(math.max(#seasonOverviewRows, 1) / 5) * 124))
        tilesScrollFrame:SetScrollChild(tilesContent)

        for index, row in ipairs(seasonOverviewRows) do
            local tile = createPanel(tilesContent)
            local column = (index - 1) % 5
            local gridRow = math.floor((index - 1) / 5)
            tile:SetSize(166, 112)
            tile:SetPoint("TOPLEFT", tilesContent, "TOPLEFT", column * 174, -(gridRow * 118))

            local icon = tile:CreateTexture(nil, "ARTWORK")
            icon:SetSize(40, 40)
            icon:SetPoint("TOPLEFT", tile, "TOPLEFT", 10, -12)
            local texturePath = tryGetMapTexture(row.id)

            if texturePath then
                icon:SetTexture(texturePath)
            else
                icon:SetColorTexture(0.16, 0.2, 0.28, 0.95)
            end

            local name = tile:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            name:SetPoint("TOPLEFT", tile, "TOPLEFT", 56, -12)
            name:SetWidth(100)
            name:SetJustifyH("LEFT")
            name:SetText(cropText(row.shortName or row.name or "Dungeon", 20))
            styleHighlightText(name)

            local best = tile:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            best:SetPoint("TOPLEFT", tile, "TOPLEFT", 10, -58)
            best:SetWidth(146)
            best:SetJustifyH("LEFT")
            best:SetText(string.format("Best %s | %s", row.bestLabel or "-", row.timedLabel or "Missing"))
            styleBodyText(best, true)

            local score = tile:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            score:SetPoint("TOPLEFT", best, "BOTTOMLEFT", 0, -4)
            score:SetWidth(146)
            score:SetJustifyH("LEFT")
            score:SetText(string.format("Score %s | Portal %s", row.scoreLabel or "0", row.portalLabel or "No"))
            styleBodyText(score, true)
        end

        return
    end

    if activePane == "toons" then
        local toons = service:ListToons()
        selectedSummary = selectedToonId and service:GetToonSummary(selectedToonId) or nil
        planner = service:GetPlannerState(selectedToonId)
        local dungeonRows = selectedToonId and service:ListDungeonRows(selectedToonId, planner.targetKeyLevel) or {}
        local dungeonTableSummary = selectedToonId and service.GetDungeonTableSummary and service:GetDungeonTableSummary(selectedToonId, planner.targetKeyLevel) or nil
        local dungeonScoreGoalSummary = selectedToonId and service.GetDungeonTableScoreGoalSummary and service:GetDungeonTableScoreGoalSummary(selectedToonId, carryMilestone) or nil

        local listPanel = createPanel(host)
        listPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
        listPanel:SetSize(244, 560)

        local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -12)
        listTitle:SetText("My Mythic+ Toons")
        styleTitleText(listTitle, 16)

        local listMeta = listPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        listMeta:SetPoint("TOPLEFT", listTitle, "BOTTOMLEFT", 0, -4)
        listMeta:SetWidth(200)
        listMeta:SetJustifyH("LEFT")
        listMeta:SetText(service:IsShowingUnratedAlts() and "Showing all tracked characters." or "Showing rated or tracked characters.")
        styleBodyText(listMeta, true)

        local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
        listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -58)
        listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -30, 12)

        local listContent = CreateFrame("Frame", nil, listScrollFrame)
        listContent:SetSize(200, math.max(#toons * 28, 1))
        listScrollFrame:SetScrollChild(listContent)

        if #toons == 0 then
            local emptyText = listContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            emptyText:SetPoint("TOPLEFT", listContent, "TOPLEFT", 0, 0)
            emptyText:SetWidth(200)
            emptyText:SetJustifyH("LEFT")
            emptyText:SetText("No live Mythic+ data is visible yet. GH3 will keep trying to pull current rating, key, and season bests automatically.")
            styleBodyText(emptyText, true)
        end

        for index, toon in ipairs(toons) do
            local ratingText = string.format("%s | %d", colorizeClassName(cropText(toon.fullName or "Unknown", 18), toon.classFile), math.floor(tonumber(toon.overallRating) or 0))
            local button = createButton(listContent, ratingText, 200, 24, "TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 28), function()
                service:SetSelectedToonId(toon.id)
                addon.MainFrame:RefreshCurrentView(true)
            end)

            if toon.id == selectedToonId then
                setButtonTone(button, { 0.16, 0.2, 0.29, 0.98 }, { 0.82, 0.66, 0.24, 1 })
            end
        end

        local summaryPanel = createPanel(host)
        summaryPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 16, 0)
        summaryPanel:SetSize(656, 150)

        local panelTitle = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        panelTitle:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 14, -14)
        panelTitle:SetText(selectedSummary and selectedSummary.fullName or "Mythic+ Summary")
        styleTitleText(panelTitle, 18)

        local panelMeta = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        panelMeta:SetPoint("TOPLEFT", panelTitle, "BOTTOMLEFT", 0, -4)
        panelMeta:SetWidth(620)
        panelMeta:SetJustifyH("LEFT")
        panelMeta:SetText(selectedSummary and selectedSummary.metaLine or "Select a tracked character to review Mythic+ data.")
        styleBodyText(panelMeta, true)

        createCard(summaryPanel, 142, 88, "Rating", selectedSummary and selectedSummary.ratingText or "0", "", 14, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Current Key", selectedSummary and selectedSummary.currentKeyShortText or "No Key", "", 170, -58, { valueSize = 18, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Portals", selectedSummary and tostring(selectedSummary.portalCount or 0) or "0", "", 326, -58, { valueSize = 24, subFontSize = 11 })
        createCard(summaryPanel, 142, 88, "Tracked Runs", selectedSummary and tostring(selectedSummary.trackedDungeons or 0) or "0", "", 482, -58, { valueSize = 24, subFontSize = 11 })

        local dungeonPanel = createPanel(host)
        dungeonPanel:SetPoint("TOPLEFT", summaryPanel, "BOTTOMLEFT", 0, -12)
        dungeonPanel:SetSize(656, 398)

        local dungeonTitle = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        dungeonTitle:SetPoint("TOPLEFT", dungeonPanel, "TOPLEFT", 14, -12)
        dungeonTitle:SetText("Season Dungeons")
        styleTitleText(dungeonTitle, 16)

        local scoreGoalInput
        local keyLevelInput

        local applyButton = createButton(dungeonPanel, "Apply", 62, 24, "TOPRIGHT", dungeonPanel, "TOPRIGHT", -14, -12, function()
            service:SetCarryMilestone(scoreGoalInput:GetText())
            service:SetPlannerTargetLevel(keyLevelInput:GetText())
            addon.MainFrame:RefreshCurrentView(true)
        end)

        local keyLevelLabel = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        keyLevelLabel:SetPoint("RIGHT", applyButton, "LEFT", -76, 0)
        keyLevelLabel:SetText("Key Level")
        styleBodyText(keyLevelLabel, true)

        keyLevelInput = createInput(dungeonPanel, 64, 24, "LEFT", keyLevelLabel, "RIGHT", 8, -1)
        keyLevelInput:SetText(tostring(planner.targetKeyLevel or 10))

        local scoreGoalLabel = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        scoreGoalLabel:SetPoint("RIGHT", keyLevelLabel, "LEFT", -86, 0)
        scoreGoalLabel:SetText("Score Goal")
        styleBodyText(scoreGoalLabel, true)

        scoreGoalInput = createInput(dungeonPanel, 72, 24, "LEFT", scoreGoalLabel, "RIGHT", 8, -1)
        scoreGoalInput:SetText(tostring(carryMilestone or 3000))

        local dungeonMeta = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        dungeonMeta:SetPoint("TOPLEFT", dungeonTitle, "BOTTOMLEFT", 0, -4)
        dungeonMeta:SetWidth(610)
        dungeonMeta:SetJustifyH("LEFT")
        dungeonMeta:SetText(service:GetDungeonCatalogText(selectedToonId))
        styleBodyText(dungeonMeta, true)

        local dungeonTotalsMeta = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        dungeonTotalsMeta:SetPoint("TOPLEFT", dungeonMeta, "BOTTOMLEFT", 0, -4)
        dungeonTotalsMeta:SetWidth(610)
        dungeonTotalsMeta:SetJustifyH("LEFT")
        dungeonTotalsMeta:SetText(dungeonTableSummary and string.format(
            "Current total %d | Timing every shown dungeon at %d adds +%d for %d total.",
            tonumber(dungeonTableSummary.currentTotal) or 0,
            tonumber(dungeonTableSummary.targetLevel) or 10,
            tonumber(dungeonTableSummary.gainTotal) or 0,
            tonumber(dungeonTableSummary.projectedTotal) or 0
        ) or "")
        styleBodyText(dungeonTotalsMeta, true)

        local dungeonGoalMeta = dungeonPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        dungeonGoalMeta:SetPoint("TOPLEFT", dungeonTotalsMeta, "BOTTOMLEFT", 0, -4)
        dungeonGoalMeta:SetWidth(610)
        dungeonGoalMeta:SetJustifyH("LEFT")
        dungeonGoalMeta:SetText(dungeonScoreGoalSummary and dungeonScoreGoalSummary.summaryLine or "")
        styleBodyText(dungeonGoalMeta, true)

        local dungeonScrollFrame = CreateFrame("ScrollFrame", nil, dungeonPanel, "UIPanelScrollFrameTemplate")
        dungeonScrollFrame:SetPoint("TOPLEFT", dungeonPanel, "TOPLEFT", 14, -88)
        dungeonScrollFrame:SetPoint("BOTTOMRIGHT", dungeonPanel, "BOTTOMRIGHT", -30, 14)

        local dungeonContent = CreateFrame("Frame", nil, dungeonScrollFrame)
        dungeonContent:SetSize(610, math.max(150, 26 + ((#dungeonRows + 1) * 24)))
        dungeonScrollFrame:SetScrollChild(dungeonContent)

        if #dungeonRows == 0 then
            local emptyText = dungeonContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            emptyText:SetPoint("TOPLEFT", dungeonContent, "TOPLEFT", 0, 0)
            emptyText:SetWidth(610)
            emptyText:SetJustifyH("LEFT")
            emptyText:SetText("No season dungeon rows have been picked up yet. GH3 will keep attempting automatic live capture for your current character.")
            styleBodyText(emptyText, true)
            return
        end

        local rows = {}

        for _, row in ipairs(dungeonRows) do
            rows[#rows + 1] = {
                values = {
                    cropText(row.name, 24),
                    row.bestLabel,
                    row.scoreLabel,
                    row.gainLabel,
                    row.projectedLabel or row.scoreLabel,
                    row.portalLabel,
                },
            }
        end

        rows[#rows + 1] = {
            values = {
                "Total",
                dungeonScoreGoalSummary and dungeonScoreGoalSummary.recommendedKeyLevel and string.format("Need %ds", tonumber(dungeonScoreGoalSummary.recommendedKeyLevel) or 0) or "-",
                tostring(dungeonTableSummary and (tonumber(dungeonTableSummary.currentTotal) or 0) or 0),
                string.format("+%d", dungeonTableSummary and (tonumber(dungeonTableSummary.gainTotal) or 0) or 0),
                tostring(dungeonTableSummary and (tonumber(dungeonTableSummary.projectedTotal) or 0) or 0),
                "-",
            },
            selected = true,
        }

        addon.TableView:Render(dungeonContent, {
            headers = { "Dungeon", "Best", "Score", string.format("Gain %d", tonumber(planner.targetKeyLevel) or 10), "After", "Portal" },
            rows = rows,
            headerHeight = 26,
            rowHeight = 24,
            columnWidths = { 206, 82, 72, 72, 72, 96 },
        })
        return
    end

    local plannerToonId = selectedToonId or (addon.Store and addon.Store.GetPlayerName and addon.Store:GetPlayerName()) or nil
    selectedSummary = plannerToonId and service:GetToonSummary(plannerToonId) or nil
    planner = service:GetPlannerState(plannerToonId)
    local seasonOverviewRows = plannerToonId and service:ListSeasonOverviewRows(plannerToonId) or {}
    local dungeonOptions = planner.options or service:GetAvailableDungeonOptions(plannerToonId)
    local partyPlannerSummary = service:GetPartyPlannerSummary(planner.selectedDungeonId, planner.targetKeyLevel)
    local partyKeyRows = service:ListPartyKeystoneRows(planner.selectedDungeonId, planner.targetKeyLevel)
    local partyPlannerRows = service:ListPartyPlannerRows(planner.selectedDungeonId, planner.targetKeyLevel)

    if #seasonOverviewRows == 0 then
        local seenPortalRows = {}

        for _, keyRow in ipairs(partyKeyRows) do
            if keyRow.dungeonId and not seenPortalRows[keyRow.dungeonId] then
                seenPortalRows[keyRow.dungeonId] = true
                local hasPortalSpell = service.GetPortalSpellId and service:GetPortalSpellId(keyRow.dungeonId, keyRow.dungeonName) ~= nil
                seasonOverviewRows[#seasonOverviewRows + 1] = {
                    bestLabel = "-",
                    id = keyRow.dungeonId,
                    name = keyRow.dungeonName or keyRow.key or "Dungeon",
                    portalLabel = hasPortalSpell and "Yes" or "No",
                    scoreLabel = "0",
                    shortName = service.GetDungeonShortLabel and service:GetDungeonShortLabel(keyRow.dungeonId, keyRow.dungeonName) or nil,
                    timedLabel = "Missing",
                }
            end
        end
    end

    local selectedCarryTargetId = service:GetSelectedCarryTargetId()
    local partyKeyGuide = service.GetPartyKeyLevelGuide and service:GetPartyKeyLevelGuide() or nil
    local bestPartyTarget = analysisMode == "party-gains" and service:GetBestKnownPartyTarget(plannerToonId, planner.targetKeyLevel) or nil
    local bestPartyGoalRecommendation = analysisMode ~= "party-gains" and service.GetBestPartyGoalRecommendation and service:GetBestPartyGoalRecommendation(selectedCarryTargetId, analysisMode, carryMilestone, carryKeyGoal) or nil

    local partyPanel = createPanel(host)
    partyPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -46)
    partyPanel:SetSize(788, 578)

    local portalRail = createPanel(host)
    portalRail:SetPoint("TOPRIGHT", host, "TOPRIGHT", -18, 0)
    portalRail:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", -18, 0)
    portalRail:SetWidth(132)

    local partyTitle = partyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    partyTitle:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -10)
    partyTitle:SetText("Party Planning")
    styleTitleText(partyTitle, 18)

    local selectorRow = CreateFrame("Frame", nil, partyPanel)
    selectorRow:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -42)
    selectorRow:SetSize(420, 32)

    local selectorColumnOne = CreateFrame("Frame", nil, selectorRow)
    selectorColumnOne:SetPoint("TOPLEFT", selectorRow, "TOPLEFT", 0, 0)
    selectorColumnOne:SetSize(232, 32)

    local selectorColumnTwo = CreateFrame("Frame", nil, selectorRow)
    selectorColumnTwo:SetPoint("TOPLEFT", selectorRow, "TOPLEFT", 246, 2)
    selectorColumnTwo:SetSize(78, 24)

    local selectorColumnThree = CreateFrame("Frame", nil, selectorRow)
    selectorColumnThree:SetPoint("TOPLEFT", selectorRow, "TOPLEFT", 338, 2)
    selectorColumnThree:SetSize(76, 24)

    local controlBlock = CreateFrame("Frame", nil, partyPanel)
    controlBlock:SetPoint("TOPRIGHT", partyPanel, "TOPRIGHT", 9, -10)
    controlBlock:SetSize(360, 46)

    local gainsModeButton = createButton(controlBlock, analysisMode == "party-gains" and "Party Gains *" or "Party Gains", 98, 22, "TOPLEFT", controlBlock, "TOPLEFT", 0, 0, function()
        service:SetAnalysisMode("party-gains")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local scoreModeButton = createButton(controlBlock, analysisMode == "score-goal" and "Score Goal *" or "Score Goal", 98, 22, "LEFT", gainsModeButton, "RIGHT", 8, 0, function()
        service:SetAnalysisMode("score-goal")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local portalModeButton = createButton(controlBlock, analysisMode == "key-goal" and "Portal Goal *" or "Portal Goal", 98, 22, "LEFT", scoreModeButton, "RIGHT", 8, 0, function()
        service:SetAnalysisMode("key-goal")
        addon.MainFrame:RefreshCurrentView(true)
    end)

    local partyGoalInput = createInput(controlBlock, 64, 22, "TOPLEFT", controlBlock, "TOPLEFT", 0, -26)
    partyGoalInput:SetText(tostring(analysisMode == "key-goal" and carryKeyGoal or carryMilestone))

    local partyGoalApply = createButton(controlBlock, "Set", 48, 22, "LEFT", partyGoalInput, "RIGHT", 8, 0, function()
        if analysisMode == "key-goal" then
            service:SetCarryKeyGoal(partyGoalInput:GetText())
        else
            service:SetCarryMilestone(partyGoalInput:GetText())
        end

        addon.MainFrame:RefreshCurrentView(true)
    end)

    local partyGoalLabel = controlBlock:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    partyGoalLabel:SetPoint("LEFT", partyGoalApply, "RIGHT", 10, 0)
    partyGoalLabel:SetWidth(226)
    partyGoalLabel:SetJustifyH("LEFT")
    partyGoalLabel:SetText(
        analysisMode == "key-goal"
            and string.format("Goal key: %d. Portal mode highlights current party keys that can advance that threshold.", tonumber(carryKeyGoal) or 10)
            or (analysisMode == "score-goal"
                and string.format("Score goal: %d for the selected player.", tonumber(carryMilestone) or 2500)
                or (partyKeyGuide and partyKeyGuide.summaryLine or "Party gear guidance unavailable."))
    )
    styleBodyText(partyGoalLabel, true)

    local dungeonDropdown = createMenuButton(selectorColumnOne, planner.selectedDungeonName or "Select Dungeon", 232, 24, "TOPLEFT", selectorColumnOne, "TOPLEFT", 0, -2)
    dungeonDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(dungeonOptions) do
                menuOptions[#menuOptions + 1] = {
                    text = option.name,
                    value = option.id,
                    checked = planner.selectedDungeonId == option.id,
                    onClick = function(value)
                        service:SetSelectedPlannerDungeonId(value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(dungeonDropdown, planner.selectedDungeonName or "Select Dungeon")

    local keyLevelInput = createInput(selectorColumnTwo, 78, 24, "TOPLEFT", selectorColumnTwo, "TOPLEFT", 0, -1)
    keyLevelInput:SetText(tostring(planner.targetKeyLevel or 10))

    createButton(selectorColumnThree, "Apply", 76, 24, "TOPLEFT", selectorColumnThree, "TOPLEFT", 0, -1, function()
        service:SetPlannerTargetLevel(keyLevelInput:GetText())
        addon.MainFrame:RefreshCurrentView(true)
    end)

    createCard(partyPanel, 240, 94, "Party Size", tostring(#partyPlannerRows), "", 14, -84)
    createCard(
        partyPanel,
        240,
        94,
        "Target",
        planner.selectedDungeonName and ((planner.selectedDungeonShortName or planner.selectedDungeonName) .. " +" .. tostring(planner.targetKeyLevel or 10)) or "Pick a dungeon",
        "",
        274,
        -84,
        { valueSize = 16 }
    )
    createCard(
        partyPanel,
        240,
        94,
        analysisMode == "party-gains" and "Best Party Target" or "Recommended Run",
        analysisMode == "party-gains"
            and (bestPartyTarget and ((bestPartyTarget.shortName or bestPartyTarget.name) .. " +" .. tostring(bestPartyTarget.targetLevel)) or "Unknown")
            or (bestPartyGoalRecommendation and cropText(string.format("%s | %s", bestPartyGoalRecommendation.holderName or "Unknown", bestPartyGoalRecommendation.keyLabel or "No Key"), 24) or "No fit yet"),
        analysisMode == "party-gains"
            and ""
            or (partyKeyGuide and string.format("Party cap about +%d", tonumber(partyKeyGuide.recommendedLevel) or 10) or ""),
        534,
        -84,
        { valueSize = 16, subFontSize = 11 }
    )

    local summaryPanel = createPanel(partyPanel)
    summaryPanel:SetPoint("TOPLEFT", partyPanel, "TOPLEFT", 14, -184)
    summaryPanel:SetSize(760, 206)

    local summaryTitle = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    summaryTitle:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 12, -12)
    summaryTitle:SetText("Party Summary")
    styleTitleText(summaryTitle, 16)

    local summaryText = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryText:SetPoint("TOPLEFT", summaryTitle, "BOTTOMLEFT", 0, -6)
    summaryText:SetWidth(724)
    summaryText:SetJustifyH("LEFT")
    summaryText:SetText(
        analysisMode == "party-gains"
            and (partyPlannerSummary ~= "" and partyPlannerSummary or "Solo view active.")
            or (bestPartyGoalRecommendation
                and string.format(
                    "%s looks like the best current party key. %s",
                    bestPartyGoalRecommendation.keyLabel or "No Key",
                    bestPartyGoalRecommendation.note or ""
                )
                or (selectedCarryTargetId and "No current party key cleanly fits the selected goal yet." or "Select a player in Member Breakdown to get party goal suggestions."))
    )
    styleBodyText(summaryText)

    local summaryGuide = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    summaryGuide:SetPoint("TOPLEFT", summaryText, "BOTTOMLEFT", 0, -8)
    summaryGuide:SetWidth(724)
    summaryGuide:SetJustifyH("LEFT")
    summaryGuide:SetText(partyKeyGuide and (partyKeyGuide.summaryLine .. " Each keystone row keeps the held key visible and shows projected score gain for the selected key underneath.") or "Click a party member's keystone to load that run into the planner. Each keystone row keeps the held key visible and shows projected score gain for the selected key underneath. Shift-click an unguilded party member to send a guild invite.")
    styleBodyText(summaryGuide, true)

    local summaryKeysHeader = CreateFrame("Frame", nil, summaryPanel)
    summaryKeysHeader:SetPoint("TOPLEFT", summaryGuide, "BOTTOMLEFT", 0, -10)
    summaryKeysHeader:SetSize(720, 22)

    local summaryKeysScrollFrame = CreateFrame("ScrollFrame", nil, summaryPanel, "UIPanelScrollFrameTemplate")
    summaryKeysScrollFrame:SetPoint("TOPLEFT", summaryKeysHeader, "BOTTOMLEFT", 0, 0)
    summaryKeysScrollFrame:SetPoint("BOTTOMRIGHT", summaryPanel, "BOTTOMRIGHT", -26, 10)
    self.mythicsSummaryKeysScrollFrame = summaryKeysScrollFrame

    local summaryKeysContent = CreateFrame("Frame", nil, summaryKeysScrollFrame)
    summaryKeysContent:SetSize(720, math.max(60, #partyKeyRows * 36))
    summaryKeysScrollFrame:SetScrollChild(summaryKeysContent)

    local selectablePartyKeys = 0

    for _, row in ipairs(partyKeyRows) do
        if row.dungeonId and (tonumber(row.keyLevel) or 0) > 0 then
            selectablePartyKeys = selectablePartyKeys + 1
        end
    end

    if #partyKeyRows <= 0 then
        local emptySummaryText = summaryKeysContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptySummaryText:SetPoint("TOPLEFT", summaryKeysContent, "TOPLEFT", 0, 0)
        emptySummaryText:SetWidth(720)
        emptySummaryText:SetJustifyH("LEFT")
        emptySummaryText:SetText("No current party data is available yet. When GH3 sees your party members, their keys and tracked status will appear here for one-click selection.")
        styleBodyText(emptySummaryText, true)
    else
        local rows = {}

        for _, row in ipairs(partyKeyRows) do
            rows[#rows + 1] = {
                values = {
                    colorizeClassName(cropText(row.name, 22), row.classFile),
                    row.roleLabel or "-",
                    (tonumber(row.itemLevel) or 0) > 0 and tostring(math.floor(tonumber(row.itemLevel) or 0)) or "-",
                    string.format("%s\nGain %s", cropText(row.key, 32), row.selectedGainLabel or "-"),
                    row.state,
                },
                selected = row.fullName == selectedCarryTargetId or row.selected,
                tooltipTitle = row.name,
                tooltipLines = row.guildInviteEligible and {
                    "Shift-click to send a guild invite.",
                    "Normal click loads this party member's key and focuses them for gain planning.",
                } or {
                    "Click to focus this player for gain planning.",
                    "Shift-click attempts a guild invite and reports why it cannot be sent.",
                },
                onClick = function()
                    if IsShiftKeyDown and IsShiftKeyDown() then
                        local ok, message = service.SendGuildInviteToPartyMember and service:SendGuildInviteToPartyMember(row.fullName)
                        addon:Print(message or (ok and "Guild invite sent." or "Unable to send guild invite."))
                        return
                    end

                    service:SetSelectedCarryTargetId(row.fullName)
                    if row.dungeonId and (tonumber(row.keyLevel) or 0) > 0 then
                        service:SetSelectedPlannerDungeonId(row.dungeonId)
                        service:SetPlannerTargetLevel(row.keyLevel)
                    end
                    if row.isPlayer and service.TrySlotCurrentKeystoneForMember then
                        service:TrySlotCurrentKeystoneForMember(row.fullName)
                    end
                    addon.MainFrame:RefreshCurrentView(true)
                end,
            }
        end

        addon.TableView:Render(summaryKeysContent, {
            headers = { "Player", "Role", "iLvl", "Keystone", "State" },
            rows = rows,
            headerParent = summaryKeysHeader,
            headerHeight = 22,
            rowHeight = 36,
            columnWidths = { 170, 76, 64, 248, 132 },
            wordWrap = true,
        })
    end

    local membersPanel = createPanel(partyPanel)
    membersPanel:SetPoint("TOPLEFT", summaryPanel, "BOTTOMLEFT", 0, -12)
    membersPanel:SetSize(760, 164)

    local membersTitle = membersPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    membersTitle:SetPoint("TOPLEFT", membersPanel, "TOPLEFT", 12, -12)
    membersTitle:SetText("Member Breakdown")
    styleTitleText(membersTitle, 16)
    local seasonDungeonCount = #seasonOverviewRows

    local membersMeta = membersPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    membersMeta:SetPoint("TOPLEFT", membersPanel, "TOPLEFT", 12, -34)
    membersMeta:SetWidth(724)
    membersMeta:SetJustifyH("LEFT")
    membersMeta:SetText(
        analysisMode == "score-goal"
            and string.format("Each dungeon shows the lowest helpful key toward %d for the selected player mode.", tonumber(carryMilestone) or 2500)
            or (analysisMode == "key-goal"
                and string.format("Each dungeon shows whether the player has met the +%d key goal or their current best.", tonumber(carryKeyGoal) or 10)
                or string.format("Each dungeon keeps the saved best key on the first line and shows score with projected gain at +%d underneath.", tonumber(planner.targetKeyLevel) or 2))
    )
    styleBodyText(membersMeta, true)

    local membersHeader = CreateFrame("Frame", nil, membersPanel)
    membersHeader:SetPoint("TOPLEFT", membersPanel, "TOPLEFT", 12, -54)
    membersHeader:SetSize(720, 22)

    local membersScrollFrame = CreateFrame("ScrollFrame", nil, membersPanel, "UIPanelScrollFrameTemplate")
    membersScrollFrame:SetPoint("TOPLEFT", membersHeader, "BOTTOMLEFT", 0, 0)
    membersScrollFrame:SetPoint("BOTTOMRIGHT", membersPanel, "BOTTOMRIGHT", -26, 10)
    self.mythicsMembersScrollFrame = membersScrollFrame

    local matrixHeaders = { "Player", "Score" }
    local matrixColumnWidths = { 132, 72 }

    for _, dungeonRow in ipairs(seasonOverviewRows) do
        matrixHeaders[#matrixHeaders + 1] = dungeonRow.shortName or getPortalShortLabel(dungeonRow)
    end

    if seasonDungeonCount > 0 then
        local fixedWidth = matrixColumnWidths[1] + matrixColumnWidths[2]
        local dungeonColumnWidth = math.max(58, math.floor((720 - fixedWidth) / seasonDungeonCount))

        for _ = 1, seasonDungeonCount do
            matrixColumnWidths[#matrixColumnWidths + 1] = dungeonColumnWidth
        end
    end

    local membersContent = CreateFrame("Frame", nil, membersScrollFrame)
    membersContent:SetSize(720, math.max(128, #partyPlannerRows * 36))
    membersScrollFrame:SetScrollChild(membersContent)

    if #partyPlannerRows == 0 then
        local emptyPartyText = membersContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptyPartyText:SetPoint("TOPLEFT", membersContent, "TOPLEFT", 0, 0)
        emptyPartyText:SetWidth(720)
        emptyPartyText:SetJustifyH("LEFT")
        emptyPartyText:SetText("No party data is available yet. Join a party and let Mythic+ sync populate the comparison table.")
        styleBodyText(emptyPartyText, true)
    elseif seasonDungeonCount == 0 then
        local emptySeasonText = membersContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        emptySeasonText:SetPoint("TOPLEFT", membersContent, "TOPLEFT", 0, 0)
        emptySeasonText:SetWidth(720)
        emptySeasonText:SetJustifyH("LEFT")
        emptySeasonText:SetText("No current-season dungeon catalog is available yet for Member Breakdown.")
        styleBodyText(emptySeasonText, true)
    else
        local rows = {}

        for _, row in ipairs(partyPlannerRows) do
            local values = {
                colorizeClassName(cropText(row.name, 16), row.classFile),
                row.rating or "0",
            }

            for _, dungeonRow in ipairs(seasonOverviewRows) do
                values[#values + 1] = getPartyDungeonDetailLabel(row.fullName, dungeonRow.id)
            end

            rows[#rows + 1] = {
                values = values,
                selected = row.fullName == selectedCarryTargetId,
                onClick = function()
                    service:SetSelectedCarryTargetId(row.fullName)
                    addon.MainFrame:RefreshCurrentView(true)
                end,
            }
        end

        addon.TableView:Render(membersContent, {
            headers = matrixHeaders,
            rows = rows,
            headerParent = membersHeader,
            headerHeight = 22,
            rowHeight = 36,
            columnWidths = matrixColumnWidths,
            wordWrap = true,
        })
    end

    local railTitle = portalRail:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    railTitle:SetPoint("TOPLEFT", portalRail, "TOPLEFT", 10, -10)
    railTitle:SetWidth(104)
    railTitle:SetJustifyH("CENTER")
    railTitle:SetText("Portals")
    styleTitleText(railTitle, 15)

    local railMeta = portalRail:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    railMeta:SetPoint("TOPLEFT", railTitle, "BOTTOMLEFT", 0, -4)
    railMeta:SetWidth(104)
    railMeta:SetJustifyH("CENTER")
    railMeta:SetText(selectedSummary and cropText(selectedSummary.fullName or "", 16) or "Toon")
    styleBodyText(railMeta, true)

    local portalRailContentHeight = math.max(124, 4 + (#seasonOverviewRows * 70))
    local railContent = nil

    if #seasonOverviewRows <= 8 then
        railContent = CreateFrame("Frame", nil, portalRail)
        railContent:SetPoint("TOPLEFT", portalRail, "TOPLEFT", 8, -54)
        railContent:SetSize(104, portalRailContentHeight)
        railContent:SetFrameLevel((portalRail:GetFrameLevel() or 0) + 2)
    else
        local railScrollFrame = CreateFrame("ScrollFrame", nil, portalRail, "UIPanelScrollFrameTemplate")
        railScrollFrame:SetPoint("TOPLEFT", portalRail, "TOPLEFT", 8, -54)
        railScrollFrame:SetPoint("BOTTOMRIGHT", portalRail, "BOTTOMRIGHT", -18, 10)
        railScrollFrame:SetFrameLevel((portalRail:GetFrameLevel() or 0) + 2)
        self.mythicsPortalRailScrollFrame = railScrollFrame

        railContent = CreateFrame("Frame", nil, railScrollFrame)
        railContent:SetSize(104, portalRailContentHeight)
        railContent:SetFrameLevel((railScrollFrame:GetFrameLevel() or 0) + 1)
        railScrollFrame:SetScrollChild(railContent)
    end

    for index, row in ipairs(seasonOverviewRows) do
        renderPortalBadge(railContent, row, index, {
            cardWidth = 104,
            cardHeight = 66,
            columns = 1,
            horizontalGap = 0,
            iconSize = 42,
            insetY = 4,
            showName = true,
            verticalGap = 4,
        })
    end

    self:RestoreMythicsScrollState()
end

function addon.MainFrame:RenderBIS()
    local host = self:GetViewHost()
    local service = addon.BISService

    addon.RosterService:RefreshLivePlayerMember()
    service:EnsureCurrentInventoryScan()

    local toons = service:ListToons()
    local selectedToonId = service:GetSelectedToonId()
    local selectedSpecId = service:GetSelectedSpecId(selectedToonId)
    service:BackfillJournalDataForSpec(selectedToonId, selectedSpecId, { allowJournalBuild = false })
    local summary = service:GetSummary(selectedToonId)
    local gearRows = service:ListGearRows(selectedToonId)
    local specOptions = service:ListSpecOptions(selectedToonId)
    local detail = service:GetSelectedEntryDetails(selectedToonId)
    local entryRows = service:ListSlotEntryRows(selectedToonId, detail and detail.slotKey or service:GetSelectedSlotKey())
    local sourceOptions = service:GetSourceTypeOptions()
    local ownershipOptions = service:GetOwnershipOptions()
    local collectionSortOptions = service:GetCollectionSortOptions()
    local priorityOptions = service:GetPriorityOptions()
    local statGoalSummary = service:GetStatGoalSummary(selectedToonId, selectedSpecId)

    createSectionTitle(host, "BIS", 18, -18)

    local listPanel = createPanel(host)
    listPanel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    listPanel:SetSize(244, 560)

    local listTitle = listPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    listTitle:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -12)
    listTitle:SetText("Tracked Toons")
    styleTitleText(listTitle, 16)

    local listMeta = listPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    listMeta:SetPoint("TOPLEFT", listTitle, "BOTTOMLEFT", 0, -4)
    listMeta:SetWidth(200)
    listMeta:SetJustifyH("LEFT")
    listMeta:SetText("Personal BIS lists are stored per toon and spec.")
    styleBodyText(listMeta, true)

    local listScrollFrame = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 12, -58)
    listScrollFrame:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -30, 12)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetSize(200, math.max(#toons * 28, 1))
    listScrollFrame:SetScrollChild(listContent)

    for index, toon in ipairs(toons) do
        local buttonLabel = string.format("%s | %d/%d", cropText(toon.fullName or "Unknown", 16), tonumber(toon.collectedCount or 0), tonumber(toon.totalSlots or 0))
        local button = createButton(listContent, buttonLabel, 200, 24, "TOPLEFT", listContent, "TOPLEFT", 0, -((index - 1) * 28), function()
            service:SetSelectedToonId(toon.id)
            addon.MainFrame:RefreshCurrentView(true)
        end)

        if toon.id == selectedToonId then
            setButtonTone(button, { 0.16, 0.2, 0.29, 0.98 }, { 0.82, 0.66, 0.24, 1 })
        end
    end

    local summaryPanel = createPanel(host)
    summaryPanel:SetPoint("TOPLEFT", listPanel, "TOPRIGHT", 16, 0)
    summaryPanel:SetSize(656, 278)

    local summaryTitle = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    summaryTitle:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 14, -14)
    summaryTitle:SetText(summary.fullName or "BIS Summary")
    styleTitleText(summaryTitle, 18)

    local summaryMeta = summaryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    summaryMeta:SetPoint("TOPLEFT", summaryTitle, "BOTTOMLEFT", 0, -4)
    summaryMeta:SetWidth(620)
    summaryMeta:SetJustifyH("LEFT")
    summaryMeta:SetText(summary.metaLine or "Track one BIS list per toon and spec.")
    styleBodyText(summaryMeta, true)

    local summaryButtonRow = CreateFrame("Frame", nil, host)
    summaryButtonRow:SetPoint("TOPRIGHT", host, "TOPRIGHT", -18, -18)
    summaryButtonRow:SetSize(576, 24)

    local specDropdown = createMenuButton(summaryButtonRow, "Spec: " .. tostring(summary.specLabel or selectedSpecId or "Unknown"), 146, 24, "TOPLEFT", summaryButtonRow, "TOPLEFT", 0, 0)
    specDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(specOptions or {}) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = option.id == selectedSpecId,
                    onClick = function(value)
                        if service:SetSelectedSpecId(selectedToonId, value) ~= selectedSpecId then
                            addon.MainFrame:RefreshCurrentView(true)
                        end
                    end,
                }
            end

            if #menuOptions <= 0 then
                menuOptions[#menuOptions + 1] = {
                    text = "No specs available",
                    value = "",
                    disabled = true,
                    notCheckable = true,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(specDropdown, "Spec: " .. tostring(summary.specLabel or selectedSpecId or "Unknown"))
    specDropdown:SetEnabled(#(specOptions or {}) > 1)

    local lootButton = createButton(summaryButtonRow, "Loot", 64, 24, "LEFT", specDropdown, "RIGHT", 8, 0, function()
        addon.MainFrame:ShowBISLootHelperDialog("GH3 Loot Helper")
    end)

    local shareButton = createButton(summaryButtonRow, "Share", 68, 24, "LEFT", lootButton, "RIGHT", 8, 0, function()
        addon.MainFrame:ShowCsvExportDialog(
            "BIS Share JSON",
            service:BuildShareExport(selectedToonId, selectedSpecId),
            "Copy this JSON to your guild site or share it with another GH3 player. Import accepts this same payload back into GH3 and still supports the older BIS CSV rows."
        )
    end)

    local importButton = createButton(summaryButtonRow, "Import", 72, 24, "LEFT", shareButton, "RIGHT", 8, 0, function()
        addon.MainFrame:ShowTextImportDialog(
            "BIS Import",
            "",
            function(importText)
                local ok, _, message, reportText = service:ImportText(selectedToonId, importText)
                local shouldShowReport = (reportText or ""):match("duplicateRows=[1-9]")
                    or (reportText or ""):match("invalidSlotRows=[1-9]")
                    or (reportText or ""):match("emptyOrUnusableRows=[1-9]")
                    or (reportText or ""):match("nameOnlyRows=[1-9]")
                    or (reportText or ""):match("unresolvedRows=[1-9]")
                    or (reportText or ""):match("duplicateItems=[1-9]")
                    or (reportText or ""):match("invalidItems=[1-9]")

                addon:Print(message or (ok and "Imported BIS data." or "Unable to import BIS data."))

                if ok then
                    addon.MainFrame:RefreshCurrentView(true)
                end

                if shouldShowReport then
                    addon.MainFrame:ShowCsvExportDialog("BIS Import Report", reportText)
                end
            end,
            "Paste a GH3 BIS share JSON payload to restore spec metadata, stat goals, and detailed item rows. Legacy BIS CSV rows still work here too."
        )
    end)

    local gearSelectButton = createButton(summaryButtonRow, "Gear Select", 96, 24, "LEFT", importButton, "RIGHT", 8, 0, function()
        addon.MainFrame:ShowBISGearSelectorDialog("BIS Gear Select", selectedToonId)
    end)

    createButton(summaryButtonRow, "Stat Goals", 82, 24, "LEFT", gearSelectButton, "RIGHT", 8, 0, function()
        addon.MainFrame:ShowBISStatGoalsDialog("BIS Stat Goals", statGoalSummary.rows or {}, function(payload)
            local changed = service:UpdateStatTargets(selectedToonId, selectedSpecId, payload)
            local message = changed and "Updated BIS stat goals." or "BIS stat goals already match the saved values."
            addon:Print(message)

            addon.MainFrame:RefreshCurrentView(true)
        end)
    end)

    createCard(summaryPanel, 118, 88, "Spec", summary.specLabel or "Unknown", "Active BIS list", 14, -58, { valueSize = 17, subFontSize = 11 })
    createCard(summaryPanel, 118, 88, "Loot Spec", summary.lootSpecLabel or "Default", "Tracked drop context", 142, -58, { valueSize = 16, subFontSize = 11 })
    createCard(summaryPanel, 118, 88, "Collected", tostring(summary.collectedCount or 0), "Owned BIS pieces", 270, -58, { valueSize = 24, subFontSize = 11 })
    createCard(summaryPanel, 118, 88, "Missing", tostring(summary.missingCount or 0), "Still unowned", 398, -58, { valueSize = 24, subFontSize = 11 })
    createCard(summaryPanel, 118, 88, "Vault", tostring(summary.vaultTargetCount or 0), "Vault-marked targets", 526, -58, { valueSize = 24, subFontSize = 11 })

    local statPanel = createPanel(summaryPanel)
    statPanel:SetPoint("TOPLEFT", summaryPanel, "TOPLEFT", 14, -152)
    statPanel:SetSize(628, 114)

    for index, row in ipairs(statGoalSummary.rows or {}) do
        local column = (index - 1) % 4
        local statRow = math.floor((index - 1) / 4)
        local statCard = createPanel(statPanel)
        statCard:SetPoint("TOPLEFT", statPanel, "TOPLEFT", column * 158, -8 - (statRow * 52))
        statCard:SetSize(150, 46)

        local titleText = statCard:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        titleText:SetPoint("TOPLEFT", statCard, "TOPLEFT", 10, -7)
        titleText:SetText(row.label or "Stat")
        styleTitleText(titleText, 13)

        local valueText = statCard:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        valueText:SetPoint("TOPLEFT", statCard, "TOPLEFT", 10, -23)
        valueText:SetText(row.currentLabel or "-")
        styleTitleText(valueText, 15)

        local goalText = statCard:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        goalText:SetPoint("TOPRIGHT", statCard, "TOPRIGHT", -10, -10)
        goalText:SetJustifyH("RIGHT")
        goalText:SetText(string.format("Goal %s", row.targetLabel or "-"))
        styleBodyText(goalText, true)

        local deltaText = statCard:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        deltaText:SetPoint("TOPRIGHT", statCard, "TOPRIGHT", -10, -25)
        deltaText:SetJustifyH("RIGHT")
        deltaText:SetText(string.format("Delta %s", row.deltaLabel or "-"))
        styleBodyText(deltaText, true)
    end

    local gearPanel = createPanel(host)
    gearPanel:SetPoint("TOPLEFT", summaryPanel, "BOTTOMLEFT", 0, -12)
    gearPanel:SetSize(656, 290)

    local gearTitle = gearPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    gearTitle:SetPoint("TOPLEFT", gearPanel, "TOPLEFT", 14, -12)
    gearTitle:SetText("Gear Table")
    styleTitleText(gearTitle, 16)

    createButton(gearPanel, "?", 22, 20, "LEFT", gearTitle, "RIGHT", 8, -1, function()
        addon.MainFrame:OpenHelpArticle("bis")
    end)

    local gearMeta = gearPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    gearMeta:SetPoint("TOPLEFT", gearTitle, "BOTTOMLEFT", 0, -4)
    gearMeta:SetWidth(624)
    gearMeta:SetJustifyH("LEFT")
    gearMeta:SetText("Pick a slot to review overall BIS progress. Use Gear Select for full target editing, journal-backed item selection, and alternate targets for each slot.")
    styleBodyText(gearMeta, true)

    local sourceDropdown = createMenuButton(gearPanel, service:GetSourceFilterLabel(), 146, 24, "TOPLEFT", gearPanel, "TOPLEFT", 14, -58)
    sourceDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(sourceOptions) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = service:GetSourceFilter() == option.id,
                    onClick = function(value)
                        service:SetSourceFilter(value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(sourceDropdown, service:GetSourceFilterLabel())

    local ownershipDropdown = createMenuButton(gearPanel, service:GetOwnershipFilterLabel(), 146, 24, "LEFT", sourceDropdown, "RIGHT", 10, 0)
    ownershipDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(ownershipOptions) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = service:GetOwnershipFilter() == option.id,
                    onClick = function(value)
                        service:SetOwnershipFilter(value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(ownershipDropdown, service:GetOwnershipFilterLabel())

    local collectionSortDropdown = createMenuButton(gearPanel, service:GetCollectionSortLabel(), 146, 24, "LEFT", ownershipDropdown, "RIGHT", 10, 0)
    collectionSortDropdown:SetScript("OnClick", function(buttonSelf)
        showMenuButton(buttonSelf, function()
            local menuOptions = {}

            for _, option in ipairs(collectionSortOptions) do
                menuOptions[#menuOptions + 1] = {
                    text = option.label,
                    value = option.id,
                    checked = service:GetCollectionSort() == option.id,
                    onClick = function(value)
                        service:SetCollectionSort(value)
                        addon.MainFrame:RefreshCurrentView(true)
                    end,
                }
            end

            return menuOptions
        end)
    end)
    setMenuButtonText(collectionSortDropdown, service:GetCollectionSortLabel())

    local gearScrollFrame = CreateFrame("ScrollFrame", nil, gearPanel, "UIPanelScrollFrameTemplate")
    gearScrollFrame:SetPoint("TOPLEFT", gearPanel, "TOPLEFT", 14, -96)
    gearScrollFrame:SetPoint("BOTTOMRIGHT", gearPanel, "BOTTOMRIGHT", -30, 14)

    local gearContent = CreateFrame("Frame", nil, gearScrollFrame)
    gearContent:SetSize(610, math.max(150, 26 + (#gearRows * 24)))
    gearScrollFrame:SetScrollChild(gearContent)

    local tableRows = {}

    for _, row in ipairs(gearRows) do
        local visual = BIS_ROW_VISUALS[row.rowVisualState or "missing"]
        local tooltipLines = {}

        if tostring(row.notes or "") ~= "" then
            tooltipLines[#tooltipLines + 1] = "Notes: " .. tostring(row.notes)
        end

        if #tooltipLines <= 0 then
            tooltipLines[#tooltipLines + 1] = "No notes for this slot."
        end

        tableRows[#tableRows + 1] = {
            values = {
                row.slotLabel,
                row.itemName ~= "" and row.itemName or "-",
                row.sourceTypeLabel,
                cropText(row.instanceLabel ~= "" and row.instanceLabel or "-", 15),
                cropText(row.bossLabel ~= "" and row.bossLabel or "-", 14),
                row.stateLabel,
            },
            baseColor = visual and visual.baseColor or nil,
            borderColor = visual and visual.borderColor or nil,
            selected = detail and row.slotKey == detail.slotKey,
            selectedBaseColor = visual and visual.selectedBaseColor or nil,
            selectedBorderColor = visual and visual.selectedBorderColor or nil,
            selectedTextColor = visual and visual.textColor or nil,
            textColor = visual and visual.textColor or nil,
            tooltipTitle = row.slotLabel,
            tooltipLines = tooltipLines,
            onClick = function()
                service:SetSelectedSlotKey(row.slotKey)
                addon.MainFrame:RefreshCurrentView(true)
            end,
        }
    end

    addon.TableView:Render(gearContent, {
        headers = { "Slot", "Item", "Source", "Instance", "Boss", "Status" },
        rows = tableRows,
        headerHeight = 22,
        rowHeight = 20,
        columnWidths = { 74, 204, 62, 104, 84, 82 },
    })
end

function addon.MainFrame:RenderView(viewKey)
    if self.HideRecruiterInviteBatchButton then
        self:HideRecruiterInviteBatchButton()
    end

    clearChildren(self.contentHost)
    self:RebuildNavigation()
    self.viewHost = CreateFrame("Frame", nil, self.contentHost)
    self.viewHost:SetPoint("TOPLEFT", self.contentHost, "TOPLEFT", 0, 0)
    self.viewHost:SetPoint("BOTTOMRIGHT", self.contentHost, "BOTTOMRIGHT", 0, 0)

    if viewKey == "gratsu" and not addon.GratsUService:CanAccess() then
        addon.Store:GetProfile().ui.selectedView = "dashboard"
        viewKey = "dashboard"
    end

    if viewKey == "celebrations" and not addon.ToonCelebrationService:CanAccess() then
        addon.Store:GetProfile().ui.selectedView = "dashboard"
        viewKey = "dashboard"
    end

    local featureKey = addon.GetFeatureKeyForView and addon:GetFeatureKeyForView(viewKey) or nil

    if featureKey and not addon:IsFeatureEnabled(featureKey) then
        addon.Store:GetProfile().ui.selectedView = "settings"
        viewKey = "settings"
    end

    if viewKey == "dashboard" then
        self:RenderDashboard()
    elseif viewKey == "board" then
        self:RenderBoard()
    elseif viewKey == "roster" then
        self:RenderRoster()
    elseif viewKey == "mytoons" then
        self:RenderMemberPane()
    elseif viewKey == "mythics" then
        self:RenderMythics()
    elseif viewKey == "bis" then
        self:RenderBIS()
    elseif viewKey == "raidbingo" then
        self:RenderRaidBingo()
    elseif viewKey == "gratsu" then
        self:RenderGratsU()
    elseif viewKey == "celebrations" then
        self:RenderToonCelebrations()
    elseif viewKey == "groups" then
        self:RenderGroups()
    elseif viewKey == "settings" then
        self:RenderSettings()
    elseif viewKey == "sync" then
        self:RenderSync()
    elseif viewKey == "setup" then
        self:RenderSetup()
    elseif viewKey == "help" then
        self:RenderHelp()
    elseif viewKey == "maintenance" then
        self:RenderMaintenance()
    else
        self:RenderDashboard()
    end
end

function addon.MainFrame:RebuildNavigation()
    if not self.sidebar then
        return
    end

    clearChildren(self.sidebar)
    self:BuildNavigation(self.sidebar)
end

function addon.MainFrame:HasActiveBingoFocus()
    if addon.Router:GetCurrentView() ~= "raidbingo" then
        return false
    end

    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() or nil

    if not focus or type(self.raidBingoInputs) ~= "table" then
        return false
    end

    for _, input in ipairs(self.raidBingoInputs) do
        if input == focus then
            return true
        end
    end

    return false
end

function addon.MainFrame:HasActiveGratsUFocus()
    if addon.Router:GetCurrentView() ~= "gratsu" then
        return false
    end

    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() or nil

    if not focus or type(self.gratsUInputs) ~= "table" then
        return false
    end

    for _, input in ipairs(self.gratsUInputs) do
        if input == focus then
            return true
        end
    end

    return false
end

function addon.MainFrame:HasActiveCelebrationsFocus()
    if addon.Router:GetCurrentView() ~= "celebrations" then
        return false
    end

    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() or nil

    if not focus or type(self.celebrationInputs) ~= "table" then
        return false
    end

    for _, input in ipairs(self.celebrationInputs) do
        if input == focus then
            return true
        end
    end

    return false
end

function addon.MainFrame:HasActiveBoardEditorFocus()
    if addon.Router:GetCurrentView() ~= "board" then
        return false
    end

    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() or nil

    if not focus or type(self.boardEditorInputs) ~= "table" then
        return false
    end

    for _, input in ipairs(self.boardEditorInputs) do
        if input == focus then
            return true
        end
    end

    return false
end

function addon.MainFrame:HasActiveGroupsFocus()
    if addon.Router:GetCurrentView() ~= "groups" then
        return false
    end

    local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() or nil

    if not focus or type(self.groupsInputs) ~= "table" then
        return false
    end

    for _, input in ipairs(self.groupsInputs) do
        if input == focus then
            return true
        end
    end

    return false
end

function addon.MainFrame:CaptureGroupsScrollState()
    self:EnsureState()

    if self.groupsDetailScrollFrame then
        self.state.groupsDetailScrollOffset = tonumber(self.groupsDetailScrollFrame:GetVerticalScroll()) or 0
    end

    if self.groupsListScrollFrame then
        self.state.groupsListScrollOffset = tonumber(self.groupsListScrollFrame:GetVerticalScroll()) or 0
    end

    if self.groupsSignupScrollFrame then
        self.state.groupsSignupScrollOffset = tonumber(self.groupsSignupScrollFrame:GetVerticalScroll()) or 0
    end
end

function addon.MainFrame:CaptureCelebrationsScrollState()
    self:EnsureState()

    if self.celebrationsScrollFrame then
        self.state.celebrationsScrollOffset = tonumber(self.celebrationsScrollFrame:GetVerticalScroll()) or 0
    end
end

function addon.MainFrame:RestoreCelebrationsScrollState()
    self:EnsureState()

    local offset = tonumber(self.state.celebrationsScrollOffset) or 0

    local function applyScrollOffset()
        if self.celebrationsScrollFrame then
            self.celebrationsScrollFrame:UpdateScrollChildRect()
            self.celebrationsScrollFrame:SetVerticalScroll(math.max(offset, 0))
        end
    end

    applyScrollOffset()

    if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if addon.MainFrame == self and addon.Router:GetCurrentView() == "celebrations" then
                applyScrollOffset()
            end
        end)
    end
end

function addon.MainFrame:RestoreGroupsScrollState()
    self:EnsureState()

    local detailOffset = tonumber(self.state.groupsDetailScrollOffset) or 0
    local listOffset = tonumber(self.state.groupsListScrollOffset) or 0
    local signupOffset = tonumber(self.state.groupsSignupScrollOffset) or 0

    local function applyScrollOffsets()
        if self.groupsDetailScrollFrame then
            self.groupsDetailScrollFrame:UpdateScrollChildRect()
            self.groupsDetailScrollFrame:SetVerticalScroll(math.max(detailOffset, 0))
        end

        if self.groupsListScrollFrame then
            self.groupsListScrollFrame:UpdateScrollChildRect()
            self.groupsListScrollFrame:SetVerticalScroll(math.max(listOffset, 0))
        end

        if self.groupsSignupScrollFrame then
            self.groupsSignupScrollFrame:UpdateScrollChildRect()
            self.groupsSignupScrollFrame:SetVerticalScroll(math.max(signupOffset, 0))
        end
    end

    applyScrollOffsets()

    if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if addon.MainFrame == self and addon.Router:GetCurrentView() == "groups" then
                applyScrollOffsets()
            end
        end)
    end
end

function addon.MainFrame:CaptureMythicsScrollState()
    self:EnsureState()

    if self.mythicsSummaryKeysScrollFrame then
        self.state.mythicsSummaryKeysScrollOffset = tonumber(self.mythicsSummaryKeysScrollFrame:GetVerticalScroll()) or 0
    end

    if self.mythicsMembersScrollFrame then
        self.state.mythicsMembersScrollOffset = tonumber(self.mythicsMembersScrollFrame:GetVerticalScroll()) or 0
    end

    if self.mythicsPortalRailScrollFrame then
        self.state.mythicsPortalRailScrollOffset = tonumber(self.mythicsPortalRailScrollFrame:GetVerticalScroll()) or 0
    end
end

function addon.MainFrame:RestoreMythicsScrollState()
    self:EnsureState()

    local summaryKeysOffset = tonumber(self.state.mythicsSummaryKeysScrollOffset) or 0
    local membersOffset = tonumber(self.state.mythicsMembersScrollOffset) or 0
    local portalRailOffset = tonumber(self.state.mythicsPortalRailScrollOffset) or 0

    local function applyScrollOffsets()
        if self.mythicsSummaryKeysScrollFrame then
            self.mythicsSummaryKeysScrollFrame:UpdateScrollChildRect()
            self.mythicsSummaryKeysScrollFrame:SetVerticalScroll(math.max(summaryKeysOffset, 0))
        end

        if self.mythicsMembersScrollFrame then
            self.mythicsMembersScrollFrame:UpdateScrollChildRect()
            self.mythicsMembersScrollFrame:SetVerticalScroll(math.max(membersOffset, 0))
        end

        if self.mythicsPortalRailScrollFrame then
            self.mythicsPortalRailScrollFrame:UpdateScrollChildRect()
            self.mythicsPortalRailScrollFrame:SetVerticalScroll(math.max(portalRailOffset, 0))
        end
    end

    applyScrollOffsets()

    if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if addon.MainFrame == self and addon.Router:GetCurrentView() == "mythics" then
                applyScrollOffsets()
            end
        end)
    end
end

function addon.MainFrame:RefreshCurrentView(force)
    if self.frame and self.frame:IsShown() then
        if InCombatLockdown and InCombatLockdown() then
            self.pendingRefreshAfterCombat = true
            return
        end

        if self.bannerText then
            self.bannerText:SetText(string.upper(getCurrentBannerGuildKey()))
        end

        if not force and addon.Router:GetCurrentView() == "board" and self.state and self.state.boardDraftDirty then
            self.pendingRefreshAfterEdit = true
            return
        end

        if not force and self:HasActiveBoardEditorFocus() then
            self.pendingRefreshAfterEdit = true
            return
        end

        if not force and self:HasActiveGratsUFocus() then
            return
        end

        if not force and self:HasActiveCelebrationsFocus() then
            return
        end

        if not force and self:HasActiveBingoFocus() then
            return
        end

        if not force and self:HasActiveGroupsFocus() then
            return
        end

        if addon.Router:GetCurrentView() == "groups" then
            self:CaptureGroupsScrollState()
        end

        if addon.Router:GetCurrentView() == "celebrations" then
            self:CaptureCelebrationsScrollState()
        end

        if addon.Router:GetCurrentView() == "mythics" then
            self:CaptureMythicsScrollState()
        end

        self.pendingRefreshAfterCombat = false
        self:EnsureState()
        self.state.pendingBackgroundRefresh = false
        self:RenderView(addon.Router:GetCurrentView())
    end
end

function addon.MainFrame:BuildNavigation(sidebar)
    local previousButton = nil

    for _, item in ipairs(NAV_ITEMS) do
        if not item.visible or item.visible() then
            local button = createButton(sidebar, item.label, 176, 30, previousButton and "TOP" or "TOP", previousButton or sidebar, previousButton and "BOTTOM" or "TOP", 0, previousButton and -10 or -18, function()
                addon.Router:Navigate(item.key)
            end)
            previousButton = button
        end
    end
end

function addon.MainFrame:CreateFrame()
    local frameSettings = addon.Store:GetProfile().ui.frame
    frameSettings.width = math.max(frameSettings.width or 0, MIN_FRAME_WIDTH)
    frameSettings.height = math.max(frameSettings.height or 0, MIN_FRAME_HEIGHT)
    local frame = CreateFrame("Frame", "GuildHelperV3MainFrame", UIParent, "BackdropTemplate")
    frame:SetFrameStrata("FULLSCREEN_DIALOG")
    frame:SetFrameLevel(180)
    frame:SetToplevel(true)
    frame:SetSize(frameSettings.width, frameSettings.height)
    frame:SetPoint(frameSettings.point, UIParent, frameSettings.point, frameSettings.x, frameSettings.y)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:SetClampedToScreen(true)
    applySurfaceStyle(frame, THEME.window)

    local titleBar = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    titleBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
    titleBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
    titleBar:SetHeight(58)
    titleBar:EnableMouse(true)
    titleBar:RegisterForDrag("LeftButton")
    applySurfaceStyle(titleBar, THEME.title)

    titleBar:SetScript("OnDragStart", function()
        frame:StartMoving()
    end)
    titleBar:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        local _, _, _, x, y = frame:GetPoint()
        frameSettings.x = x
        frameSettings.y = y
    end)

    local titleGlow = titleBar:CreateFontString(nil, "BACKGROUND", "GameFontHighlightLarge")
    titleGlow:SetPoint("LEFT", titleBar, "LEFT", 18, 0)
    titleGlow:SetText("GuildHelper v3")
    titleGlow:SetFont(TITLE_FONT_PATH, 28, "")
    titleGlow:SetTextColor(1, 0.86, 0.34, 0.16)

    local titleShadow = titleBar:CreateFontString(nil, "ARTWORK", "GameFontHighlightLarge")
    titleShadow:SetPoint("LEFT", titleGlow, "LEFT", 2, -2)
    titleShadow:SetText("GuildHelper v3")
    titleShadow:SetFont(TITLE_FONT_PATH, 28, "OUTLINE")
    titleShadow:SetTextColor(0.08, 0.04, 0.01, 0.9)

    local titleBevel = titleBar:CreateFontString(nil, "ARTWORK", "GameFontHighlightLarge")
    titleBevel:SetPoint("LEFT", titleGlow, "LEFT", -1, 1)
    titleBevel:SetText("GuildHelper v3")
    titleBevel:SetFont(TITLE_FONT_PATH, 28, "")
    titleBevel:SetTextColor(1, 0.97, 0.86, 0.38)

    local title = titleBar:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("LEFT", titleBar, "LEFT", 18, 0)
    title:SetText("GuildHelper v3")
    title:SetFont(TITLE_FONT_PATH, 28, "OUTLINE")
    title:SetTextColor(0.98, 0.9, 0.68, 1)
    title:SetShadowOffset(1, -1)
    title:SetShadowColor(0.45, 0.28, 0.04, 0.9)

    local subtitle = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    subtitle:SetPoint("LEFT", title, "RIGHT", 12, 0)
    subtitle:SetPoint("RIGHT", titleBar, "RIGHT", -300, 0)
    subtitle:SetJustifyH("LEFT")
    subtitle:SetText("Guild coordination, roster intelligence, and linked guild sync")
    styleBodyText(subtitle, true)

    local authorGlow = titleBar:CreateFontString(nil, "BACKGROUND", "GameFontHighlight")
    authorGlow:SetPoint("RIGHT", titleBar, "RIGHT", -52, 0)
    authorGlow:SetText("Author: FatherRahl/HereTanky/JB")
    authorGlow:SetFont(TITLE_FONT_PATH, 12, "")
    authorGlow:SetTextColor(1, 0.86, 0.34, 0.12)

    local authorShadow = titleBar:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    authorShadow:SetPoint("RIGHT", authorGlow, "RIGHT", 1, -1)
    authorShadow:SetText("Author: FatherRahl/HereTanky/JB")
    authorShadow:SetFont(TITLE_FONT_PATH, 12, "OUTLINE")
    authorShadow:SetTextColor(0.08, 0.04, 0.01, 0.9)

    local authorBevel = titleBar:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
    authorBevel:SetPoint("RIGHT", authorGlow, "RIGHT", -1, 1)
    authorBevel:SetText("Author: FatherRahl/HereTanky/JB")
    authorBevel:SetFont(TITLE_FONT_PATH, 12, "")
    authorBevel:SetTextColor(1, 0.97, 0.86, 0.28)

    local authorText = titleBar:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    authorText:SetPoint("RIGHT", titleBar, "RIGHT", -52, 0)
    authorText:SetText("Author: FatherRahl/HereTanky/JB")
    authorText:SetFont(TITLE_FONT_PATH, 12, "OUTLINE")
    authorText:SetTextColor(0.96, 0.88, 0.66, 1)
    authorText:SetShadowOffset(1, -1)
    authorText:SetShadowColor(0.45, 0.28, 0.04, 0.9)

    local closeButton = createCloseButton(titleBar, "RIGHT", titleBar, "RIGHT", -8, 0, function()
        self:HideAfterCombatSafe()
    end)

    local banner = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    banner:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -68)
    banner:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -18, -68)
    banner:SetHeight(64)
    applySurfaceStyle(banner, THEME.banner)

    local bannerText = banner:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    bannerText:SetPoint("CENTER", banner, "CENTER", 0, 0)
    bannerText:SetText(string.upper(getCurrentBannerGuildKey()))
    styleTitleText(bannerText, 24)
    self.bannerText = bannerText

    local sidebar = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    sidebar:SetPoint("TOPLEFT", banner, "BOTTOMLEFT", 0, -16)
    sidebar:SetSize(196, 656)
    applySurfaceStyle(sidebar, THEME.sidebar)

    local contentFrame = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    contentFrame:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 16, 0)
    contentFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -18, 18)
    applySurfaceStyle(contentFrame, THEME.content)

    local contentHost = CreateFrame("Frame", nil, contentFrame)
    contentHost:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 12, -12)
    contentHost:SetPoint("BOTTOMRIGHT", contentFrame, "BOTTOMRIGHT", -12, 12)

    self.frame = frame
    self.sidebar = sidebar
    self.contentFrame = contentFrame
    self.contentHost = contentHost

    self:BuildNavigation(sidebar)
    frame:Hide()
end

function addon.MainFrame:RegisterViews()
    addon.Router:Register("dashboard", function()
        addon.MainFrame:RenderView("dashboard")
    end)
    addon.Router:Register("board", function()
        addon.MainFrame:RenderView("board")
    end)
    addon.Router:Register("roster", function()
        addon.MainFrame:RenderView("roster")
    end)
    addon.Router:Register("mytoons", function()
        addon.MainFrame:RenderView("mytoons")
    end)
    addon.Router:Register("mythics", function()
        addon.MainFrame:RenderView("mythics")
    end)
    addon.Router:Register("bis", function()
        addon.BISService:SetSelectedToonId(addon.Store:GetPlayerName())
        addon.MainFrame:RenderView("bis")
    end)
    addon.Router:Register("raidbingo", function()
        addon.MainFrame:RenderView("raidbingo")
    end)
    addon.Router:Register("gratsu", function()
        addon.MainFrame:RenderView("gratsu")
    end)
    addon.Router:Register("celebrations", function()
        addon.MainFrame:RenderView("celebrations")
    end)
    addon.Router:Register("groups", function()
        addon.MainFrame:RenderView("groups")
    end)
    addon.Router:Register("settings", function()
        addon.MainFrame:RenderView("settings")
    end)
    addon.Router:Register("sync", function()
        addon.MainFrame:RenderView("sync")
    end)
    addon.Router:Register("setup", function()
        addon.MainFrame:RenderView("setup")
    end)
    addon.Router:Register("help", function()
        addon.MainFrame:RenderView("help")
    end)
    addon.Router:Register("maintenance", function()
        addon.MainFrame:RenderView("maintenance")
    end)
end

function addon.MainFrame:OpenQuickLink(viewKey, maintenanceTab)
    if type(viewKey) ~= "string" or viewKey == "" then
        return
    end

    self:EnsureState()

    if viewKey == "maintenance" and maintenanceTab then
        self.state.maintenanceTab = maintenanceTab
    end

    if addon.Router and addon.Router.Navigate then
        addon.Router:Navigate(viewKey)
    end

    self:ShowForCurrentView()
end

function addon.MainFrame:ShowMinimapQuickLinkMenu(anchorButton)
    if not anchorButton then
        return
    end

    showMenuButton(anchorButton, buildMinimapQuickLinkOptions)
end

function addon.MainFrame:Initialize()
    if self.frame then
        return
    end

    if InCombatLockdown and InCombatLockdown() then
        self.pendingInitializeAfterCombat = true
        self.pendingRefreshAfterCombat = true
        return
    end

    self:CreateFrame()
    self:InitializeGuildCleanupLaunchers()
    self:EnsureState()
    self:RegisterViews()
    self:RenderView(addon.Router:GetCurrentView())
end

function addon.MainFrame:ShowForCurrentView()
    if not self.frame then
        self:Initialize()
    end

    if not self.frame then
        self.pendingShowAfterCombat = true
        self.pendingRefreshAfterCombat = true
        return
    end

    if InCombatLockdown and InCombatLockdown() then
        self.pendingShowAfterCombat = true
        self.pendingRefreshAfterCombat = true
        return
    end

    self:RefreshCurrentView(true)
    self.frame:Show()
    self.frame:Raise()
end

function addon.MainFrame:HideAfterCombatSafe()
    if not self.frame then
        return false
    end

    if InCombatLockdown and InCombatLockdown() then
        self.pendingHideAfterCombat = true
        self.pendingShowAfterCombat = false
        self.pendingRefreshAfterCombat = false
        addon:Print("GH3 cannot close during combat. It will close after combat ends.")
        return false
    end

    self.pendingHideAfterCombat = false
    self.frame:Hide()
    return true
end

function addon.MainFrame:HandleDeferredProtectedUiAfterCombat()
    if InCombatLockdown and InCombatLockdown() then
        return
    end

    local helperFrame = self.guildCleanupFrame

    if helperFrame and helperFrame.ProcessDeferredCombatActions then
        helperFrame:ProcessDeferredCombatActions()
    end

    if self.pendingHideAfterCombat then
        self.pendingHideAfterCombat = false

        if self.frame and self.frame:IsShown() then
            self.frame:Hide()
        end
    end
end

function addon.MainFrame:Toggle()
    if not self.frame then
        self:Initialize()
    end

    if not self.frame then
        self.pendingShowAfterCombat = true
        self.pendingRefreshAfterCombat = true
        return
    end

    if self.frame:IsShown() then
        self:HideAfterCombatSafe()
    else
        self:ShowForCurrentView()
    end
end
