GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.NewUISettings = addon.NewUISettings or {}

local page = addon.NewUISettings
page.State = page.State or {}

local PANEL_ART_KEY = "MyToonsPanelBackground"
local BUTTON_ART_KEY = "MyToonsButtonBackground"
local BUTTON_ACTIVE_ART_KEY = "MyToonsButtonSelected"
local TITLE_FONT_PATH = "Fonts\\MORPHEUS.ttf"
local RECRUITER_INVITE_MACRO_NAME = "GH3InviteRun"
local DEFAULT_PANE_BODY_FONT_KEY = "default"
local DEFAULT_PANE_TITLE_FONT_KEY = "morpheus"
local TEXT_VIEWER_AUTOSELECT_MAX_CHARS = 12000
local styleBodyText
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 TRANSPARENCY_OPTIONS = {
    { key = "solid", label = "0%", alpha = 1.00 },
    { key = "10", label = "10%", alpha = 0.90 },
    { key = "20", label = "20%", alpha = 0.80 },
    { key = "30", label = "30%", alpha = 0.70 },
    { key = "40", label = "40%", alpha = 0.60 },
}
local DEFAULT_TRANSPARENCY_OPTION_KEY = "solid"
local DEFAULT_TRANSPARENCY_KEYS = {
    mainUi = DEFAULT_TRANSPARENCY_OPTION_KEY,
    helperPopup = DEFAULT_TRANSPARENCY_OPTION_KEY,
    bisPopup = DEFAULT_TRANSPARENCY_OPTION_KEY,
    mythicTimerPopup = DEFAULT_TRANSPARENCY_OPTION_KEY,
    mythicSummaryPopup = DEFAULT_TRANSPARENCY_OPTION_KEY,
}
local refreshPage
local setButtonTone

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.50, 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 trimString(value)
    return tostring(value or ""):match("^%s*(.-)%s*$") or ""
end

local function lowerText(value)
    return string.lower(tostring(value or ""))
end

local function normalizeDisplayName(name)
    if addon.Store and addon.Store.SplitFullName then
        return addon.Store:SplitFullName(tostring(name or ""), GetRealmName() or "Unknown")
    end

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

local function formatConflictTime(timestamp)
    local numericTimestamp = tonumber(timestamp) or 0
    if numericTimestamp <= 0 then
        return "-"
    end

    return date("%m-%d %H:%M", numericTimestamp)
end

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

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

    for _, child in ipairs({ frame:GetChildren() }) 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

    if frame.Hide then
        frame:Hide()
    end

    if frame.SetParent then
        frame:SetParent(nil)
    end
end

local function resolveTexturePath(textureKey)
    local textures = addon.NewUIAssets and addon.NewUIAssets.Textures or nil
    return textures and textures[textureKey] or nil
end

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

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

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

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

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

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, isActive and BUTTON_ACTIVE_ART_KEY or BUTTON_ART_KEY, isActive and { 0.20, 0.23, 0.31, 0.98 } or { 0.10, 0.11, 0.14, 0.95 })

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("CENTER", button, "CENTER", 0, 0)
    text:SetText(labelText or "")
    text:SetTextColor(isActive and 0.98 or 0.96, isActive and 0.88 or 0.93, isActive and 0.58 or 0.84, 1)
    button.Background = background
    button.Text = text

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

    return button
end

local function createSelectButton(parent, rect, labelText, onClick)
    local button = createButton(parent, rect, labelText, onClick, false)
    local arrow = button:CreateTexture(nil, "OVERLAY")
    arrow:SetPoint("RIGHT", button, "RIGHT", -8, 0)
    arrow:SetSize(16, 16)
    arrow:SetTexture("Interface\\ChatFrame\\ChatFrameExpandArrow")
    arrow:SetVertexColor(0.96, 0.82, 0.46, 1)
    button.Arrow = arrow
    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:HookScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end

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

local function ensurePopupMenuFrame()
    local anchorFrame = page._popupAnchorFrame

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

    if page._popupMenuFrame and not anchorFrame then
        return page._popupMenuFrame
    end

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

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

    return frame
end

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

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

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

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

    return math.min(width, 320)
end

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

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

        item:SetScript("OnClick", function()
            if option.disabled then return end
            if type(option.onClick) == "function" then
                option.onClick(option.value, option)
            elseif type(option.func) == "function" then
                option.func(option.value)
            end
            hidePopupMenu(frame)
            page._activePopupAnchor = nil
        end)
        item:Show()
    end

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

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

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

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

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

local function showContainedDropdownMenu(hostFrame, anchorFrame, items)
    local frame = page._containedDropdownFrame

    if not frame then
        frame = CreateFrame("Frame", nil, UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
        frame:SetFrameStrata("TOOLTIP")
        frame:SetToplevel(true)
        frame:SetClampedToScreen(true)
        frame:EnableMouse(true)
        if frame.SetBackdrop then
            frame:SetBackdrop({
                bgFile = "Interface\\Buttons\\WHITE8X8",
                edgeFile = "Interface\\Buttons\\WHITE8X8",
                edgeSize = 1,
                insets = { left = 1, right = 1, top = 1, bottom = 1 },
            })
            frame:SetBackdropColor(0.06, 0.07, 0.10, 0.98)
            frame:SetBackdropBorderColor(0.74, 0.58, 0.22, 0.95)
        end
        frame:Hide()
        frame.itemButtons = {}
        frame.maxVisibleRows = 8
        frame.scrollOffset = 0
        frame.upButton = createButton(frame, { x = 0, y = 0, w = 20, h = 22 }, "^", nil, false)
        frame.downButton = createButton(frame, { x = 0, y = 0, w = 20, h = 22 }, "v", nil, false)
        if frame.SetPropagateMouseWheel then
            frame:SetPropagateMouseWheel(true)
        end
        page._containedDropdownFrame = frame
    end

    frame:SetFrameStrata("TOOLTIP")
    frame:SetFrameLevel(math.max(400, (hostFrame and hostFrame.GetFrameLevel and tonumber(hostFrame:GetFrameLevel()) or 0) + 120))

    local menuItems = type(items) == "function" and (items() or {}) or (items or {})
    local width = math.max(160, tonumber(anchorFrame and anchorFrame:GetWidth()) or 160)
    local uiScale = UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale() or 1
    local screenHeight = GetScreenHeight and (GetScreenHeight() / math.max(uiScale, 0.01)) or 1080
    local anchorTop = anchorFrame and anchorFrame.GetTop and anchorFrame:GetTop() or nil
    local anchorBottom = anchorFrame and anchorFrame.GetBottom and anchorFrame:GetBottom() or nil
    local anchorLeft = anchorFrame and anchorFrame.GetLeft and anchorFrame:GetLeft() or nil
    local availableBelow = anchorBottom and math.max(24, math.floor(anchorBottom - 24)) or 208
    local availableAbove = anchorTop and math.max(24, math.floor((screenHeight - anchorTop) - 24)) or 208
    local openBelow = availableBelow >= availableAbove
    local availableHeight = openBelow and availableBelow or availableAbove
    local maxRowsBySpace = math.max(1, math.floor((availableHeight - 16) / 24))
    local visibleCount = math.max(1, math.min(#menuItems > 0 and #menuItems or 1, frame.maxVisibleRows or 8, math.max(maxRowsBySpace, 4)))
    local canScroll = #menuItems > visibleCount
    local scrollWidth = canScroll and 24 or 0
    local buttonWidth = width - scrollWidth

    local function renderMenu()
        local maxOffset = math.max(0, #menuItems - visibleCount)
        frame.scrollOffset = math.max(0, math.min(tonumber(frame.scrollOffset) or 0, maxOffset))

        for visibleIndex = 1, visibleCount do
            local itemIndex = visibleIndex + frame.scrollOffset
            local option = menuItems[itemIndex] or { text = "", disabled = true }
            local button = ensurePopupMenuItem(frame, visibleIndex)
            frame.itemButtons[visibleIndex] = button
            ApplyRect(button, frame, { x = 8, y = -8 - ((visibleIndex - 1) * 24), w = buttonWidth, h = 22 })
            button:SetFrameStrata("TOOLTIP")
            button:SetFrameLevel((frame:GetFrameLevel() or 0) + 10)
            local isChecked = type(option.checked) == "function" and option.checked() or option.checked
            local prefix = option.notCheckable and "" or (isChecked and "[x] " or "[ ] ")
            if button.Text then
                button.Text:SetText(prefix .. tostring(option.text or "Select"))
                button.Text:SetTextColor(option.disabled and 0.54 or 0.96, option.disabled and 0.54 or 0.93, option.disabled and 0.58 or 0.84, 1)
            end
            if button.SetBackdropColor then
                if option.disabled then
                    button:SetBackdropColor(0.14, 0.14, 0.16, 0.98)
                else
                    button:SetBackdropColor(0.10, 0.11, 0.14, 0.92)
                end
            end
            button:SetEnabled(not option.disabled)
            button:SetScript("OnClick", function()
                if option.disabled then
                    return
                end
                frame:Hide()
                if type(option.func) == "function" then
                    option.func(option.value)
                end
            end)
            button:Show()
        end

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

        frame.upButton:ClearAllPoints()
        frame.upButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
        frame.upButton:SetFrameStrata("TOOLTIP")
        frame.upButton:SetFrameLevel((frame:GetFrameLevel() or 0) + 20)
        frame.upButton:SetShown(canScroll)
        frame.upButton:SetEnabled(frame.scrollOffset > 0)
        frame.upButton:SetScript("OnClick", function()
            frame.scrollOffset = math.max(0, frame.scrollOffset - 1)
            renderMenu()
        end)

        frame.downButton:ClearAllPoints()
        frame.downButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -8, 8)
        frame.downButton:SetFrameStrata("TOOLTIP")
        frame.downButton:SetFrameLevel((frame:GetFrameLevel() or 0) + 20)
        frame.downButton:SetShown(canScroll)
        frame.downButton:SetEnabled(frame.scrollOffset < maxOffset)
        frame.downButton:SetScript("OnClick", function()
            frame.scrollOffset = math.min(maxOffset, frame.scrollOffset + 1)
            renderMenu()
        end)
    end

    frame:SetScript("OnMouseWheel", function(_, delta)
        if #menuItems <= visibleCount then
            return
        end

        frame.scrollOffset = math.max(0, math.min(#menuItems - visibleCount, frame.scrollOffset - (delta > 0 and 1 or -1)))
        renderMenu()
    end)

    ApplyRect(frame, hostFrame, { x = 0, y = 0, w = width + 16 + scrollWidth, h = (visibleCount * 24) + 16 })
    frame:ClearAllPoints()
    local offsetX = anchorLeft and math.floor(anchorLeft + 0.5) or 0
    if openBelow and anchorBottom then
        frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", offsetX, math.floor(anchorBottom - 4 + 0.5))
    elseif anchorTop then
        frame:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", offsetX, math.floor(anchorTop + 4 + 0.5))
    else
        frame:SetPoint("TOPLEFT", anchorFrame, "BOTTOMLEFT", 0, -4)
    end
    frame.scrollOffset = 0
    renderMenu()
    frame:Show()
end

local function createPanel(parent, rect)
    local panel = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(panel, parent, rect)

    local background = panel:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(panel)
    applyArtTexture(background, PANEL_ART_KEY, { 0.08, 0.09, 0.12, 0.96 })
    panel.Background = background

    return panel
end

local function createListRow(parent, rect, evenRow)
    local row = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(row, parent, rect)
    row:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    if evenRow then
        row:SetBackdropColor(0.08, 0.10, 0.14, 0.96)
    else
        row:SetBackdropColor(0.05, 0.07, 0.10, 0.96)
    end
    row:SetBackdropBorderColor(0.30, 0.38, 0.48, 1)
    return row
end

local function createInput(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)
    input:SetFontObject("ChatFontNormal")
    if input.SetTextInsets then
        input:SetTextInsets(6, 6, 0, 0)
    end
    if input.SetTextColor then
        input:SetTextColor(0.92, 0.92, 0.92, 1)
    end
    input:SetScript("OnEscapePressed", function(edit)
        edit:ClearFocus()
    end)
    return input
end

local function createMultilineInput(parent, width, height, point, relativeTo, relativePoint, x, y)
    local container = createPanel(parent, { x = 0, y = 0, w = width, h = height })
    container:ClearAllPoints()
    container:SetPoint(point, relativeTo, relativePoint, x, y)

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

    local input = CreateFrame("EditBox", nil, scrollFrame)
    input:SetMultiLine(true)
    input:SetAutoFocus(false)
    input:SetFontObject("ChatFontNormal")
    input:SetTextColor(0.92, 0.92, 0.92, 1)
    input:SetWidth(math.max(width - 50, 20))
    input:SetHeight(math.max(height - 24, 40))
    input:SetJustifyH("LEFT")
    input:SetJustifyV("TOP")
    input:SetScript("OnEscapePressed", function(edit)
        edit:ClearFocus()
    end)
    if input.SetTextInsets then
        input:SetTextInsets(4, 4, 4, 4)
    end
    scrollFrame:SetScrollChild(input)

    return input, container
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 or "")
        styleBodyText(checkbox.text, true)
    end

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

    return checkbox
end

local function createCloseButton(parent, point, relativeTo, relativePoint, x, y, onClick)
    local button = createButton(parent, { x = 0, y = 0, w = 26, h = 26 }, "X", onClick, false)
    button:ClearAllPoints()
    button:SetPoint(point, relativeTo, relativePoint, x, y)
    setButtonTone(button, { 0.16, 0.08, 0.08, 0.98 }, { 0.98, 0.84, 0.72, 1 })
    return button
end

local function createSecureMacroButton(parent, rect, labelText)
    local button = CreateFrame("Button", nil, parent, "BackdropTemplate,SecureActionButtonTemplate")
    ApplyRect(button, parent, rect)
    button:RegisterForClicks("LeftButtonUp")

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(button)
    applyArtTexture(background, BUTTON_ART_KEY, { 0.10, 0.11, 0.14, 0.95 })

    local text = button:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    text:SetPoint("CENTER", button, "CENTER", 0, 0)
    text:SetText(labelText or "")
    text:SetTextColor(0.96, 0.93, 0.84, 1)
    button.Background = background
    button.Text = text

    return button
end

local function createLegacyButton(parent, labelText, width, height, point, relativeTo, relativePoint, x, y, onClick, isActive)
    local button = createButton(parent, { x = 0, y = 0, w = width, h = height }, labelText, onClick, isActive)
    button:ClearAllPoints()
    button:SetPoint(point, relativeTo, relativePoint, x, y)
    return button
end

local function createLegacySelectButton(parent, labelText, width, height, point, relativeTo, relativePoint, x, y, onClick)
    local button = createSelectButton(parent, { x = 0, y = 0, w = width, h = height }, labelText, onClick)
    button:ClearAllPoints()
    button:SetPoint(point, relativeTo, relativePoint, x, y)
    return button
end

local function createLegacySecureMacroButton(parent, labelText, width, height, point, relativeTo, relativePoint, x, y, onClick)
    local button = createSecureMacroButton(parent, { x = 0, y = 0, w = width, h = height }, labelText)
    button:ClearAllPoints()
    button:SetPoint(point, relativeTo, relativePoint, x, y)
    if type(onClick) == "function" then
        button:SetScript("OnClick", function()
            onClick(button)
        end)
    end
    return button
end

local function setSelectButtonText(button, value)
    if button and button.Text then
        button.Text:SetText(tostring(value or ""))
    end
end

local function showLegacyMenuButton(button, buildMenu)
    showDropdownMenu(button, function()
        local items = {}

        for _, option in ipairs(type(buildMenu) == "function" and (buildMenu() or {}) or {}) do
            items[#items + 1] = {
                text = option.text,
                value = option.value,
                disabled = option.disabled,
                func = option.onClick or option.func,
            }
        end

        return items
    end)
end

local function requestGuildRosterSnapshot()
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        local ok = pcall(C_GuildInfo.GuildRoster)
        if ok then
            return true
        end
    end

    if GuildRoster then
        local ok = pcall(GuildRoster)
        if ok then
            return true
        end
    end

    return false
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 = trimString(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

local refreshSettingsPage

local function hideRecruiterInviteBatchButton()
    local button = page._maintenanceInviteBatchButton
    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

local function ensureRecruiterInviteBatchButton()
    if page._maintenanceInviteBatchButton then
        return page._maintenanceInviteBatchButton
    end

    local hostFrame = page._parentFrame or UIParent
    local service = addon.MaintenanceService
    local button = createSecureMacroButton(hostFrame, { x = -10000, y = 0, w = 112, h = 24 }, "Invite Batch")

    button:SetFrameStrata(hostFrame:GetFrameStrata())
    button:SetFrameLevel((hostFrame: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
        refreshSettingsPage()
        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if page.State.activePane == "maintenance" and not (InCombatLockdown and InCombatLockdown()) then
                    refreshSettingsPage()
                end
            end)
        end
    end)

    page._maintenanceInviteBatchButton = button
    return button
end

local function positionRecruiterInviteBatchButton(anchorFrame, payload, preparedMacro)
    local button = ensureRecruiterInviteBatchButton()
    local hostFrame = page._parentFrame

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

    local anchorLeft, anchorBottom = anchorFrame:GetLeft(), anchorFrame:GetBottom()
    local hostLeft, hostBottom = hostFrame:GetLeft(), hostFrame:GetBottom()
    if not anchorLeft or not anchorBottom or not hostLeft or not hostBottom then
        hideRecruiterInviteBatchButton()
        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if page.State.activePane == "maintenance" and not (InCombatLockdown and InCombatLockdown()) then
                    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: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

    hideRecruiterInviteBatchButton()
    return button
end

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.Text then
            button.Text:ClearAllPoints()
            button.Text:SetPoint("LEFT", preview, "RIGHT", 6, 0)
            button.Text:SetPoint("RIGHT", button, "RIGHT", -6, 0)
            button.Text:SetJustifyH("LEFT")
        end
    end

    button._gh3ColorPreview:SetColorTexture(unpack(resolvedColor))
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
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

    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

refreshSettingsPage = function()
    if page._parentFrame then
        refreshPage(page._parentFrame, page._snapshot)
        return
    end

    if addon.MainFrame and addon.MainFrame.RefreshCurrentView then
        addon.MainFrame:RefreshCurrentView(true)
    end
end

function page:RefreshCurrentPane()
    refreshSettingsPage()
end

local function openSettingsHelpArticle(articleKey)
    local mainFrame = addon.MainFrame
    if mainFrame and mainFrame.EnsureState then
        mainFrame:EnsureState()
        mainFrame.state.helpArticleKey = articleKey or "overview"
    end

    page.State.activePane = "help"
    refreshSettingsPage()
end

local function ensureTextViewerDialog()
    if page._textViewerDialog then
        return page._textViewerDialog
    end

    local frame = CreateFrame("Frame", nil, 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()
    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)
    frame:Hide()

    local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -14)
    title:SetTextColor(0.93, 0.84, 0.63, 1)
    title:SetFont(TITLE_FONT_PATH, 18, "OUTLINE")
    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")
    frame.scrollFrame = scrollFrame

    local editBox = CreateFrame("EditBox", nil, scrollFrame, "InputBoxTemplate")
    editBox:SetMultiLine(true)
    editBox:SetFontObject("ChatFontNormal")
    editBox:SetAutoFocus(false)
    editBox:SetWidth(760)
    editBox:SetTextColor(0.92, 0.92, 0.92, 1)
    editBox:SetScript("OnEscapePressed", function(edit)
        edit:ClearFocus()
    end)
    scrollFrame:SetScrollChild(editBox)
    frame.editBox = editBox

    createButton(frame, { x = 370, y = -486, w = 120, h = 24 }, "Close", function()
        frame:Hide()
    end, false)

    page._textViewerDialog = frame
    return frame
end

local function showTextViewerDialog(titleText, bodyText, helpText, width, height)
    local frame = ensureTextViewerDialog()
    frame:SetSize(width or 860, height or 520)
    frame:ClearAllPoints()
    frame:SetPoint("CENTER")
    frame.title:SetText(titleText or "GH3")
    frame.helperText:SetText(tostring(helpText or ""))
    frame.helperText:SetShown(trimString(helpText) ~= "")
    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 resolvedBodyText = tostring(bodyText or "")
    frame.editBox:SetText(resolvedBodyText)
    frame.editBox:SetCursorPosition(0)
    if string.len(resolvedBodyText) <= 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

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 getNewUITransparencyProfile()
    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.newUI = profile.ui.newUI or {}
    profile.ui.newUI.transparency = profile.ui.newUI.transparency or {}
    return profile.ui.newUI.transparency
end

local function getTransparencyOption(optionKey, defaultKey)
    local resolvedKey = tostring(optionKey or defaultKey or DEFAULT_TRANSPARENCY_OPTION_KEY)

    for _, option in ipairs(TRANSPARENCY_OPTIONS) do
        if option.key == resolvedKey then
            return option
        end
    end

    for _, option in ipairs(TRANSPARENCY_OPTIONS) do
        if option.key == (defaultKey or DEFAULT_TRANSPARENCY_OPTION_KEY) then
            return option
        end
    end

    return TRANSPARENCY_OPTIONS[1]
end

local function getConfiguredTransparencyOption(surfaceKey)
    local transparencyProfile = getNewUITransparencyProfile() or {}
    local defaultKey = DEFAULT_TRANSPARENCY_KEYS[surfaceKey] or DEFAULT_TRANSPARENCY_OPTION_KEY
    return getTransparencyOption(transparencyProfile[surfaceKey], defaultKey)
end

function page:GetConfiguredTransparencyAlpha(surfaceKey)
    local option = getConfiguredTransparencyOption(surfaceKey)
    return tonumber(option and option.alpha) or 1
end

local function setConfiguredTransparency(surfaceKey, optionKey)
    local transparencyProfile = getNewUITransparencyProfile()
    if not transparencyProfile then
        return
    end

    local defaultKey = DEFAULT_TRANSPARENCY_KEYS[surfaceKey] or DEFAULT_TRANSPARENCY_OPTION_KEY
    transparencyProfile[surfaceKey] = getTransparencyOption(optionKey, defaultKey).key
end

local function applyConfiguredPopupTransparencyOwners()
    if addon.NewUIShell and addon.NewUIShell.ApplyConfiguredTransparency then
        addon.NewUIShell:ApplyConfiguredTransparency()
    end

    if page and page.ApplyConfiguredPopupTransparency then
        page:ApplyConfiguredPopupTransparency()
    end

    if addon.NewUIBIS and addon.NewUIBIS.ApplyConfiguredPopupTransparency then
        addon.NewUIBIS:ApplyConfiguredPopupTransparency()
    end

    if addon.MythicPlusService and addon.MythicPlusService.ApplyConfiguredPopupTransparency then
        addon.MythicPlusService:ApplyConfiguredPopupTransparency()
    end
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 function styleTitleText(fontString, size)
    if not fontString then
        return
    end
    fontString:SetTextColor(0.93, 0.84, 0.63, 1)
    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneTitle") or TITLE_FONT_PATH, size or 18, "OUTLINE", TITLE_FONT_PATH, size or 18, "OUTLINE")
end

local function styleHighlightText(fontString)
    if not fontString then
        return
    end
    fontString:SetTextColor(0.88, 0.78, 0.56, 1)
    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneTitle"), nil, nil, TITLE_FONT_PATH, 14, "")
end

styleBodyText = function(fontString, muted)
    if not fontString then
        return
    end
    if muted then
        fontString:SetTextColor(0.70, 0.71, 0.72, 1)
    else
        fontString:SetTextColor(0.80, 0.79, 0.74, 1)
    end
    applyConfiguredFont(fontString, getConfiguredPaneFontPath("paneBody"), nil, nil, nil, 12, "")
end

setButtonTone = function(button, backgroundColor, textColor)
    if button and button.Background and button.Background.SetVertexColor and type(backgroundColor) == "table" then
        button.Background:SetVertexColor(backgroundColor[1] or 1, backgroundColor[2] or 1, backgroundColor[3] or 1, backgroundColor[4] or 1)
    end
    if button and button.Text and button.Text.SetTextColor and type(textColor) == "table" then
        button.Text:SetTextColor(textColor[1] or 1, textColor[2] or 1, textColor[3] or 1, textColor[4] or 1)
    end
end

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

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 formatTimestampText(timestamp)
    local numericTimestamp = tonumber(timestamp) or 0
    if numericTimestamp <= 0 or not date then
        return "Never"
    end

    return date("%Y-%m-%d %H:%M", numericTimestamp)
end

local function getSelectedSyncPeerName(peers)
    local mainFrame = addon.MainFrame
    if mainFrame and mainFrame.GetSelectedSyncPeer then
        return mainFrame:GetSelectedSyncPeer(peers)
    end
    return peers and peers[1] and peers[1].sender or nil
end

local function setSelectedSyncPeerName(peer)
    if addon.MainFrame and addon.MainFrame.SetSelectedSyncPeer then
        addon.MainFrame:SetSelectedSyncPeer(peer)
    end
end

local function requestGuildRosterSnapshot()
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        local ok = pcall(C_GuildInfo.GuildRoster)
        if ok then
            return true
        end
    end

    if GuildRoster then
        local ok = pcall(GuildRoster)
        if ok then
            return true
        end
    end

    return false
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 = trimString(record.includedViaSearch or record.includedViaMain)

    if linkedMemberId == "" then
        return nil
    end

    local shortName = addon.Store and addon.Store.SplitFullName and 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

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

    local alpha = page.GetConfiguredTransparencyAlpha and page:GetConfiguredTransparencyAlpha("helperPopup") or 1

    if frame.SetBackdropColor then
        frame:SetBackdropColor(0.02, 0.03, 0.05, 0.985 * alpha)
    end

    if frame.Background and frame.Background.SetAlpha then
        frame.Background:SetAlpha(alpha)
    end
end

local function ensureGuildCleanupDialog()
    if page._guildCleanupDialog then
        return page._guildCleanupDialog
    end

    local service = addon.MaintenanceService
    local frame = CreateFrame("Frame", "GH3GuildCleanupFrame", 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", frame.StopMovingOrSizing)
    frame:SetSize(760, 664)
    frame:SetPoint("CENTER")
    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 backdrop = frame:CreateTexture(nil, "BACKGROUND")
    backdrop:SetAllPoints(frame)
    applyArtTexture(backdrop, PANEL_ART_KEY, { 0.08, 0.09, 0.12, 0.96 })
    frame.Background = backdrop
    applyHelperPopupTransparency(frame)
    frame.queuePageSize = 5
    frame.queuePages = { kick = 1, promote = 1 }
    frame.pendingBatchRefreshAfterCombat = false
    frame.pendingBatchRunDialogHideAfterCombat = false
    frame.pendingBatchRunDialogShowAfterCombat = false
    frame.pendingCloseAfterCombat = false
    frame:Hide()

    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 confirm the batch run.")
    styleBodyText(helperText, true)
    frame.helperText = helperText

    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, { x = 0, y = 0, w = 108, h = 24 }, "Kick", function()
        frame.mode = "kick"
        frame.queuePages.kick = tonumber(frame.queuePages.kick) or 1
        frame:RefreshData()
    end, true)
    frame.promoteTabButton = createButton(frame.tabRow, { x = 116, y = 0, w = 108, h = 24 }, "Promote", function()
        frame.mode = "promote"
        frame.queuePages.promote = tonumber(frame.queuePages.promote) or 1
        frame:RefreshData()
    end, false)

    frame.scanButton = createButton(frame, { x = 16, y = -72, w = 90, h = 24 }, "Run Scan", function()
        frame:ResetKickSearchState(true)
        service:ScanRoster()
        frame:RefreshData()
    end, false)

    frame.buildMacroButton = createButton(frame, { x = 114, y = -72, w = 104, h = 24 }, "Build Macro", 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, false)

    frame.runBatchButton = createButton(frame, { x = 226, y = -72, w = 104, h = 24 }, "Batch Run", function()
        frame:ShowBatchRunDialog()
    end, false)

    local filterPanel = createPanel(frame, { x = 16, y = -104, w = 728, h = 92 })
    frame.filterPanel = filterPanel

    frame.selectAllButton = createButton(frame, { x = 540, y = -194, w = 96, h = 22 }, "Select All", function()
        if frame.mode == "promote" then
            service:SelectAllVisiblePromotionMembers()
        else
            service:SelectAllVisibleCleanupMembers()
        end
        frame.macroPayload = nil
        frame:RefreshData(false)
    end, false)

    frame.clearSelectionButton = createButton(frame, { x = 644, y = -194, w = 96, h = 22 }, "Select None", function()
        service:ClearSelectedMembers(frame.mode)
        frame.macroPayload = nil
        frame:RefreshData(false)
    end, false)

    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, { x = 72, y = -26, w = 64, h = 22 }, "Apply", function()
        frame:ResetKickSearchState(true)
        service:UpdateSettings({ staleAfterDays = frame.inactiveDaysInput:GetText() })
        frame.macroPayload = nil
        frame:RefreshData(false)
    end, false)

    frame.scopeButton = createSelectButton(filterPanel, { x = 148, y = -26, w = 108, h = 22 }, "Scope: All", function(anchor)
        local settings = service:GetSettings()
        showDropdownMenu(anchor, function()
            local items = {}
            for _, value in ipairs({ "all", "mains", "alts" }) do
                items[#items + 1] = {
                    text = getCleanupKickScopeLabel(value),
                    value = value,
                    func = function(selectedValue)
                        frame:ResetKickSearchState(true)
                        service:UpdateSettings({ kickScope = selectedValue })
                        frame.macroPayload = nil
                        frame:RefreshData(false)
                    end,
                    disabled = false,
                }
            end
            return items
        end)
    end)

    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)

    frame.kickSearchButton = createSelectButton(filterPanel, { x = 602, y = -26, w = 114, h = 22 }, "Queue Match", function(anchor)
        frame:ShowKickSearchMenu(anchor)
    end)

    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)

    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 = createSelectButton(filterPanel, { x = 12, y = -26, w = 132, h = 22 }, "From: -", function(anchor)
        local settings = service:GetSettings()
        local ranks = service:GetGuildRankNames()
        showDropdownMenu(anchor, function()
            local items = {}
            if #ranks <= 0 then
                items[#items + 1] = { text = "No guild ranks", disabled = true }
            end
            for _, value in ipairs(ranks) do
                items[#items + 1] = {
                    text = value,
                    value = value,
                    func = function(selectedValue)
                        service:UpdateSettings({ promoteFromRank = selectedValue })
                        frame:RefreshData(true)
                    end,
                }
            end
            return items
        end)
    end)

    frame.toRankButton = createSelectButton(filterPanel, { x = 152, y = -26, w = 132, h = 22 }, "To: -", function(anchor)
        local ranks = service:GetGuildRankNames()
        showDropdownMenu(anchor, function()
            local items = {}
            if #ranks <= 0 then
                items[#items + 1] = { text = "No guild ranks", disabled = true }
            end
            for _, value in ipairs(ranks) do
                items[#items + 1] = {
                    text = value,
                    value = value,
                    func = function(selectedValue)
                        service:UpdateSettings({ promoteToRank = selectedValue })
                        frame:RefreshData(true)
                    end,
                }
            end
            return items
        end)
    end)

    frame.promotionTenureButton = createSelectButton(filterPanel, { x = 292, y = -26, w = 132, h = 22 }, "Tenure: Any", function(anchor)
        local settings = service:GetSettings()
        showDropdownMenu(anchor, function()
            local items = {}
            for _, value in ipairs({ 0, 30, 90, 180, 365, 730 }) do
                items[#items + 1] = {
                    text = getPromotionTenureOptionLabel(value),
                    value = value,
                    func = function(selectedValue)
                        service:UpdateSettings({ promoteMinJoinDays = selectedValue })
                        frame:RefreshData(true)
                    end,
                }
            end
            return items
        end)
    end)

    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.queueHeader = queueHeader

    frame.prevQueuePageButton = createButton(frame, { x = 84, y = -198, w = 72, h = 22 }, "< Prev", 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, false)

    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, { x = 260, y = -198, w = 72, h = 22 }, "Next >", 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, false)

    frame.queueContainer = createPanel(frame, { x = 16, y = -222, w = 728, h = 176 })
    frame.queueScroll = CreateFrame("ScrollFrame", nil, frame.queueContainer, "UIPanelScrollFrameTemplate")
    frame.queueScroll:SetPoint("TOPLEFT", frame.queueContainer, "TOPLEFT", 8, -8)
    frame.queueScroll:SetPoint("BOTTOMRIGHT", frame.queueContainer, "BOTTOMRIGHT", -30, 8)

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

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

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

    frame.macroContainer = createPanel(frame, { x = 16, y = -428, w = 728, h = 90 })
    frame.macroScroll = CreateFrame("ScrollFrame", nil, frame.macroContainer, "UIPanelScrollFrameTemplate")
    frame.macroScroll:SetPoint("TOPLEFT", frame.macroContainer, "TOPLEFT", 8, -8)
    frame.macroScroll:SetPoint("BOTTOMRIGHT", frame.macroContainer, "BOTTOMRIGHT", -30, 8)
    frame.macroContent = CreateFrame("Frame", nil, frame.macroScroll)
    frame.macroContent:SetWidth(688)
    frame.macroScroll:SetScrollChild(frame.macroContent)
    frame.macroBody = frame.macroContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.macroBody:SetPoint("TOPLEFT", frame.macroContent, "TOPLEFT", 0, 0)
    frame.macroBody:SetWidth(688)
    frame.macroBody:SetJustifyH("LEFT")
    frame.macroBody:SetJustifyV("TOP")
    styleBodyText(frame.macroBody)

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

    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
            addon:Print("GH3 Helper cannot close during combat. It will close after combat ends.")
            return false
        end

        self.pendingCloseAfterCombat = 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 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
            self.statusText:SetText("Batch executed. GH3 Helper will refresh the roster and resume the next batch step after combat.")
            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
            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
        local searchText = self.kickSearchInput and self.kickSearchInput:GetText() or ""
        local includeLinkedAlts = self.linkedAltCheckbox and self.linkedAltCheckbox:GetChecked() and true or false

        showDropdownMenu(anchor, function()
            local options = {}
            local matches = service:ListKickSearchMatches(searchText, 12)
            if trimString(searchText) == "" then
                return { { text = "Type a toon name", disabled = true } }
            end
            if #matches <= 0 then
                return { { text = "No current-guild matches", disabled = true } }
            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,
                    func = 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)
        dialog:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        dialog:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        dialog:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)
        dialog:Hide()

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

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

        local previewHost = createPanel(dialog, { x = 14, y = -56, w = 492, h = 190 })
        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)
        dialog.previewText = previewContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        dialog.previewText:SetPoint("TOPLEFT", previewContent, "TOPLEFT", 0, 0)
        dialog.previewText:SetWidth(450)
        dialog.previewText:SetJustifyH("LEFT")
        dialog.previewText:SetJustifyV("TOP")
        styleBodyText(dialog.previewText)
        dialog.previewContent = previewContent

        dialog.runMacroButton = createSecureMacroButton(dialog, { x = 398, y = -262, w = 108, h = 24 }, "Run Macro")
        dialog.runMacroButton:SetScript("OnClick", function()
            if 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()
                    frame:HandleBatchRunPostClick()
                end)
            else
                frame:HandleBatchRunPostClick()
            end
        end)

        createButton(dialog, { x = 302, y = -262, w = 88, h = 24 }, "Close", function()
            frame.pendingPromotionChain = nil
            frame:HideBatchRunDialogAfterCombatSafe(true)
        end, false)

        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 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
            self.statusText:SetText("GH3 Helper will reopen the batch confirmation after combat.")
            return
        end

        local 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.Text: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.Text:SetText("Run Macro")
        end

        dialog.previewText:SetText(macroText)
        dialog.previewContent:SetHeight(math.max(dialog.previewText:GetStringHeight() + 8, 40))
        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)
        dialog.runMacroButton:SetEnabled(not (InCombatLockdown and InCombatLockdown()))
        self.pendingBatchRunDialogShowAfterCombat = false
        dialog:Show()
    end

    function frame:ApplyModeLayout(mode)
        local isPromote = mode == "promote"
        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)
    end

    function frame:ClearQueueRows()
        for _, row in ipairs(self.queueRows or {}) do
            row:Hide()
            row:SetParent(nil)
        end
        self.queueRows = {}
    end

    function frame:AddQueueRow(index, mode, entry, settings)
        local row = createListRow(self.queueContent, { x = 0, y = -((index - 1) * 34), w = 668, h = 28 }, index % 2 == 0)
        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

        if mode == "kick" then
            createButton(row, { x = 588, y = -4, w = 72, h = 20 }, service:IsMemberSelected(mode, entry.memberId) and "Selected" or "Select", function()
                service:ToggleSelectedMember(mode, entry.memberId)
                frame.macroPayload = nil
                frame:RefreshData(false)
            end, service:IsMemberSelected(mode, entry.memberId))

            createButton(row, { x = 588 + 78, y = -4, w = 72, h = 20 }, entry.manualKick and "Remove" or "Exclude", function()
                if entry.manualKick then
                    service:RemoveManualKickMember(entry.memberId)
                else
                    service:ExcludeMember(entry.memberId)
                end
                frame.macroPayload = nil
                frame:RefreshData(false)
            end, false)
        else
            createButton(row, { x = 588, y = -4, w = 72, h = 20 }, service:IsMemberSelected(mode, entry.memberId) and "Selected" or "Select", function()
                service:ToggleSelectedMember(mode, entry.memberId)
                frame.macroPayload = nil
                frame:RefreshData(false)
            end, service:IsMemberSelected(mode, entry.memberId))
        end

        self.queueRows[#self.queueRows + 1] = row
        return 34
    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, applyRefresh)
            if mode == "promote" then
                C_Timer.After(2.5, applyRefresh)
            end
        else
            applyRefresh()
        end
    end

    function frame:RefreshData(preserveMacroPayload)
        service:ScanRoster()
        local mode = self.mode == "promote" and "promote" or "kick"
        local settings = service:GetSettings()
        local summary = service:GetSummary()
        local entries = mode == "promote" and service:ListPromotionEntries() or service:ListCleanupEntries("kick")
        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:ApplyModeLayout(mode)

        if not preserveMacroPayload or type(self.macroPayload) ~= "table" then
            if mode == "promote" then
                self.macroPayload = service:BuildPromotionMacro(service:GetEffectivePromotionMacroMemberIds())
            else
                self.macroPayload = service:BuildCleanupMacro(service:GetEffectiveCleanupMacroMemberIds())
            end
        end

        self.queuePages[mode] = currentPage
        self.queueTotalPages = totalPages
        for index = pageStart, pageEnd do
            visibleEntries[#visibleEntries + 1] = entries[index]
        end

        self.kickTabButton.Text:SetText(mode == "kick" and "Kick *" or "Kick")
        self.promoteTabButton.Text:SetText(mode == "promote" and "Promote *" or "Promote")
        self.inactiveDaysInput:SetText(tostring(settings.staleAfterDays or summary.staleAfterDays or 30))
        self.scopeButton.Text:SetText("Scope: " .. getCleanupKickScopeLabel(settings.kickScope))
        self.linkedAltCheckbox:SetChecked(settings.kickLinkedAltsWithMain and true or false)
        self.fromRankButton.Text:SetText(getPromotionRankButtonText("From", settings.promoteFromRank))
        self.toRankButton.Text:SetText(getPromotionRankButtonText("To", settings.promoteToRank))
        self.promotionTenureButton.Text:SetText(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.queuePageLabel:SetText(string.format("Page %d/%d", currentPage, totalPages))
        self.prevQueuePageButton:SetEnabled(currentPage > 1)
        self.nextQueuePageButton:SetEnabled(currentPage < totalPages)

        self:ClearQueueRows()
        self.queueScroll:SetVerticalScroll(0)

        if #entries <= 0 then
            self.queueBody:Show()
            self.queueBody:SetText(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.")
            self.queueContent:SetHeight(math.max(self.queueBody:GetStringHeight() + 8, 40))
        else
            self.queueBody:Hide()
            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

        local macroText = (self.macroPayload and self.macroPayload.body ~= "") and self.macroPayload.body or (#entries > 0 and (mode == "promote" and "No members are selected for this promotion batch yet." or "No members are selected for this kick batch yet.") or (mode == "promote" and "No promotion candidates fit in the macro body limit yet." or "No kick candidates fit in the macro body limit yet."))
        self.macroBody:SetText(macroText)
        self.macroContent:SetHeight(math.max(self.macroBody:GetStringHeight() + 8, 90))

        local canRemove = service:CanGuildRemoveMembers()
        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 batch execution 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

    frame.controller = CreateFrame("Frame")
    frame.controller:RegisterEvent("PLAYER_REGEN_ENABLED")
    frame.controller:SetScript("OnEvent", function()
        if frame and frame.ProcessDeferredCombatActions then
            frame:ProcessDeferredCombatActions()
        end
    end)

    page._guildCleanupDialog = frame
    return frame
end

function page: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 dialog = ensureGuildCleanupDialog()
    if not dialog:IsShown() and dialog.ResetKickSearchState then
        dialog:ResetKickSearchState(true)
    end

    dialog.mode = initialMode == "kick" and "kick" or "promote"
    dialog.macroPayload = nil
    dialog:RefreshData()
    dialog:Show()
    dialog:Raise()
end

function page:ApplyConfiguredPopupTransparency()
    applyHelperPopupTransparency(self._guildCleanupDialog)
end

local function createTesterPanel(parent, rect)
    local panel = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(panel, parent, rect)

    local background = panel:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(panel)
    applyArtTexture(background, PANEL_ART_KEY, { 0.09, 0.10, 0.13, 0.94 })

    return panel
end

local function createPreviewSurface(parent, rect, textureKey)
    local panel = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(panel, parent, rect)

    local background = panel:CreateTexture(nil, "BACKGROUND")
    background:SetAllPoints(panel)
    applyArtTexture(background, textureKey or "MyToonsRowBackground", { 0.08, 0.09, 0.12, 0.96 })
    return panel
end

local function closeTesterPreview()
    page.State.previewOverlay = nil
    if page._parentFrame then
        refreshPage(page._parentFrame, page._snapshot)
    end
end

local function openTesterPreview(kind)
    page.State.previewOverlay = kind
    if page._parentFrame then
        refreshPage(page._parentFrame, page._snapshot)
    end
end

local function createPreviewOverlay(parent, width, height, titleText, subtitleText)
    local overlay = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    overlay:SetAllPoints(parent)
    overlay:SetFrameStrata(parent:GetFrameStrata())
    overlay:SetFrameLevel((parent:GetFrameLevel() or 0) + 60)

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

    local dialog = createTesterPanel(overlay, { x = math.floor((1380 - width) / 2), y = -48, w = width, h = height })
    dialog:SetFrameLevel((overlay:GetFrameLevel() or 0) + 2)

    local title = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 18, -18)
    title:SetText(titleText or "Preview")

    local subtitle = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    subtitle:SetWidth(width - 180)
    subtitle:SetJustifyH("LEFT")
    subtitle:SetText(subtitleText or "")
    subtitle:SetTextColor(0.82, 0.80, 0.74, 1)

    local closeButton = createButton(dialog, { x = width - 150, y = -(height - 48), w = 132, h = 30 }, "Close", closeTesterPreview, false)

    overlay.Dialog = dialog
    overlay.CloseButton = closeButton
    overlay.Title = title
    overlay.Subtitle = subtitle
    return overlay
end

local function showBISLootPreview()
    if addon.BISService and addon.BISService.QueueLootHelperPreviewRows then
        addon.BISService:QueueLootHelperPreviewRows()
    end
    page.State.previewOverlay = nil
    if addon.BISService and addon.BISService.UpdateLootHelperSettings then
        addon.BISService:UpdateLootHelperSettings({ compactPopup = false })
    end
    if addon.NewUIBIS and addon.NewUIBIS.ShowDropReviewDialog then
        addon.NewUIBIS:ShowDropReviewDialog()
    end
end

local function buildTesterFullName(shortName)
    if addon.Store and addon.Store.NormalizeFullName then
        return addon.Store:NormalizeFullName(shortName or "Unknown", GetRealmName() or "Unknown")
    end

    return tostring(shortName or "Unknown")
end

local function clearTesterMythicPreview()
    page.State.previewOverlay = nil

    if addon.MythicPlusService and addon.MythicPlusService.ClearTestMythicPlusPreview then
        addon.MythicPlusService:ClearTestMythicPlusPreview("settings-tester")
    end
end

local function seedMythicSummaryPreview()
    local service = addon.MythicPlusService
    if not (service and service.GetRuntimeState) then
        return false
    end

    local runtime = service:GetRuntimeState().summary
    local groupOrder = {
        buildTesterFullName("Heretanky"),
        buildTesterFullName("Luciifferr"),
        buildTesterFullName("Tinoko"),
        buildTesterFullName("Maniacdh"),
        buildTesterFullName("Wincheester"),
    }
    local startScores = {
        [groupOrder[1]] = 2673,
        [groupOrder[2]] = 1885,
        [groupOrder[3]] = 332,
        [groupOrder[4]] = 950,
        [groupOrder[5]] = 1885,
    }
    local endScores = {
        [groupOrder[1]] = 2673,
        [groupOrder[2]] = 1885,
        [groupOrder[3]] = 342,
        [groupOrder[5]] = 1885,
    }
    local classMap = {
        [groupOrder[1]] = "PALADIN",
        [groupOrder[2]] = "MAGE",
        [groupOrder[3]] = "MONK",
        [groupOrder[4]] = "DEMONHUNTER",
        [groupOrder[5]] = "EVOKER",
    }

    runtime.active = true
    runtime.completedAt = time and time() or 0
    runtime.isPreview = true
    runtime.lootSeen = {}
    runtime.token = (tonumber(runtime.token) or 0) + 1
    runtime.run = {
        completedAt = runtime.completedAt,
        deaths = 8,
        duration = (17 * 60) + 34,
        keyLevel = 2,
        mapName = "Pit of Saron",
        newKey = {
            keyLevel = 10,
            mapName = "MC",
        },
        onTime = true,
        playerClasses = classMap,
        reason = "settings-tester",
        scoresEnd = {
            classes = classMap,
            order = groupOrder,
            scores = endScores,
        },
        scoresStart = {
            classes = classMap,
            order = groupOrder,
            scores = startScores,
        },
        timeLimit = 30 * 60,
        upgradeLevels = 3,
    }
    runtime.loot = {
        {
            owner = groupOrder[5],
            itemLevel = 237,
            itemName = "Horns of the Spurned Val'kyr",
        },
        {
            owner = groupOrder[2],
            itemLevel = 250,
            itemName = "Cloak of the Fallen Cardinal",
        },
        {
            owner = groupOrder[1],
            itemLevel = 250,
            itemName = "Flayer's Black Belt",
        },
    }

    return true
end

local function seedMythicTimerPreview()
    local service = addon.MythicPlusService
    if not (service and service.GetRuntimeState) then
        return false
    end

    local runtime = service:GetRuntimeState().overlay
    runtime.previewSnapshot = {
        active = true,
        affixes = "Tyrannical  Bursting  Volcanic",
        deaths = 7,
        elapsed = (27 * 60) + 14,
        forces = {
            current = 86,
            isTrustedForces = true,
            quantityLabel = "86%",
            total = 100,
        },
        keyLevel = 10,
        mapName = "The Dawnbreaker",
        objectiveDone = 3,
        objectiveRows = {
            { completed = true, label = "Speaker Shadowcrown" },
            { completed = false, current = 3, label = "Free the Dawnbreaker captives", quantityLabel = "3/5", total = 5 },
            { completed = true, label = "Anub'ikkaj" },
            { completed = true, label = "Rasha'nan" },
            { completed = false, label = "The Ascendant Viscoxria" },
        },
        objectiveTotal = 4,
        remaining = (2 * 60) + 46,
        timeLimit = 30 * 60,
    }
    runtime.previewTrackerReadings = nil
    return true
end

local function showMythicSummaryPreview()
    clearTesterMythicPreview()

    if seedMythicSummaryPreview() then
        page.State.previewOverlay = nil
        if addon.MythicPlusService and addon.MythicPlusService.RenderMythicPlusCompletionSummary then
            addon.MythicPlusService:RenderMythicPlusCompletionSummary()
        end
    end
end

local function showMythicTimerPreview()
    clearTesterMythicPreview()

    if seedMythicTimerPreview() then
        page.State.previewOverlay = nil
        if addon.MythicPlusService and addon.MythicPlusService.RefreshMythicPlusOverlay then
            addon.MythicPlusService:RefreshMythicPlusOverlay("settings")
        end
    end
end

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

local function buildActionSummary(row)
    local parts = {}
    if row.canWhisper then parts[#parts + 1] = "Whisper" end
    if row.canClaim then parts[#parts + 1] = "Claim" end
    if row.canOffer then parts[#parts + 1] = "Offer" end
    if row.canRoll then parts[#parts + 1] = "Roll" end
    if row.canTrade then parts[#parts + 1] = "Trade" end
    return #parts > 0 and table.concat(parts, "  ") or "Preview"
end

local function attachPreviewTooltip(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 trimString(bodyText) ~= "" 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 buildPreviewBadgeStyle(kind, targetId)
    if kind == "offer" or kind == "self-offer" then
        return trimString(targetId) ~= "" and "CLAIMED" or "OFFER", kind == "offer" and trimString(targetId) ~= "" and "MyToonsCardBackground" or BUTTON_ART_KEY
    end
    return "YOURBIS", "MyToonsRowBackground"
end

local function buildPreviewStatusBadge(rowData)
    if rowData and rowData.leftGroup then return "LEFT" 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
    return highestRoll > 0 and "ROLL" or nil
end

local function buildPreviewInterestSummary(rowData)
    local claimantNames, 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] = splitPreviewName(claimantId)
    end
    for rollerId, rollValue in pairs(type(rowData and rowData.rolls) == "table" and rowData.rolls or {}) do
        local resolvedRoll = tonumber(rollValue) or 0
        rollEntries[#rollEntries + 1] = string.format("%s%s %d", resolvedRoll == highestRoll and highestRoll > 0 and "Winner " or "", splitPreviewName(rollerId), resolvedRoll)
    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 listPreviewReviewRows(service)
    local settings = service and service.GetLootHelperSettings and service:GetLootHelperSettings() or {}
    local rows, allRows = {}, service and service.ListActiveDropReviewRows and service:ListActiveDropReviewRows() or {}
    for _, rowData in ipairs(allRows) do
        if settings.reviewFilter == "all" or trimString(rowData.kind) == trimString(settings.reviewFilter) then
            rows[#rows + 1] = rowData
        end
    end
    table.sort(rows, function(left, right)
        if settings.reviewSort == "owner" then
            local leftOwner = string.lower(splitPreviewName(left.ownerId or ""))
            local rightOwner = string.lower(splitPreviewName(right.ownerId or ""))
            return leftOwner == rightOwner and (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0) or leftOwner < rightOwner
        end
        if settings.reviewSort == "ilevel" then
            local leftLevel = tonumber(left.itemLevel) or 0
            local rightLevel = tonumber(right.itemLevel) or 0
            return leftLevel == rightLevel and (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0) or leftLevel > rightLevel
        end
        return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
    end)
    return rows, allRows, settings
end

local function createPreviewCard(parent, rect, labelText, valueText, accentColor, textureKey, iconTexturePath)
    local card = CreateFrame("Frame", nil, parent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    ApplyRect(card, parent, rect)

    local tone = accentColor or { 0.98, 0.90, 0.64, 1 }
    local hasLabel = trimString(labelText) ~= ""
    local fill = card:CreateTexture(nil, "BACKGROUND")
    fill:SetAllPoints(card)
    fill:SetColorTexture(tone[1] * 0.12, tone[2] * 0.12, tone[3] * 0.12, 0.70)

    local tint = card:CreateTexture(nil, "ARTWORK")
    tint:SetAllPoints(card)
    applyArtTexture(tint, textureKey or "MyToonsCardBackground", { 0.08, 0.09, 0.12, 0.96 })
    tint:SetVertexColor(1, 1, 1, 0.20)

    local frame = card:CreateTexture(nil, "OVERLAY")
    frame:SetAllPoints(card)
    applyArtTexture(frame, textureKey or "MyToonsCardBackground", { 1, 1, 1, 1 })
    frame:SetVertexColor(1, 1, 1, 1)

    local headerRule = card:CreateTexture(nil, "OVERLAY")
    headerRule:SetPoint("TOPLEFT", card, "TOPLEFT", 18, -32)
    headerRule:SetPoint("TOPRIGHT", card, "TOPRIGHT", -46, -32)
    headerRule:SetHeight(1)
    headerRule:SetColorTexture(tone[1], tone[2], tone[3], 0.40)

    local label = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("TOP", card, "TOP", 0, -14)
    label:SetWidth((tonumber(rect.w) or 1) - 58)
    label:SetJustifyH("CENTER")
    label:SetText(labelText or "")
    label:SetFont("Fonts\\FRIZQT__.TTF", 13, "")
    label:SetTextColor(tone[1], tone[2], tone[3], 1)
    if not hasLabel then
        label:Hide()
        headerRule:Hide()
    end

    if trimString(iconTexturePath or "") ~= "" then
        local icon = card:CreateTexture(nil, "OVERLAY")
        icon:SetTexture(iconTexturePath)
        icon:SetTexCoord(0.16, 0.84, 0.16, 0.84)
        icon:SetVertexColor(tone[1], tone[2], tone[3], 1)
        icon:SetPoint("TOPRIGHT", card, "TOPRIGHT", -14, -10)
        icon:SetSize(20, 20)
    end

    local value = card:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    if hasLabel then
        value:SetPoint("TOPLEFT", card, "TOPLEFT", 12, -34)
        value:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -12, 12)
        value:SetWidth((tonumber(rect.w) or 1) - 28)
    else
        value:SetPoint("CENTER", card, "CENTER", 0, -1)
        value:SetWidth((tonumber(rect.w) or 1) - 16)
    end
    value:SetJustifyH("CENTER")
    value:SetJustifyV("MIDDLE")
    value:SetFont("Fonts\\FRIZQT__.TTF", 16, "")
    value:SetText(valueText or "-")
    value:SetTextColor(unpack(tone))
    card.HeaderRule = headerRule
    card.Label = label
    card.Value = value
    return card
end

local function createPreviewTextureButton(parent, rect, textureKey, hoverTextureKey, onClick)
    local button = CreateFrame("Button", nil, parent)
    ApplyRect(button, parent, rect)

    local texture = button:CreateTexture(nil, "ARTWORK")
    texture:SetAllPoints(button)
    applyArtTexture(texture, textureKey, { 1, 1, 1, 1 })

    button:SetScript("OnEnter", function()
        applyArtTexture(texture, hoverTextureKey or textureKey, { 1, 1, 1, 1 })
    end)
    button:SetScript("OnLeave", function()
        applyArtTexture(texture, textureKey, { 1, 1, 1, 1 })
    end)
    button:SetScript("OnClick", function()
        if onClick then
            onClick(button)
        end
    end)

    return button
end

local function getPreviewClassColor(classFile)
    local colors = RAID_CLASS_COLORS or CUSTOM_CLASS_COLORS
    local token = string.upper(trimString(classFile or ""))
    local color = colors and colors[token] or nil
    if color then
        return color.r, color.g, color.b, 1
    end
    return 0.96, 0.93, 0.84, 1
end

local function decorateSummaryPreviewDialog(dialog)
    dialog._gh3SummaryPreview = dialog._gh3SummaryPreview or {}

    local function ensureTexture(key, layer, textureKey)
        if not dialog._gh3SummaryPreview[key] then
            dialog._gh3SummaryPreview[key] = dialog:CreateTexture(nil, layer or "ARTWORK")
        end

        local texture = dialog._gh3SummaryPreview[key]
        texture:ClearAllPoints()
        applyArtTexture(texture, textureKey, { 0.08, 0.09, 0.12, 0.96 })
        texture:SetVertexColor(1, 1, 1, 0.98)
        texture:Show()
        return texture
    end

    local background = ensureTexture("background", "BACKGROUND", "ShellFrameCenter")
    background:SetAllPoints(dialog)
    background:SetVertexColor(1, 1, 1, 1)

    local shade = ensureTexture("shade", "BACKGROUND", "MyToonsPanelBackground")
    shade:SetAllPoints(dialog)
    shade:SetColorTexture(0.03, 0.03, 0.04, 0.26)

    local top = ensureTexture("top", "OVERLAY", "ShellFrameTopEdge")
    top:SetPoint("TOPLEFT", dialog, "TOPLEFT", 70, 0)
    top:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -70, 0)
    top:SetHeight(24)

    local bottom = ensureTexture("bottom", "OVERLAY", "ShellFrameBottomEdge")
    bottom:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 70, 0)
    bottom:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -70, 0)
    bottom:SetHeight(24)

    local left = ensureTexture("left", "OVERLAY", "ShellFrameLeftEdge")
    left:SetPoint("TOPLEFT", dialog, "TOPLEFT", 0, -70)
    left:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 0, 70)
    left:SetWidth(24)

    local right = ensureTexture("right", "OVERLAY", "ShellFrameRightEdge")
    right:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", 0, -70)
    right:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", 0, 70)
    right:SetWidth(24)

    local topLeft = ensureTexture("topLeft", "OVERLAY", "ShellFrameTopLeft")
    topLeft:SetPoint("TOPLEFT", dialog, "TOPLEFT", 0, 0)
    topLeft:SetSize(76, 76)

    local topRight = ensureTexture("topRight", "OVERLAY", "ShellFrameTopRight")
    topRight:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", 0, 0)
    topRight:SetSize(76, 76)

    local bottomLeft = ensureTexture("bottomLeft", "OVERLAY", "ShellFrameBottomLeft")
    bottomLeft:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 0, 0)
    bottomLeft:SetSize(76, 76)

    local bottomRight = ensureTexture("bottomRight", "OVERLAY", "ShellFrameBottomRight")
    bottomRight:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", 0, 0)
    bottomRight:SetSize(76, 76)

    local titleBar = ensureTexture("titleBar", "ARTWORK", "HomePanelHeader")
    titleBar:SetPoint("TOPLEFT", dialog, "TOPLEFT", 28, -16)
    titleBar:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -28, -16)
    titleBar:SetHeight(36)

    local ornament = ensureTexture("ornament", "OVERLAY", "ShellHeaderMain")
    ornament:SetPoint("TOP", dialog, "TOP", 0, -10)
    ornament:SetSize(320, 20)
end

local function decorateBISPreviewDialog(dialog)
    dialog._gh3BISPreview = dialog._gh3BISPreview or {}

    local function ensureTexture(key, layer, textureKey)
        if not dialog._gh3BISPreview[key] then
            dialog._gh3BISPreview[key] = dialog:CreateTexture(nil, layer or "ARTWORK")
        end

        local texture = dialog._gh3BISPreview[key]
        texture:ClearAllPoints()
        applyArtTexture(texture, textureKey, { 0.08, 0.09, 0.12, 0.96 })
        texture:SetVertexColor(1, 1, 1, 0.98)
        texture:Show()
        return texture
    end

    local background = ensureTexture("background", "BACKGROUND", "MyToonsPanelBackground")
    background:SetAllPoints(dialog)

    local header = ensureTexture("header", "ARTWORK", "HomePanelHeader")
    header:SetPoint("TOPLEFT", dialog, "TOPLEFT", 18, -8)
    header:SetPoint("TOPRIGHT", dialog, "TOPRIGHT", -18, -8)
    header:SetHeight(34)

    local footer = ensureTexture("footer", "ARTWORK", "HomeFooterBackground")
    footer:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 18, 8)
    footer:SetPoint("BOTTOMRIGHT", dialog, "BOTTOMRIGHT", -18, 8)
    footer:SetHeight(34)

    local ornament = ensureTexture("ornament", "OVERLAY", "ShellHeaderMain")
    ornament:SetPoint("TOP", dialog, "TOP", 0, -2)
    ornament:SetSize(240, 18)

    if not dialog._gh3BISPreview.icon then
        dialog._gh3BISPreview.icon = dialog:CreateTexture(nil, "OVERLAY")
    end

    dialog._gh3BISPreview.icon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_BIS-l.png")
    dialog._gh3BISPreview.icon:SetPoint("TOPLEFT", dialog, "TOPLEFT", 24, -16)
    dialog._gh3BISPreview.icon:SetSize(40, 40)
end

local function renderBISLootPreviewOverlay(parent)
    local service = addon.BISService
    local settings = service and service:GetLootHelperSettings() or {}
    local activeRows = service and service:ListActiveDropReviewRows() or {}
    local historyRows = service and service:ListLootHistory() or {}
    local overlay = createPreviewOverlay(parent, 620, 642, "GH3 Loot Helper", "Configure how GH3 builds the BIS Drops review queue.")
    local dialog = overlay.Dialog

    local helper = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    helper:SetPoint("TOPLEFT", dialog, "TOPLEFT", 18, -64)
    helper:SetWidth(560)
    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.")
    helper:SetTextColor(0.82, 0.80, 0.74, 1)

    local modeLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    modeLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 18, -116)
    modeLabel:SetText("Queue Mode")
    modeLabel:SetTextColor(0.82, 0.80, 0.74, 1)

    createButton(dialog, { x = 18, y = -140, w = 220, h = 24 }, settings.mode == "all-tradeable" and "All Tradeable Loot" or "BIS Only", nil, false)

    local detectionLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    detectionLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 258, -116)
    detectionLabel:SetText("Detection")
    detectionLabel:SetTextColor(0.82, 0.80, 0.74, 1)

    createButton(dialog, { x = 258, y = -140, w = 326, h = 24 }, settings.detectionMode == "broad" and "Broad Best-Effort Detection" or "Strict Tradable Detection", nil, false)

    local optionsPanel = createPreviewSurface(dialog, { x = 18, y = -182, w = 566, h = 178 }, PANEL_ART_KEY)
    local optionsTitle = optionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    optionsTitle:SetPoint("TOPLEFT", optionsPanel, "TOPLEFT", 14, -12)
    optionsTitle:SetText("Popup Options")
    optionsTitle:SetTextColor(0.98, 0.90, 0.64, 1)

    local optionLabels = {
        { text = "Auto open the BIS Drops popup when new rows arrive", checked = settings.autoOpenPopup and true or false },
        { text = "Play a sound when the BIS Drops popup opens", checked = settings.playPopupSound and true or false },
        { text = "Whisper the other player when GH3 sees a successful trade close", checked = settings.whisperTradeCompletion and true or false },
        { text = "Pin the BIS Drops popup and remember its dragged position", checked = settings.pinPopupPosition and true or false },
        { text = "Keep active rows until dismissed instead of auto-expiring them", checked = settings.keepRowsUntilDismissed and true or false },
    }

    for index, option in ipairs(optionLabels) do
        local mark = optionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        mark:SetPoint("TOPLEFT", optionsPanel, "TOPLEFT", 14, -36 - ((index - 1) * 26))
        mark:SetText(option.checked and "[x]" or "[ ]")
        mark:SetTextColor(0.98, 0.90, 0.64, 1)

        local label = optionsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", mark, "RIGHT", 8, 0)
        label:SetWidth(510)
        label:SetJustifyH("LEFT")
        label:SetText(option.text)
        label:SetTextColor(0.90, 0.88, 0.82, 1)
    end

    local queuePanel = createPreviewSurface(dialog, { x = 18, y = -376, w = 566, h = 96 }, PANEL_ART_KEY)
    local queueTitle = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    queueTitle:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 14, -12)
    queueTitle:SetText("Queue")
    queueTitle:SetTextColor(0.98, 0.90, 0.64, 1)

    local queueSummary = queuePanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    queueSummary:SetPoint("TOPLEFT", queuePanel, "TOPLEFT", 14, -42)
    queueSummary:SetWidth(370)
    queueSummary:SetJustifyH("LEFT")
    queueSummary:SetText(string.format("%d active review row(s). Mode: %s. Detection: %s.", #activeRows, settings.mode == "all-tradeable" and "All Tradeable Loot" or "BIS Only", settings.detectionMode == "broad" and "Broad Best-Effort" or "Strict Tradable"))
    queueSummary:SetTextColor(0.82, 0.80, 0.74, 1)

    createButton(queuePanel, { x = 410, y = -34, w = 142, h = 28 }, "Open Active Drops", function() openTesterPreview("bis-drops") end, false)

    local historyPanel = createPreviewSurface(dialog, { x = 18, y = -486, w = 566, h = 118 }, PANEL_ART_KEY)
    local historyTitle = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    historyTitle:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 14, -12)
    historyTitle:SetText("Loot History")
    historyTitle:SetTextColor(0.98, 0.90, 0.64, 1)

    local historyPolicy = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    historyPolicy:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 14, -34)
    historyPolicy:SetWidth(530)
    historyPolicy:SetJustifyH("LEFT")
    historyPolicy:SetText(service and service.GetLootHistoryPolicyText and service:GetLootHistoryPolicyText() or "History unavailable.")
    historyPolicy:SetTextColor(0.82, 0.80, 0.74, 1)

    for index = 1, math.min(#historyRows, 3) do
        local entry = historyRows[index]
        local line = historyPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        line:SetPoint("TOPLEFT", historyPanel, "TOPLEFT", 14, -62 - ((index - 1) * 16))
        line:SetWidth(530)
        line:SetJustifyH("LEFT")
        line:SetText(string.format("%s | %s | %s", entry.at and date("%m/%d %H:%M", entry.at) or "--", trimString(entry.outcome) ~= "" and trimString(entry.outcome) or "event", trimString(entry.itemLink) ~= "" and trimString(entry.itemLink) or (trimString(entry.itemName) ~= "" and trimString(entry.itemName) or "Item")))
    end

    return overlay
end

local function renderBISDropReviewPreviewOverlay(parent)
    local service = addon.BISService
    local rows, allRows, settings = listPreviewReviewRows(service)
    local compact = page.State.bisDropsCompact == true
    local overlay = createPreviewOverlay(parent, compact and 920 or 960, compact and 170 or 560, compact and "GH3 BIS" or "GH3 BIS Drops", compact and "" or "W whisper, C claim, O offer, R roll, T trade, D dismiss.")
    local dialog = overlay.Dialog
    decorateBISPreviewDialog(dialog)

    if overlay.Title then
        overlay.Title:ClearAllPoints()
        overlay.Title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 78, -18)
    end

    if overlay.Subtitle then
        overlay.Subtitle:ClearAllPoints()
        overlay.Subtitle:SetPoint("TOPLEFT", dialog, "TOPLEFT", 78, compact and -42 or -48)
        overlay.Subtitle:SetWidth((compact and 360 or 500))
        overlay.Subtitle:SetShown(not compact)
    end

    if overlay.CloseButton then
        overlay.CloseButton:SetShown(not compact)
    end

    local sortButton = createSelectButton(dialog, { x = compact and 468 or 520, y = -64, w = compact and 92 or 96, h = 24 }, service and service.GetLootReviewSortLabel and service:GetLootReviewSortLabel() or "Newest", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service and service.GetLootReviewSortOptions and service:GetLootReviewSortOptions() or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:UpdateLootHelperSettings({ reviewSort = value }); openTesterPreview("bis-drops") end }
            end
            return items
        end)
    end)
    local filterButton = createSelectButton(dialog, { x = compact and 570 or 624, y = -64, w = compact and 104 or 118, h = 24 }, service and service.GetLootReviewFilterLabel and service:GetLootReviewFilterLabel() or "All Rows", function(anchor)
        showDropdownMenu(anchor, function()
            local items = {}
            for _, option in ipairs(service and service.GetLootReviewFilterOptions and service:GetLootReviewFilterOptions() or {}) do
                items[#items + 1] = { text = option.label, value = option.id, func = function(value) service:UpdateLootHelperSettings({ reviewFilter = value }); openTesterPreview("bis-drops") end }
            end
            return items
        end)
    end)
    attachPreviewTooltip(sortButton, "Sort", "Choose how the BIS rows are ordered.")
    attachPreviewTooltip(filterButton, "Filter", "Choose which BIS rows are shown.")
    createButton(dialog, { x = compact and 686 or 752, y = -64, w = 32, h = 24 }, "O", nil, false)
    createButton(dialog, { x = compact and 726 or 790, y = -64, w = 32, h = 24 }, "T", nil, false)
    createButton(dialog, { x = compact and 766 or 828, y = -64, w = 32, h = 24 }, "D", function() if service and service.ClearDropReviewRows then service:ClearDropReviewRows() end; openTesterPreview("bis-drops") end, false)
    createButton(dialog, { x = compact and 806 or 866, y = -64, w = 32, h = 24 }, "P", function() if service and service.UpdateLootHelperSettings then service:UpdateLootHelperSettings({ pinPopupPosition = not (settings and settings.pinPopupPosition) }) end; openTesterPreview("bis-drops") end, false)
    createButton(dialog, { x = compact and 846 or 904, y = -64, w = 32, h = 24 }, compact and "v" or "^", function()
        page.State.bisDropsCompact = not compact
        openTesterPreview("bis-drops")
    end, false)

    if compact then
        local activeLabel = dialog:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
        activeLabel:SetPoint("TOPLEFT", dialog, "TOPLEFT", 280, -22)
        activeLabel:SetText(string.format("%d/%d Active", #rows, #allRows))
        activeLabel:SetTextColor(0.54, 0.92, 0.44, 1)

        local footerCounts = dialog:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        footerCounts:SetPoint("BOTTOMLEFT", dialog, "BOTTOMLEFT", 34, 24)
        footerCounts:SetWidth(760)
        footerCounts:SetJustifyH("LEFT")
        footerCounts:SetText(string.format("%d Active     O %d     T %d     D %d     P %d", #rows, service and service.ListOfferableDropReviewRows and #(service:ListOfferableDropReviewRows() or {}) or 0, service and service.ListCurrentGroupTradeTargets and #(service:ListCurrentGroupTradeTargets() or {}) or 0, #rows, settings and settings.pinPopupPosition and 1 or 0))
        footerCounts:SetTextColor(0.96, 0.90, 0.68, 1)
        return overlay
    end

    local header = createPreviewSurface(dialog, { x = 18, y = -100, w = 924, h = 24 }, "MyToonsCardBackground")
    local headers = {
        { x = 12, text = "Owner" },
        { x = 168, text = "Item" },
        { x = 646, text = "ilvl" },
        { x = 706, text = "Actions" },
    }
    for _, entry in ipairs(headers) do
        local label = header:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", header, "LEFT", entry.x, 0)
        label:SetText(entry.text)
        label:SetTextColor(0.98, 0.90, 0.64, 1)
    end

    for index = 1, math.min(#rows, 5) do
        local row = rows[index]
        local rowPanel = createPreviewSurface(dialog, { x = 18, y = -132 - ((index - 1) * 62), w = 924, h = 56 }, "MyToonsRowBackground")

        local owner = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        owner:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", 12, -10)
        owner:SetWidth(140)
        owner:SetJustifyH("LEFT")
        owner:SetText(splitPreviewName(row.ownerId))

        local ownerMeta = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        ownerMeta:SetPoint("TOPLEFT", owner, "BOTTOMLEFT", 0, -4)
        ownerMeta:SetWidth(140)
        ownerMeta:SetJustifyH("LEFT")
        ownerMeta:SetText(trimString(row.slotLabel) ~= "" and row.slotLabel or "Loot holder")
        ownerMeta:SetTextColor(0.82, 0.80, 0.74, 1)

        local item = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        item:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", 168, -10)
        item:SetWidth(460)
        item:SetJustifyH("LEFT")
        item:SetText(trimString(row.itemLink) ~= "" and row.itemLink or (trimString(row.itemName) ~= "" and row.itemName or "Item"))

        local meta = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        meta:SetPoint("TOPLEFT", item, "BOTTOMLEFT", 0, -4)
        meta:SetWidth(460)
        meta:SetJustifyH("LEFT")
        meta:SetText(trimString(row.metaText) ~= "" and row.metaText or "Tracked GH3 BIS review row.")
        meta:SetTextColor(0.82, 0.80, 0.74, 1)

        local level = rowPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        level:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", 646, -18)
        level:SetText((tonumber(row.itemLevel) or 0) > 0 and tostring(tonumber(row.itemLevel) or 0) or "-")

        local actionLane = CreateFrame("Frame", nil, rowPanel)
        actionLane:SetPoint("TOPLEFT", rowPanel, "TOPLEFT", 706, -12)
        actionLane:SetSize(190, 28)

        local nextActionX = 0
        local function addActionButton(label, actionKey, tooltipTitle, tooltipBody)
            local button = createButton(actionLane, { x = nextActionX, y = 0, w = 32, h = 24 }, label, function()
                if actionKey == "T" and trimString(row.kind) == "self-offer" and service and service.ListCurrentGroupTradeTargets and service.SetDropReviewRowTarget then
                    showDropdownMenu(button, function()
                        local items, targets = {}, service:ListCurrentGroupTradeTargets() or {}
                        if #targets <= 0 then
                            items[#items + 1] = { text = "No active group targets", value = "", disabled = true }
                        end
                        for _, targetId in ipairs(targets) do
                            items[#items + 1] = {
                                text = splitPreviewName(targetId),
                                value = targetId,
                                checked = trimString(row.targetId) == trimString(targetId),
                                func = function(value)
                                    service:SetDropReviewRowTarget(row.id, value)
                                    if service:HandleDropReviewAction(row.id, actionKey) then
                                        openTesterPreview("bis-drops")
                                    end
                                end,
                            }
                        end
                        return items
                    end)
                    return
                end
                if service and service.HandleDropReviewAction and service:HandleDropReviewAction(row.id, actionKey) then
                    openTesterPreview("bis-drops")
                end
            end, false)
            attachPreviewTooltip(button, tooltipTitle, tooltipBody)
            if actionKey == "T" and row.leftGroup then
                button:Disable()
                button:SetAlpha(0.45)
                attachPreviewTooltip(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 + 40
            return button
        end

        if row.canWhisper then addActionButton("W", "W", "Whisper", "Ask the holder for the item with a GH3 whisper.") end
        if row.canClaim then addActionButton("C", "C", "Claim", "Tell the owner you want this item.") end
        if row.canOffer then addActionButton("O", "O", "Offer", "Offer this tradable item to the group.") end
        if row.canRoll then addActionButton("R", "R", "Roll", "Submit a GH3 roll for this item.") end
        if row.canTrade then addActionButton("T", "T", "Trade", trimString(row.kind) == "self-offer" and "Use the service-selected trade target for this row." or "Attempt to trade this item to the relevant player.") end
        addActionButton("D", "D", "Dismiss", "Remove this row from the BIS popup.")

        attachPreviewTooltip(rowPanel, trimString(row.itemName) ~= "" and row.itemName or "BIS Review", buildPreviewInterestSummary(row))
    end

    return overlay
end

local function renderMythicSummaryPreviewOverlay(parent)
    local summaryRuntime = addon.MythicPlusService and addon.MythicPlusService.GetRuntimeState and addon.MythicPlusService:GetRuntimeState().summary or {}
    local run = summaryRuntime.run or {}
    local scoreOrder = run.scoresStart and run.scoresStart.order or run.scoresEnd and run.scoresEnd.order or {}
    local startScores = run.scoresStart and run.scoresStart.scores or {}
    local endScores = run.scoresEnd and run.scoresEnd.scores or {}
    local classMap = run.scoresStart and run.scoresStart.classes or run.playerClasses or {}
    local lootRows = summaryRuntime.loot or {}
    local titleText = string.format("+%d %s Summary", math.max(0, tonumber(run.keyLevel) or 0), trimString(run.mapName) ~= "" and trimString(run.mapName) or "Mythic")
    local overlay = createPreviewOverlay(parent, 1020, 620, titleText, "")
    local dialog = overlay.Dialog
    decorateSummaryPreviewDialog(dialog)
    dialog:SetMovable(true)
    dialog:EnableMouse(true)
    dialog:RegisterForDrag("LeftButton")
    dialog:SetScript("OnDragStart", dialog.StartMoving)
    dialog:SetScript("OnDragStop", dialog.StopMovingOrSizing)

    if overlay.CloseButton then
        overlay.CloseButton:Hide()
    end
    if overlay.Subtitle then
        overlay.Subtitle:Hide()
    end
    if overlay.Title then
        overlay.Title:ClearAllPoints()
        overlay.Title:SetPoint("TOP", dialog, "TOP", 18, -18)
        overlay.Title:SetFont("Fonts\\MORPHEUS.ttf", 28, "OUTLINE")
        overlay.Title:SetTextColor(0.98, 0.86, 0.44, 1)
    end

    local closeButton = createPreviewTextureButton(dialog, { x = 966, y = -8, w = 44, h = 44 }, "ShellCloseButton", "ShellCloseButtonHover", closeTesterPreview)
    attachPreviewTooltip(closeButton, "Close", "Close the summary preview.")

    local titleIcon = dialog:CreateTexture(nil, "OVERLAY")
    titleIcon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_MythicPlus-l.png")
    titleIcon:SetPoint("TOPLEFT", dialog, "TOPLEFT", 274, -18)
    titleIcon:SetSize(34, 34)

    local resultText = run.onTime and string.format("Timed +%d", math.max(1, tonumber(run.upgradeLevels) or 0)) or "Depleted"
    local timerText = (tonumber(run.duration) or 0) > 0 and string.format("%02d:%02d / %02d:%02d", math.floor((tonumber(run.duration) or 0) / 60), (tonumber(run.duration) or 0) % 60, math.floor((tonumber(run.timeLimit) or 0) / 60), (tonumber(run.timeLimit) or 0) % 60) or "Pending"
    local newKeyText = string.format("%s +%d", trimString(run.newKey and run.newKey.mapName) ~= "" and trimString(run.newKey.mapName) or "M", math.max(0, tonumber(run.newKey and run.newKey.keyLevel) or 0))

    createPreviewCard(dialog, { x = 36, y = -84, w = 222, h = 96 }, "RESULT", resultText, { 0.78, 0.96, 0.72, 1 }, "HomeMembersCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Ready-l.png")
    createPreviewCard(dialog, { x = 274, y = -84, w = 222, h = 96 }, "TIMER", timerText, { 0.82, 0.92, 1.00, 1 }, "HomeCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\MythicBadge-l.png")
    createPreviewCard(dialog, { x = 512, y = -84, w = 222, h = 96 }, "DEATHS", tostring(math.max(0, tonumber(run.deaths) or 0)), { 1.00, 0.84, 0.74, 1 }, "HomeRaidCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Warning-l.png")
    createPreviewCard(dialog, { x = 750, y = -84, w = 234, h = 96 }, "NEW KEY", newKeyText, { 0.96, 0.88, 1.00, 1 }, "HomeMythicCardBackground", "Interface\\AddOns\\GH3\\Media\\Icons\\Icon_Event_Mythic-l.png")

    local scorePanel = createPreviewSurface(dialog, { x = 36, y = -198, w = 458, h = 368 }, PANEL_ART_KEY)
    local scoreTitle = scorePanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    scoreTitle:SetPoint("TOPLEFT", scorePanel, "TOPLEFT", 44, -16)
    scoreTitle:SetFont("Fonts\\MORPHEUS.ttf", 22, "OUTLINE")
    scoreTitle:SetText("Party Score")
    scoreTitle:SetTextColor(0.98, 0.90, 0.64, 1)

    local scoreIcon = scorePanel:CreateTexture(nil, "OVERLAY")
    scoreIcon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_MythicPlus-l.png")
    scoreIcon:SetPoint("TOPLEFT", scorePanel, "TOPLEFT", 16, -14)
    scoreIcon:SetSize(20, 20)

    local scoreHeader = createPreviewSurface(scorePanel, { x = 16, y = -54, w = 426, h = 26 }, "HomePanelHeader")
    local scoreColumns = {
        { x = 12, text = "Player" },
        { x = 212, text = "Start" },
        { x = 296, text = "End" },
        { x = 374, text = "Gain" },
    }
    for _, column in ipairs(scoreColumns) do
        local label = scoreHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", scoreHeader, "LEFT", column.x, 0)
        label:SetText(column.text)
        label:SetTextColor(0.98, 0.90, 0.64, 1)
    end

    for index = 1, math.min(#scoreOrder, 5) do
        local playerId = scoreOrder[index]
        local beforeValue = tonumber(startScores[playerId])
        local afterValue = tonumber(endScores[playerId])
        local gainValue = (beforeValue and afterValue) and (afterValue - beforeValue) or nil
        local scoreRow = createPreviewSurface(scorePanel, { x = 16, y = -80 - ((index - 1) * 42), w = 426, h = 42 }, "MyToonsRowBackground")
        local name = scoreRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        name:SetPoint("LEFT", scoreRow, "LEFT", 12, 0)
        name:SetWidth(170)
        name:SetJustifyH("LEFT")
        name:SetText(splitPreviewName(playerId))
        name:SetTextColor(getPreviewClassColor(classMap[playerId]))

        local before = scoreRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        before:SetPoint("LEFT", scoreRow, "LEFT", 210, 0)
        before:SetText(beforeValue and tostring(beforeValue) or "-")

        local after = scoreRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        after:SetPoint("LEFT", scoreRow, "LEFT", 294, 0)
        after:SetText(afterValue and tostring(afterValue) or "-")

        local gain = scoreRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        gain:SetPoint("LEFT", scoreRow, "LEFT", 370, 0)
        gain:SetText(gainValue and string.format("%+d", gainValue) or "-")
        gain:SetTextColor(gainValue and gainValue > 0 and 0.78 or 0.96, gainValue and gainValue > 0 and 0.96 or 0.90, gainValue and gainValue > 0 and 0.72 or 0.82, 1)
    end

    local lootPanel = createPreviewSurface(dialog, { x = 526, y = -198, w = 458, h = 368 }, PANEL_ART_KEY)
    local lootTitle = lootPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    lootTitle:SetPoint("TOPLEFT", lootPanel, "TOPLEFT", 44, -16)
    lootTitle:SetFont("Fonts\\MORPHEUS.ttf", 22, "OUTLINE")
    lootTitle:SetText("Loot")
    lootTitle:SetTextColor(0.98, 0.90, 0.64, 1)

    local lootIcon = lootPanel:CreateTexture(nil, "OVERLAY")
    lootIcon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_BIS-l.png")
    lootIcon:SetPoint("TOPLEFT", lootPanel, "TOPLEFT", 16, -14)
    lootIcon:SetSize(20, 20)

    local lootHeader = createPreviewSurface(lootPanel, { x = 16, y = -54, w = 426, h = 26 }, "HomePanelHeader")
    local lootColumns = {
        { x = 12, text = "Player" },
        { x = 150, text = "Item" },
        { x = 374, text = "iLvl" },
    }
    for _, column in ipairs(lootColumns) do
        local label = lootHeader:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("LEFT", lootHeader, "LEFT", column.x, 0)
        label:SetText(column.text)
        label:SetTextColor(0.98, 0.90, 0.64, 1)
    end

    for index, row in ipairs(lootRows) do
        local lootRow = createPreviewSurface(lootPanel, { x = 16, y = -80 - ((index - 1) * 42), w = 426, h = 42 }, "MyToonsRowBackground")
        local name = lootRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        name:SetPoint("LEFT", lootRow, "LEFT", 12, 0)
        name:SetWidth(120)
        name:SetJustifyH("LEFT")
        name:SetText(splitPreviewName(row.owner))
        name:SetTextColor(getPreviewClassColor(classMap[row.owner]))

        local item = lootRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        item:SetPoint("LEFT", lootRow, "LEFT", 150, 0)
        item:SetWidth(204)
        item:SetJustifyH("LEFT")
        item:SetText(trimString(row.itemName) ~= "" and trimString(row.itemName) or "Item")

        local level = lootRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        level:SetPoint("LEFT", lootRow, "LEFT", 374, 0)
        level:SetText((tonumber(row.itemLevel) or 0) > 0 and tostring(tonumber(row.itemLevel) or 0) or "-")
    end

    return overlay
end

local function renderMythicTimerPreviewOverlay(parent)
    local snapshot = addon.MythicPlusService and addon.MythicPlusService.GetRuntimeState and addon.MythicPlusService:GetRuntimeState().overlay.previewSnapshot or {}
    local dialogWidth = 468
    local dialogHeight = 304
    local overlay = createPreviewOverlay(parent, dialogWidth, dialogHeight, trimString(snapshot.mapName) ~= "" and trimString(snapshot.mapName) or "The Dawnbreaker", "")
    local dialog = overlay.Dialog
    decorateSummaryPreviewDialog(dialog)
    dialog:SetMovable(true)
    dialog:EnableMouse(true)
    dialog:RegisterForDrag("LeftButton")
    dialog:SetScript("OnDragStart", dialog.StartMoving)
    dialog:SetScript("OnDragStop", dialog.StopMovingOrSizing)

    if overlay.CloseButton then
        overlay.CloseButton:Hide()
    end
    if overlay.Subtitle then
        overlay.Subtitle:Hide()
    end
    if overlay.Title then
        overlay.Title:ClearAllPoints()
        overlay.Title:SetPoint("TOPLEFT", dialog, "TOPLEFT", 66, -18)
        overlay.Title:SetFont("Fonts\\MORPHEUS.ttf", 22, "OUTLINE")
        overlay.Title:SetTextColor(0.98, 0.86, 0.44, 1)
    end

    local function formatClock(seconds)
        local totalSeconds = math.max(0, math.floor(tonumber(seconds) or 0))
        return string.format("%d:%02d", math.floor(totalSeconds / 60), totalSeconds % 60)
    end

    local timeLimit = math.max(0, math.floor(tonumber(snapshot.timeLimit) or 0))
    local remaining = math.max(0, math.floor(tonumber(snapshot.remaining) or 0))
    local elapsed = math.max(0, timeLimit - remaining)
    local plusThreeRemaining = math.floor((timeLimit * 0.6) - elapsed)
    local plusTwoRemaining = math.floor((timeLimit * 0.8) - elapsed)
    local forcesPercent = math.max(0, math.min(100, tonumber(snapshot.forces and snapshot.forces.current) or 0))
    local nextObjective = "Objectives loading"
    local utilityObjective = ""

    for _, row in ipairs(snapshot.objectiveRows or {}) do
        if not row.completed and utilityObjective == "" and ((tonumber(row.total) or 0) > 1 or trimString(row.quantityLabel) ~= "") then
            utilityObjective = string.format("Objective: %s %s", trimString(row.label), trimString(row.quantityLabel))
        elseif not row.completed and nextObjective == "Objectives loading" then
            nextObjective = "Next: " .. trimString(row.label)
        end
    end

    local closeButton = createPreviewTextureButton(dialog, { x = 0, y = 0, w = 44, h = 44 }, "ShellCloseButton", "ShellCloseButtonHover", closeTesterPreview)
    local titleIcon = dialog:CreateTexture(nil, "OVERLAY")
    titleIcon:SetTexture("Interface\\AddOns\\GH3\\Media\\Icons\\Icon_MythicPlus-l.png")
    titleIcon:SetPoint("TOPLEFT", dialog, "TOPLEFT", 28, -16)
    titleIcon:SetSize(28, 28)

    local keyBadge = createPreviewCard(dialog, { x = 0, y = 0, w = 68, h = 42 }, "", string.format("+%d", math.max(0, tonumber(snapshot.keyLevel) or 0)), { 0.98, 0.86, 0.30, 1 }, "HomeRaidCardBackground", "")
    keyBadge:ClearAllPoints()
    keyBadge:SetPoint("LEFT", overlay.Title, "RIGHT", 14, -1)
    closeButton:ClearAllPoints()
    closeButton:SetPoint("LEFT", keyBadge, "RIGHT", 10, 0)

    local titleWidth = overlay.Title and math.ceil(overlay.Title:GetStringWidth() or 0) or 0
    local parentWidth = tonumber(parent:GetWidth()) or 1380
    dialogWidth = math.max(356, 66 + titleWidth + 14 + 68 + 10 + 44 + 18)
    local panelWidth = dialogWidth - 40
    dialog:SetSize(dialogWidth, dialogHeight)
    dialog:ClearAllPoints()
    dialog:SetPoint("TOPLEFT", parent, "TOPLEFT", math.floor((parentWidth - dialogWidth) / 2), -48)

    local timerPanel = createPreviewSurface(dialog, { x = 20, y = -68, w = panelWidth, h = 80 }, "MyToonsPanelBackground")
    local infoPanel = createPreviewSurface(dialog, { x = 20, y = -154, w = panelWidth, h = 130 }, "MyToonsPanelBackground")

    local function createPreviewBar(parentFrame, rect, progress, fillColor)
        local host = CreateFrame("Frame", nil, parentFrame)
        ApplyRect(host, parentFrame, rect)
        local track = host:CreateTexture(nil, "BACKGROUND")
        track:SetAllPoints(host)
        track:SetColorTexture(0.10, 0.12, 0.16, 0.94)
        local fill = host:CreateTexture(nil, "ARTWORK")
        fill:SetPoint("TOPLEFT", host, "TOPLEFT", 3, -3)
        fill:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 3, 3)
        fill:SetWidth(math.max(6, math.floor((rect.w - 6) * math.max(0, math.min(1, progress or 0)))))
        fill:SetColorTexture(fillColor[1], fillColor[2], fillColor[3], fillColor[4] or 0.96)
        return host
    end

    local timerLabel = timerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    timerLabel:SetPoint("TOPLEFT", timerPanel, "TOPLEFT", 16, -7)
    timerLabel:SetText("TIMER")
    timerLabel:SetTextColor(0.78, 0.90, 1.00, 1)
    local timerValue = timerPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    timerValue:SetPoint("TOPLEFT", timerPanel, "TOPLEFT", 16, -29)
    timerValue:SetFont("Fonts\\FRIZQT__.TTF", 22, "")
    timerValue:SetText(string.format("%s left / %s", formatClock(remaining), formatClock(timeLimit)))
    timerValue:SetTextColor(0.94, 0.97, 1.00, 1)
    createPreviewBar(timerPanel, { x = 16, y = -51, w = panelWidth - 32, h = 18 }, timeLimit > 0 and (elapsed / timeLimit) or 0, { 0.12, 0.40, 0.92, 0.96 })
    local threshold = timerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    threshold:SetPoint("TOPLEFT", timerPanel, "TOPLEFT", 16, -72)
    threshold:SetText(string.format("+3 %s   +2 %s   +1 %s", formatClock(math.abs(plusThreeRemaining)), formatClock(math.abs(plusTwoRemaining)), formatClock(remaining)))
    threshold:SetTextColor(0.88, 0.86, 0.80, 1)

    local infoHeader = infoPanel:CreateTexture(nil, "ARTWORK")
    infoHeader:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 12, -8)
    infoHeader:SetPoint("TOPRIGHT", infoPanel, "TOPRIGHT", -12, -8)
    infoHeader:SetHeight(24)
    applyArtTexture(infoHeader, "HomePanelHeader", { 1, 1, 1, 1 })
    local affixes = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    affixes:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 18, -13)
    affixes:SetText(trimString(snapshot.affixes) ~= "" and trimString(snapshot.affixes) or "Affixes loading")
    affixes:SetTextColor(0.92, 0.96, 1.00, 1)
    local forces = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    forces:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 18, -42)
    forces:SetText(string.format("Enemy Forces %d%% complete", math.floor(forcesPercent + 0.5)))
    createPreviewBar(infoPanel, { x = 16, y = -58, w = panelWidth - 32, h = 18 }, forcesPercent / 100, { 0.20, 0.72, 0.18, 0.96 })
    local summary = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    summary:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 18, -82)
    summary:SetText(string.format("Objectives %d/%d   Deaths %d", math.max(0, tonumber(snapshot.objectiveDone) or 0), math.max(0, tonumber(snapshot.objectiveTotal) or 0), math.max(0, tonumber(snapshot.deaths) or 0)))
    local objectiveLine = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    objectiveLine:SetPoint("TOPLEFT", infoPanel, "TOPLEFT", 18, -102)
    objectiveLine:SetWidth(panelWidth - 36)
    objectiveLine:SetJustifyH("LEFT")
    objectiveLine:SetWordWrap(true)
    objectiveLine:SetText(utilityObjective ~= "" and utilityObjective or nextObjective)
    objectiveLine:SetTextColor(0.84, 0.96, 0.74, 1)

    return overlay
end

local function renderTesterPane(parent, rect)
    local panel = createTesterPanel(parent, rect)

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", panel, "TOPLEFT", 18, -18)
    title:SetText("Tester")

    local subtitle = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
    subtitle:SetWidth(900)
    subtitle:SetJustifyH("LEFT")
    subtitle:SetText("Launch the copied New UI BIS popup and the Mythic preview frames with safe preview data so you can inspect them without waiting on live events.")
    subtitle:SetTextColor(0.82, 0.80, 0.74, 1)

    createButton(panel, { x = 18, y = -84, w = 260, h = 30 }, "Preview BIS Loot Popup", showBISLootPreview, false)
    createButton(panel, { x = 18, y = -128, w = 260, h = 30 }, "Preview Mythic Summary", showMythicSummaryPreview, false)
    createButton(panel, { x = 18, y = -172, w = 260, h = 30 }, "Preview Mythic Timer", showMythicTimerPreview, false)

    local note = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    note:SetPoint("TOPLEFT", panel, "TOPLEFT", 320, -88)
    note:SetWidth(780)
    note:SetJustifyH("LEFT")
    note:SetText("The BIS button now stays inside the copied New UI popup path, while the Mythic buttons still use the seeded preview frames for their current tester coverage.")
    note:SetTextColor(0.78, 0.76, 0.71, 1)

    return panel
end

local function buildUnionRect(rects)
    local minX, maxRight, topY, maxBottom

    for _, rect in ipairs(rects or {}) do
        if rect then
            local x = tonumber(rect.x) or 0
            local y = tonumber(rect.y) or 0
            local w = tonumber(rect.w) or 0
            local h = tonumber(rect.h) or 0
            minX = minX and math.min(minX, x) or x
            maxRight = maxRight and math.max(maxRight, x + w) or (x + w)
            topY = topY and math.max(topY, y) or y
            maxBottom = maxBottom and math.max(maxBottom, (-y) + h) or ((-y) + h)
        end
    end

    if not minX then
        return { x = 20, y = -36, w = 1340, h = 638 }
    end

    return {
        x = minX,
        y = topY,
        w = maxRight - minX,
        h = maxBottom - (-topY),
    }
end

local function isSettingsPageActive()
    return addon.NewUIShell and addon.NewUIShell.activePage == "settings"
end

local function renderSettingsPane(parent, rect)
    local featureOptions = addon.GetFeatureToggleDefinitions and addon:GetFeatureToggleDefinitions() or {}
    local paneTitleFont = getConfiguredPaneFontOption("paneTitle")
    local paneBodyFont = getConfiguredPaneFontOption("paneBody")
    local shellSizeOption = addon.NewUIShell and addon.NewUIShell.GetConfiguredSizeOption and addon.NewUIShell:GetConfiguredSizeOption() or { label = "1920 x 1080" }
    local host = createPanel(parent, rect)
    local contentWidth = math.max(820, (tonumber(rect.w) or 1340) - 54)
    local fontSectionHeight = 352

    local title = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -18)
    title:SetText("Settings")
    styleTitleText(title, 18)

    local settingsPanel = createPanel(host, { x = 18, y = -54, w = (tonumber(rect.w) or 1340) - 36, h = (tonumber(rect.h) or 638) - 72 })

    local panelTitle = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    panelTitle:SetPoint("TOPLEFT", settingsPanel, "TOPLEFT", 14, -14)
    panelTitle:SetText("Menu Settings")
    styleTitleText(panelTitle, 18)

    local meta = settingsPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    meta:SetPoint("TOPLEFT", panelTitle, "BOTTOMLEFT", 0, -4)
    meta:SetWidth(contentWidth - 20)
    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(contentWidth, math.max(220, fontSectionHeight + 12 + (#featureOptions * 108)))
    listScrollFrame:SetScrollChild(listContent)

    local fontRow = createPanel(listContent, { x = 0, y = 0, w = contentWidth - 16, h = fontSectionHeight })

    local fontRowTitle = fontRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    fontRowTitle:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -12)
    fontRowTitle:SetText("Display and Pane Fonts")
    styleTitleText(fontRowTitle, 16)

    local fontRowMeta = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    fontRowMeta:SetPoint("TOPLEFT", fontRowTitle, "BOTTOMLEFT", 0, -4)
    fontRowMeta:SetWidth(contentWidth - 220)
    fontRowMeta:SetJustifyH("LEFT")
    fontRowMeta:SetText("Use supported 16:9 shell presets to keep the fixed GH3 page layout intact, then adjust pane fonts for readability. The sidebar menu and main GH3 shell title stay unchanged.")
    styleBodyText(fontRowMeta, true)

    local shellSizeLabel = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    shellSizeLabel:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -62)
    shellSizeLabel:SetText("New UI Size")
    styleBodyText(shellSizeLabel, true)

    local shellSizeDropdown = createSelectButton(fontRow, { x = 14, y = -78, w = 216, h = 24 }, shellSizeOption.label or "1920 x 1080", function(anchor)
        local items = {}
        local options = addon.NewUIShell and addon.NewUIShell.GetSupportedSizeOptions and addon.NewUIShell:GetSupportedSizeOptions() or {}

        for _, option in ipairs(options) do
            items[#items + 1] = {
                text = option.label,
                value = option.key,
                func = function(value)
                    if addon.NewUIShell and addon.NewUIShell.SetConfiguredSize then
                        addon.NewUIShell:SetConfiguredSize(value)
                    end
                    refreshSettingsPage()
                end,
            }
        end

        showDropdownMenu(anchor, items)
    end)
    attachHoverNote(shellSizeDropdown, "New UI Size", "Supported 16:9 presets scale the fixed GH3 shell without reflowing page layouts. 1920 x 1080 is the smallest supported size.")

    local shellSizeHelp = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    shellSizeHelp:SetPoint("TOPLEFT", shellSizeDropdown, "BOTTOMLEFT", 0, -8)
    shellSizeHelp:SetWidth(contentWidth - 220)
    shellSizeHelp:SetJustifyH("LEFT")
    shellSizeHelp:SetText("This changes the full New UI shell scale only. It does not recalculate individual page widths or panel positions, which is what keeps the current layout stable.")
    styleBodyText(shellSizeHelp, true)

    local titleFontLabel = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    titleFontLabel:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -132)
    titleFontLabel:SetText("Pane Title Font")
    styleBodyText(titleFontLabel, true)

    local titleFontDropdown = createSelectButton(fontRow, { x = 14, y = -148, w = 216, h = 24 }, paneTitleFont and paneTitleFont.label or "Morpheus", function(anchor)
        local items = {}
        for _, option in ipairs(PANE_TITLE_FONT_OPTIONS) do
            items[#items + 1] = {
                text = option.label,
                value = option.key,
                func = function(value)
                    setConfiguredPaneFont("paneTitle", value)
                    addon.MainFrame:RefreshCurrentView(true)
                end,
            }
        end
        showDropdownMenu(anchor, items)
    end)

    local bodyFontLabel = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bodyFontLabel:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 254, -132)
    bodyFontLabel:SetText("Pane Text Font")
    styleBodyText(bodyFontLabel, true)

    local bodyFontDropdown = createSelectButton(fontRow, { x = 254, y = -148, w = 216, h = 24 }, paneBodyFont and paneBodyFont.label or "Default UI", function(anchor)
        local items = {}
        for _, option in ipairs(PANE_BODY_FONT_OPTIONS) do
            items[#items + 1] = {
                text = option.label,
                value = option.key,
                func = function(value)
                    setConfiguredPaneFont("paneBody", value)
                    addon.MainFrame:RefreshCurrentView(true)
                end,
            }
        end
        showDropdownMenu(anchor, items)
    end)

    createButton(fontRow, { x = contentWidth - 138, y = -144, w = 104, h = 24 }, "Reset Fonts", function()
        setConfiguredPaneFont("paneTitle", DEFAULT_PANE_TITLE_FONT_KEY)
        setConfiguredPaneFont("paneBody", DEFAULT_PANE_BODY_FONT_KEY)
        addon.MainFrame:RefreshCurrentView(true)
    end, false)

    local transparencyTitle = fontRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    transparencyTitle:SetPoint("TOPLEFT", fontRow, "TOPLEFT", 14, -196)
    transparencyTitle:SetText("Transparency")
    styleTitleText(transparencyTitle, 16)

    local transparencyMeta = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    transparencyMeta:SetPoint("TOPLEFT", transparencyTitle, "BOTTOMLEFT", 0, -4)
    transparencyMeta:SetWidth(contentWidth - 220)
    transparencyMeta:SetJustifyH("LEFT")
    transparencyMeta:SetText("Use separate background transparency presets for the shell and the major GH3 popups without fading the foreground text and controls.")
    styleBodyText(transparencyMeta, true)

    local function createTransparencyDropdown(x, y, labelText, surfaceKey, helpText)
        local option = getConfiguredTransparencyOption(surfaceKey)

        local label = fontRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", fontRow, "TOPLEFT", x, y)
        label:SetText(labelText)
        styleBodyText(label, true)

        local dropdown = createSelectButton(fontRow, { x = x, y = y - 16, w = 216, h = 24 }, option.label or "0%", function(anchor)
            local items = {}

            for _, transparencyOption in ipairs(TRANSPARENCY_OPTIONS) do
                items[#items + 1] = {
                    text = transparencyOption.label,
                    value = transparencyOption.key,
                    func = function(value)
                        setConfiguredTransparency(surfaceKey, value)
                        applyConfiguredPopupTransparencyOwners()
                        refreshSettingsPage()
                    end,
                }
            end

            showDropdownMenu(anchor, items)
        end)

        attachHoverNote(dropdown, labelText, helpText)
        return dropdown
    end

    createTransparencyDropdown(14, -248, "Main UI", "mainUi", "Controls shell and top-tab chrome transparency for the main GH3 window.")
    createTransparencyDropdown(254, -248, "GH3 Helper Popup", "helperPopup", "Controls the guild helper popup background transparency.")
    createTransparencyDropdown(494, -248, "BIS Popup", "bisPopup", "Controls the BIS Drops popup background transparency.")
    createTransparencyDropdown(14, -308, "Mythic Timer Popup", "mythicTimerPopup", "Controls the Mythic timer overlay background transparency.")
    createTransparencyDropdown(254, -308, "Mythic Summary Popup", "mythicSummaryPopup", "Controls the Mythic run completion summary transparency.")

    createButton(fontRow, { x = contentWidth - 166, y = -304, w = 132, h = 24 }, "Reset Transparency", function()
        for surfaceKey in pairs(DEFAULT_TRANSPARENCY_KEYS) do
            setConfiguredTransparency(surfaceKey, DEFAULT_TRANSPARENCY_OPTION_KEY)
        end
        applyConfiguredPopupTransparencyOwners()
        refreshSettingsPage()
    end, false)

    for index, option in ipairs(featureOptions) do
        local row = createPanel(listContent, { x = 0, y = -(fontSectionHeight + 12 + ((index - 1) * 110)), w = contentWidth - 16, h = 96 })
        local enabled = addon.IsFeatureEnabled and addon:IsFeatureEnabled(option.key)

        local rowTitle = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        rowTitle:SetPoint("TOPLEFT", row, "TOPLEFT", 14, -12)
        rowTitle:SetText(option.label)
        styleTitleText(rowTitle, 16)

        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(contentWidth - 220)
        description:SetJustifyH("LEFT")
        description:SetText(option.description or "")
        styleBodyText(description, false)

        local toggleButton = createButton(row, { x = contentWidth - 126, y = -18, w = 92, h = 28 }, enabled and "On" or "Off", function()
            local newEnabled = not (addon.IsFeatureEnabled and addon:IsFeatureEnabled(option.key))
            if addon.SetFeatureEnabled then
                addon:SetFeatureEnabled(option.key, newEnabled)
            end

            if not newEnabled and addon.Router and addon.Router.GetCurrentView and addon.GetFeatureKeyForView and addon:GetFeatureKeyForView(addon.Router:GetCurrentView()) == option.key then
                addon.Store:GetProfile().ui.selectedView = "settings"
            end

            addon.MainFrame:RefreshCurrentView(true)
        end, enabled)

        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

    return host
end

local function renderSetupPane(parent, rect)
    local service = addon.FederationService
    local host = CreateFrame("Frame", nil, parent)
    ApplyRect(host, parent, rect)

    local linkedGuilds = service and service.GetLinkedGuilds and service:GetLinkedGuilds() or {}
    local canManageLinkedGuilds = service and service.CanManageLinkedGuilds and service:CanManageLinkedGuilds() or false
    local partyPeers = {}
    if canManageLinkedGuilds and service and service.RequestPartyPeerDiscovery then
        service:RequestPartyPeerDiscovery(false)
        partyPeers = service.GetPartyHandshakePeers and service:GetPartyHandshakePeers() or {}
    end

    local title = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -18)
    title:SetText("Setup")
    styleTitleText(title, 18)

    local headerPanel = createPanel(host, { x = 0, y = 0, w = tonumber(rect.w) or 1340, h = 64 })
    local homeLabel = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    homeLabel:SetPoint("TOPLEFT", headerPanel, "TOPLEFT", 18, -14)
    homeLabel:SetText("Home Guild: " .. ((service and service.GetHomeGuild and service:GetHomeGuild()) or "Not available"))
    styleHighlightText(homeLabel)

    local accessLabel = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    accessLabel:SetPoint("TOPLEFT", homeLabel, "BOTTOMLEFT", 0, -6)
    accessLabel:SetWidth((tonumber(rect.w) or 1340) - 36)
    accessLabel:SetJustifyH("LEFT")
    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 helpLabel = host:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        helpLabel:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -92)
        helpLabel:SetWidth((tonumber(rect.w) or 1340) - 36)
        helpLabel:SetJustifyH("LEFT")
        helpLabel: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(helpLabel, true)

        createButton(host, { x = 18, y = -134, w = 148, h = 24 }, "Refresh Party GMs", function()
            local ok, message = service:RequestPartyPeerDiscovery(true)
            if not ok and message then
                addon:Print(message)
            end
            addon.MainFrame:RefreshCurrentView(true)
        end, false)

        local partyPanel = createPanel(host, { x = 0, y = -166, w = tonumber(rect.w) or 1340, h = 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((tonumber(rect.w) or 1340) - 52)
            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 = createListRow(partyPanel, { x = 14, y = -32 - ((index - 1) * 26), w = (tonumber(rect.w) or 1340) - 52, h = 24 }, index % 2 == 0)
                local record = service.GetLinkedGuildRecord and service:GetLinkedGuildRecord(peer.homeGuild) or nil
                local linkStatus = service.GetLinkStatus and service:GetLinkStatus(record) or { label = "Unknown" }

                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 or "Unknown")
                styleBodyText(statusText, true)

                if (not record or not (record.requestedAt or record.addedAt)) then
                    createButton(row, { x = (tonumber(rect.w) or 1340) - 160, y = -3, w = 98, h = 18 }, "Request Link", function()
                        local ok, message = service:RequestLinkWithPeer(peer.peer, peer.homeGuild)
                        if ok then
                            addon.MainFrame:RefreshCurrentView(true)
                        elseif message then
                            addon:Print(message)
                        end
                    end, false)
                elseif (record.remoteRequestedAt or record.remoteApprovedAt) and not record.approvedAt then
                    createButton(row, { x = (tonumber(rect.w) or 1340) - 146, y = -3, w = 84, h = 18 }, "Approve", function()
                        local ok, message = service:ApproveLinkWithPeer(peer.peer, peer.homeGuild)
                        if ok then
                            addon.MainFrame:RefreshCurrentView(true)
                        elseif message then
                            addon:Print(message)
                        end
                    end, false)
                end
            end
        end
    end

    local listTop = canManageLinkedGuilds and -264 or -102
    local listHeight = canManageLinkedGuilds and 370 or 500
    local listHost = createPanel(host, { x = 0, y = listTop, w = tonumber(rect.w) or 1340, h = listHeight })

    local headers = {
        { x = 14, width = 270, text = "Linked Guild" },
        { x = 296, width = 210, text = "Status" },
        { x = 518, width = 154, text = "Requested" },
        { x = 684, width = 96, text = "Approval" },
    }
    for _, entry in ipairs(headers) do
        local label = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        label:SetPoint("TOPLEFT", listHost, "TOPLEFT", entry.x, -16)
        label:SetWidth(entry.width)
        label:SetJustifyH("LEFT")
        label:SetText(entry.text)
        styleHighlightText(label)
    end
    if canManageLinkedGuilds then
        local actionsLabel = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        actionsLabel:SetPoint("TOPLEFT", listHost, "TOPLEFT", 792, -16)
        actionsLabel:SetWidth(98)
        actionsLabel:SetJustifyH("LEFT")
        actionsLabel:SetText("Actions")
        styleHighlightText(actionsLabel)
    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, false)
        return host
    end

    for index, record in ipairs(linkedGuilds) do
        local row = createListRow(listHost, { x = 14, y = -42 - ((index - 1) * 36), w = (tonumber(rect.w) or 1340) - 52, h = 30 }, index % 2 == 0)
        local linkStatus = service.GetLinkStatus and service:GetLinkStatus(record) or { label = "Unknown", key = "unknown" }
        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 or "Unknown")
        styleBodyText(statusText, true)
        if linkStatus.key == "approved" then
            statusText:SetTextColor(0.74, 0.90, 0.62, 1)
        elseif linkStatus.key == "needs-your-approval" or linkStatus.key == "needs-both-approval" then
            statusText:SetTextColor(0.96, 0.80, 0.38, 1)
        elseif linkStatus.key == "remote-requested" then
            statusText:SetTextColor(0.72, 0.84, 0.98, 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, { x = (tonumber(rect.w) or 1340) - 132, y = -5, w = 72, h = 20 }, "Remove", function()
                if service:RemoveLinkedGuild(record.guildKey or record.name) then
                    addon.MainFrame:RefreshCurrentView(true)
                end
            end, false)
        end
    end

    return host
end

local function renderHelpPane(parent, rect)
    local mainFrame = addon.MainFrame
    if mainFrame and mainFrame.EnsureState then
        mainFrame:EnsureState()
    end

    local state = (mainFrame and mainFrame.state) or page.State
    state.helpArticleKey = tostring(state.helpArticleKey or "overview")
    local selectedArticle = getHelpArticleByKey(state.helpArticleKey)
    local host = CreateFrame("Frame", nil, parent)
    ApplyRect(host, parent, rect)

    local title = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -18)
    title:SetText("Help Center")
    styleTitleText(title, 18)

    local intro = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    intro:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -54)
    intro:SetWidth((tonumber(rect.w) or 1340) - 36)
    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, false)

    local navPanel = createPanel(host, { x = 18, y = -86, w = 248, h = (tonumber(rect.h) or 638) - 104 })
    local articlePanel = createPanel(host, { x = 278, y = -86, w = (tonumber(rect.w) or 1340) - 296, h = (tonumber(rect.h) or 638) - 104 })

    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, true)

    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)
            navCursorY = navCursorY - 22

            for _, article in ipairs(HELP_ARTICLES) do
                if article.category == category then
                    local articleKey = article.key
                    local button = createButton(navContent, { x = 4, y = navCursorY, w = 184, h = 24 }, article.title, function()
                        if state.helpArticleKey ~= articleKey then
                            state.helpArticleKey = articleKey
                            addon.MainFrame:RefreshCurrentView(true)
                        end
                    end, state.helpArticleKey == articleKey)

                    if button.Text then
                        button.Text:ClearAllPoints()
                        button.Text:SetPoint("LEFT", button, "LEFT", 10, 0)
                        button.Text:SetPoint("RIGHT", button, "RIGHT", -10, 0)
                        button.Text:SetJustifyH("LEFT")
                    end

                    if 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((tonumber(rect.w) or 1340) - 372)
    articleContent:SetHeight(1)
    articleScroll:SetScrollChild(articleContent)

    local articleTitle = articleContent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    articleTitle:SetPoint("TOPLEFT", articleContent, "TOPLEFT", 4, -4)
    articleTitle:SetWidth((tonumber(rect.w) or 1340) - 404)
    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((tonumber(rect.w) or 1340) - 404)
    articleSummary:SetJustifyH("LEFT")
    articleSummary:SetJustifyV("TOP")
    articleSummary:SetText(selectedArticle.summary or "")
    styleBodyText(articleSummary, false)
    if articleSummary.SetSpacing then
        articleSummary:SetSpacing(2)
    end

    local quickCard = createPanel(articleContent, { x = 4, y = -(articleSummary:GetStringHeight() + 44), w = (tonumber(rect.w) or 1340) - 404, h = 80 })
    local quickTitle = quickCard:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    quickTitle:SetPoint("TOPLEFT", quickCard, "TOPLEFT", 12, -10)
    quickTitle:SetText("Quick Shortcuts")
    styleHighlightText(quickTitle)

    local quickValue = quickCard:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    quickValue:SetPoint("TOPLEFT", quickTitle, "BOTTOMLEFT", 0, -8)
    quickValue:SetWidth((tonumber(rect.w) or 1340) - 428)
    quickValue:SetJustifyH("LEFT")
    quickValue:SetText("/gh3  |  /gh3 mythics  |  /gh3 bis  |  /gh3 sync")
    styleTitleText(quickValue, 15)

    local quickBody = quickCard:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    quickBody:SetPoint("TOPLEFT", quickValue, "BOTTOMLEFT", 0, -6)
    quickBody:SetWidth((tonumber(rect.w) or 1340) - 428)
    quickBody:SetJustifyH("LEFT")
    quickBody:SetText("Use slash commands when you want to jump directly to the page that answers the question in front of you.")
    styleBodyText(quickBody, true)

    local articleCursorY = -((articleSummary:GetStringHeight() + 44) + 90)
    local function addArticleBlock(blockTitle, body)
        if not body or body == "" then
            return
        end

        local block = createPanel(articleContent, { x = 4, y = articleCursorY, w = (tonumber(rect.w) or 1340) - 404, h = 74 })
        local titleText = block:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        titleText:SetPoint("TOPLEFT", block, "TOPLEFT", 12, -12)
        titleText:SetWidth((tonumber(rect.w) or 1340) - 428)
        titleText:SetJustifyH("LEFT")
        titleText:SetText(blockTitle)
        styleHighlightText(titleText)

        local bodyText = block:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        bodyText:SetPoint("TOPLEFT", titleText, "BOTTOMLEFT", 0, -8)
        bodyText:SetWidth((tonumber(rect.w) or 1340) - 428)
        bodyText:SetJustifyH("LEFT")
        bodyText:SetJustifyV("TOP")
        bodyText:SetText(body)
        styleBodyText(bodyText, false)
        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))
    return host
end

local function renderDataSyncPane(parent, rect)
    local reconciler = addon.Reconciler
    local sessionManager = addon.SessionManager
    local host = CreateFrame("Frame", nil, parent)
    ApplyRect(host, parent, rect)

    local sessions = sessionManager and sessionManager.ListActive and sessionManager:ListActive() or {}
    local peers = reconciler and reconciler.GetPeerList and reconciler:GetPeerList() or {}
    local conflicts = reconciler and reconciler.ListConflicts and reconciler:ListConflicts() or {}
    local localStatus = reconciler and reconciler.BuildLocalStatus and reconciler:BuildLocalStatus() or { tables = {} }
    local syncDiagnostics = reconciler and reconciler.BuildSyncDiagnostics and reconciler:BuildSyncDiagnostics() or { sessions = {}, transport = {} }
    local selectedPeer = getSelectedSyncPeerName(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 addon.IsFeatureEnabled and not addon:IsFeatureEnabled("mythics") then
        disabledSyncFeatures[#disabledSyncFeatures + 1] = "Mythic+ auto-refresh and party mythic autosync"
    end
    if addon.IsFeatureEnabled and not addon:IsFeatureEnabled("groups") then
        disabledSyncFeatures[#disabledSyncFeatures + 1] = "Groups background refresh handling"
    end

    local function getActiveSyncPeer()
        return getSelectedSyncPeerName(addon.Reconciler and addon.Reconciler.GetPeerList and addon.Reconciler:GetPeerList() or peers)
    end

    local function getProbePeers()
        local discoveredPeers = {}
        local seen = {}

        for _, discoveredPeer in ipairs(peers or {}) do
            local peerName = trimString(discoveredPeer.sender or discoveredPeer.player)

            if peerName ~= "" and not seen[peerName] then
                seen[peerName] = true
                discoveredPeers[#discoveredPeers + 1] = {
                    fullName = peerName,
                    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
                    discoveredPeers[#discoveredPeers + 1] = {
                        fullName = fullName,
                        guildKey = guildKey,
                        source = "group",
                    }
                end
            end
        end

        table.sort(discoveredPeers, function(left, right)
            return (left.fullName or "") < (right.fullName or "")
        end)

        return discoveredPeers
    end

    local title = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -18)
    title:SetText("DataSync")
    styleTitleText(title, 18)

    if #disabledSyncFeatures > 0 then
        local warningPanel = createPanel(host, { x = math.max(((tonumber(rect.w) or 954) - 470), 440), y = -38, w = 452, h = 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 requireSelectedPeer()
        local peer = getActiveSyncPeer()
        if peer and peer ~= "" then
            return peer
        end
        addon:Print("No sync peer selected.")
        return nil
    end

    local syncRosterButton = createButton(host, { x = 18, y = -44, w = 90, h = 24 }, "Sync Roster", function()
        local ok, message, startedSync = 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, false)

    local syncMyToonsButton = createButton(host, { x = 116, y = -44, w = 132, h = 24 }, "Sync Toons+Mythics", function()
        local ok, message = 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, false)

    local syncBoardButton = createButton(host, { x = 256, y = -44, w = 90, h = 24 }, "Sync Board", function()
        local ok, message, startedSync = 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, false)

    local syncGroupsButton = createButton(host, { x = 354, y = -44, w = 94, h = 24 }, "Sync Groups", function()
        local ok, message, startedSync = 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, false)

    local syncFederationButton = createButton(host, { x = 456, y = -44, w = 112, h = 24 }, "Sync Federation", function()
        local ok, message, startedSync = 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, false)

    createButton(host, { x = 576, y = -44, w = 82, h = 24 }, "Sync All", function()
        local _, message = reconciler:RequestAllBidirectionalSnapshots(getActiveSyncPeer())
        if message then
            addon:Print(message)
        end
    end, false)

    local refreshPeersButton = createButton(host, { x = 18, y = -76, w = 104, h = 24 }, "Refresh Peers", function()
        local ok, message = reconciler:RequestGuildStatus()
        if not ok and message then
            addon:Print(message)
        end
    end, false)

    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
    groupedPeerDropdown = createSelectButton(host, { x = 182, y = -76, w = 240, h = 24 }, #groupedPeers > 0 and "Choose Peer" or "No discovered peers", function(anchor)
        showContainedDropdownMenu(host, anchor, function()
            local items = {}
            for _, peer in ipairs(groupedPeers) do
                local sourceLabel = peer.source == "group" and "grouped" or "guild"
                items[#items + 1] = {
                    text = string.format("%s (%s)", peer.fullName, sourceLabel),
                    value = peer.fullName,
                    func = function(value)
                        groupedPeerDropdown.selectedValue = value
                        groupedPeerDropdown.selectedGuildKey = peer.guildKey
                        if groupedPeerDropdown.Text then
                            groupedPeerDropdown.Text:SetText(peer.fullName)
                        end
                    end,
                }
            end

            if #items == 0 then
                items[#items + 1] = {
                    text = "No discovered peers",
                    disabled = true,
                }
            end

            return items
        end)
    end)

    createButton(host, { x = 434, y = -75, w = 90, h = 24 }, "Probe Peer", 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)
        setSelectedSyncPeerName(selectedPeerName)
        refreshSettingsPage()

        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, false)

    createButton(host, { x = 536, y = -76, w = 120, h = 24 }, "Clear Conflicts", function()
        addon.Reconciler:ClearConflicts()
        refreshSettingsPage()
    end, false)

    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,
                trimString(selectedPeerRecord.addonVersion) ~= "" and trimString(selectedPeerRecord.addonVersion) or "0",
                tonumber(selectedPeerRecord.syncVersion) or 0,
                addon.Reconciler:IsPeerOnNewerAddonVersion(selectedPeerRecord) and " | newer available" or "")
        end

        peerSummary:SetText(peerSummaryText)
    end
    styleBodyText(peerSummary, false)

    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",
        trimString(localStatus.addonVersion) ~= "" and trimString(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, false)

    local listHost = createPanel(host, { x = 18, y = -142, w = 574, h = 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, { x = 608, y = -142, w = 324, h = 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

    createButton(diagnosticsPanel, { x = 14, y = -186, w = 144, h = 22 }, syncDiagnostics.manualOnly and "Mode: Manual" or "Mode: Automatic", function()
        local nextValue = not addon.Reconciler:IsManualOnlySync()
        addon.Reconciler:SetManualOnlySync(nextValue)
        refreshSettingsPage()
    end, false)

    createButton(diagnosticsPanel, { x = 166, y = -186, w = 144, h = 22 }, "Cancel Active", function()
        local cancelled = addon.Reconciler:CancelActiveSyncs()
        if cancelled == 0 then
            addon:Print("No active sync sessions to cancel.")
        end
    end, false)

    createButton(diagnosticsPanel, { x = 14, y = -214, w = 144, h = 22 }, "Roster Report", function()
        showTextViewerDialog("Last Roster Sync", addon.Reconciler:BuildLastRosterSyncReport(), nil, 640, 380)
    end, false)

    createButton(diagnosticsPanel, { x = 166, y = -214, w = 144, h = 22 }, "Remove Peer", 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

        setSelectedSyncPeerName(nil)
        refreshSettingsPage()
        addon:Print("Removed peer " .. peerToRemove .. ".")
    end, false)

    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

        rows[#rows + 1] = {
            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",
            trimString(peer.addonVersion) ~= "" and trimString(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)

        rows[#rows + 1] = {
            values = {
                peer.sender or peer.player or "-",
                modeLabel,
                peer.homeGuild or "-",
                versionLabel,
                progressLabel,
            },
            selected = (peer.sender or peer.player) == selectedPeer,
            onClick = function()
                setSelectedSyncPeerName(peer.sender or peer.player)
                refreshSettingsPage()
            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, { x = 18, y = -404, w = 916, h = 220 })
    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, { x = 538, y = -12, w = 116, h = 22 }, "Keep Local All", 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))
        refreshSettingsPage()
    end, false)

    createButton(conflictPanel, { x = 662, y = -12, w = 132, h = 22 }, "Accept Remote All", function()
        local ok, message = addon.Reconciler:ResolveAllConflictsAcceptRemote()
        if not ok then
            addon:Print(message)
            return
        end

        addon:Print(message)
        refreshSettingsPage()
    end, false)

    createButton(conflictPanel, { x = 802, y = -12, w = 84, h = 22 }, "Clear All", function()
        local cleared = addon.Reconciler:ClearConflicts()
        addon:Print(string.format("Cleared %d conflicts.", cleared or 0))
        refreshSettingsPage()
    end, false)

    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)

    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, false)
        conflictContent:SetHeight(24)
        return host
    end

    conflictContent:SetWidth(872)
    conflictContent:SetHeight(math.max(150, (#conflicts * 42) - 8))

    for index, conflict in ipairs(conflicts) do
        local row = createListRow(conflictContent, { x = 0, y = -((index - 1) * 42), w = 860, h = 34 }, index % 2 == 0)

        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, { x = 510, y = -7, w = 28, h = 20 }, "C", function()
            showTextViewerDialog("Conflict Compare", 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.", nil, 620, 360)
        end, false)

        createButton(row, { x = 546, y = -7, w = 92, h = 20 }, "Keep Local", function()
            local ok, message = addon.Reconciler:ResolveConflictKeepLocal(conflict.tableName, conflict.recordId, conflict.sender)
            if not ok then
                addon:Print(message)
                return
            end
            refreshSettingsPage()
        end, false)

        createButton(row, { x = 646, y = -7, w = 104, h = 20 }, "Accept Remote", function()
            local ok, message = addon.Reconciler:ResolveConflictAcceptRemote(conflict.tableName, conflict.recordId, conflict.sender)
            if not ok then
                addon:Print(message)
                return
            end
            refreshSettingsPage()
        end, false)

        createButton(row, { x = 758, y = -7, w = 64, h = 20 }, "Clear", function()
            addon.Reconciler:ClearConflict(conflict.tableName, conflict.recordId, conflict.sender)
            refreshSettingsPage()
        end, false)
    end

    return host
end

local function renderMaintenanceCleanupTab(host, rect, service, state)
    local summary = service:GetSummary()
    local settings = service:GetSettings()
    local purgeEntries = service:ListCleanupEntries("purge") or {}
    local pageSize = 50
    local contentWidth = math.max(918, math.floor((tonumber(rect.w) or 954) - 36))

    local summaryText = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryText:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -58)
    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)

    createButton(host, { x = 18, y = -100, w = 90, h = 24 }, "Run Scan", function()
        service:ScanRoster()
        refreshSettingsPage()
    end, false)

    createButton(host, { x = 116, y = -100, w = 110, h = 24 }, "Purge Ready", function()
        local purged = service:PurgeReadyMembers()
        addon:Print("Purged", purged, "ready record(s).")
        refreshSettingsPage()
    end, false)

    local settingsRow = createPanel(host, { x = 18, y = -132, w = contentWidth, h = 54 })

    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, { x = 72, y = -24, w = 64, h = 22 }, "Apply", function()
        service:UpdateSettings({ purgeAfterDays = purgeDaysInput:GetText() })
        refreshSettingsPage()
    end, false)

    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))
    state.maintenancePage = math.max(1, math.min(state.maintenancePage or 1, totalPages))
    local startIndex = ((state.maintenancePage - 1) * pageSize) + 1
    local endIndex = math.min(#purgeEntries, startIndex + pageSize - 1)

    local listHost = createPanel(host, { x = 18, y = -190, w = contentWidth, h = math.max((tonumber(rect.h) or 638) - 208, 360) })

    local header = listHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    header:SetPoint("TOPLEFT", listHost, "TOPLEFT", 14, -12)
    header:SetText("Action Queue")
    styleTitleText(header, 16)

    local columnHeaderRow = createListRow(listHost, { x = 14, y = -38, w = 860, h = 24 }, false)

    local function createMaintenanceHeader(parentFrame, text, x, width)
        local label = parentFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", parentFrame, "LEFT", x, 0)
        label:SetWidth(width)
        label:SetJustifyH("LEFT")
        label:SetText(text)
        styleHighlightText(label)
        return label
    end

    createMaintenanceHeader(columnHeaderRow, "Member", 8, 190)
    createMaintenanceHeader(columnHeaderRow, "Guild", 214, 180)
    createMaintenanceHeader(columnHeaderRow, "Status", 400, 180)
    createMaintenanceHeader(columnHeaderRow, "Age", 586, 70)
    createMaintenanceHeader(columnHeaderRow, "Last Seen", 662, 110)

    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)

    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, false)
        scrollChild:SetHeight(60)
    else
        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 = createListRow(scrollChild, { x = 0, y = -((index - 1) * rowPitch), w = 860, h = rowHeight }, index % 2 == 0)

            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, false)

            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, false)

            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, false)

            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, { x = 788, y = -13, w = 64, h = 20 }, "Purge", function()
                addon.MaintenanceService:PurgeMember(record.memberId)
                refreshSettingsPage()
            end, false)
        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("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", state.maintenancePage, totalPages))
    styleHighlightText(pageIndicator)

    createButton(listHost, { x = 640, y = -((tonumber(listHost:GetHeight()) or 420) - 390), w = 100, h = 30 }, "Previous", function()
        if state.maintenancePage > 1 then
            state.maintenancePage = state.maintenancePage - 1
            refreshSettingsPage()
        end
    end, false):SetEnabled(state.maintenancePage > 1)

    createButton(listHost, { x = 750, y = -((tonumber(listHost:GetHeight()) or 420) - 390), w = 100, h = 30 }, "Next", function()
        if state.maintenancePage < totalPages then
            state.maintenancePage = state.maintenancePage + 1
            refreshSettingsPage()
        end
    end, false):SetEnabled(state.maintenancePage < totalPages)

    createButton(listHost, { x = 10, y = -((tonumber(listHost:GetHeight()) or 420) - 390), w = 100, h = 30 }, "Reset", function()
        state.maintenancePage = 1
        refreshSettingsPage()
    end, false)
end

local function renderMaintenanceInvitesTab(host, rect, service, state)
    local summary = service:GetInviteSummary()
    local settings = service:GetInviteSettings()
    local scanState = service:GetInviteScanState()
    local entries = service:ListInviteScanEntries() or {}
    local pageSize = 8
    local totalPages = math.max(1, math.ceil(#entries / pageSize))
    local currentPage = math.min(state.maintenanceInvitePage or 1, totalPages)
    local pageStart = ((currentPage - 1) * pageSize) + 1
    local pageEnd = math.min(pageStart + pageSize - 1, #entries)
    local visibleEntries = {}
    local contentWidth = math.max(916, (tonumber(rect.w) or 954) - 36)

    state.maintenanceInvitePage = currentPage

    for index = pageStart, pageEnd do
        visibleEntries[#visibleEntries + 1] = entries[index]
    end

    local function queueRecruiterRefresh()
        refreshSettingsPage()
        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if not (InCombatLockdown and InCombatLockdown()) then
                    refreshSettingsPage()
                end
            end)
        end
    end

    local function ensureInviteSettingsDialog()
        if page._maintenanceInviteSettingsDialog then
            local dialog = page._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.Text:SetText(string.format("Batch: %d", settings.batchSize or 5))
            dialog.zoneScopeButton.Text:SetText(settings.currentZoneOnly and "Zone: Current" or "Zone: All")
            dialog.whisperOnlyButton.Text:SetText(settings.whisperOnly and "Whisper Only: On" or "Whisper Only: Off")
            dialog.whisperButton.Text:SetText(settings.usePrimaryWhisper and "Invite Whisper: On" or "Invite Whisper: Off")
            dialog.announceButton.Text:SetText(settings.announceJoins and "Announce: On" or "Announce: Off")
            dialog.leaverButton.Text:SetText(settings.blacklistLeavers and "Blacklist Leavers: On" or "Blacklist Leavers: Off")
            dialog.syncBlacklistButton.Text:SetText(settings.syncBlacklist and "Sync Blacklist: On" or "Sync Blacklist: Off")
            dialog.syncHistoryButton.Text: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(220)
        dialog:SetSize(472, 566)
        dialog:SetPoint("CENTER")
        dialog:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        dialog:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        dialog:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)
        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, { x = 14, y = -96, w = 92, h = 22 }, "Batch: 5", 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.Text:SetText(string.format("Batch: %d", nextValue))
        end, false)

        dialog.zoneScopeButton = createButton(dialog, { x = 116, y = -96, w = 108, h = 22 }, "Zone: All", function()
            dialog.currentZoneOnly = not dialog.currentZoneOnly
            dialog.zoneScopeButton.Text:SetText(dialog.currentZoneOnly and "Zone: Current" or "Zone: All")
        end, false)

        dialog.whisperButton = createButton(dialog, { x = 234, y = -96, w = 128, h = 22 }, "Invite Whisper: Off", function()
            dialog.usePrimaryWhisper = not dialog.usePrimaryWhisper
            dialog.whisperButton.Text:SetText(dialog.usePrimaryWhisper and "Invite Whisper: On" or "Invite Whisper: Off")
        end, false)

        dialog.announceButton = createButton(dialog, { x = 14, y = -126, w = 108, h = 22 }, "Announce: Off", function()
            dialog.announceJoins = not dialog.announceJoins
            dialog.announceButton.Text:SetText(dialog.announceJoins and "Announce: On" or "Announce: Off")
        end, false)

        dialog.leaverButton = createButton(dialog, { x = 132, y = -126, w = 148, h = 22 }, "Blacklist Leavers: Off", function()
            dialog.blacklistLeavers = not dialog.blacklistLeavers
            dialog.leaverButton.Text:SetText(dialog.blacklistLeavers and "Blacklist Leavers: On" or "Blacklist Leavers: Off")
        end, false)

        dialog.whisperOnlyButton = createButton(dialog, { x = 290, y = -126, w = 124, h = 22 }, "Whisper Only: Off", function()
            dialog.whisperOnly = not dialog.whisperOnly
            dialog.whisperOnlyButton.Text:SetText(dialog.whisperOnly and "Whisper Only: On" or "Whisper Only: Off")
        end, false)

        dialog.syncBlacklistButton = createButton(dialog, { x = 14, y = -156, w = 128, h = 22 }, "Sync Blacklist: Off", function()
            dialog.syncBlacklist = not dialog.syncBlacklist
            dialog.syncBlacklistButton.Text:SetText(dialog.syncBlacklist and "Sync Blacklist: On" or "Sync Blacklist: Off")
        end, false)

        dialog.syncHistoryButton = createButton(dialog, { x = 152, y = -156, w = 128, h = 22 }, "Sync Invites: Off", function()
            dialog.syncInviteHistory = not dialog.syncInviteHistory
            dialog.syncHistoryButton.Text:SetText(dialog.syncInviteHistory and "Sync Invites: On" or "Sync Invites: Off")
        end, false)

        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()
            local preview = service:FormatInviteMessage(dialog.primaryMessageInput:GetText(), "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")),
                " ",
                "Preview for invite whisper:",
                preview ~= "" and preview or "No message saved yet.",
            }
        end)
        attachHoverNote(dialog.primaryMessageContainer, "Invite Whisper Preview", function()
            local preview = service:FormatInviteMessage(dialog.primaryMessageInput:GetText(), "Recruit")
            return preview ~= "" and preview or "No message saved yet."
        end)
        attachHoverNote(welcomeTokensNote, "Welcome Whisper Preview", function()
            local preview = service:FormatInviteMessage(dialog.welcomeMessageInput:GetText(), "Recruit")
            return preview ~= "" and preview or "No message saved yet."
        end)
        attachHoverNote(dialog.welcomeMessageContainer, "Welcome Whisper Preview", function()
            local preview = service:FormatInviteMessage(dialog.welcomeMessageInput:GetText(), "Recruit")
            return preview ~= "" and preview or "No message saved yet."
        end)

        local saveButton = createButton(dialog, { x = 370, y = -528, w = 88, h = 24 }, "Save", 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()
            refreshSettingsPage()
        end, false)

        createButton(dialog, { x = 272, y = -528, w = 88, h = 24 }, "Close", function()
            dialog:Hide()
        end, false)

        page._maintenanceInviteSettingsDialog = dialog
        return ensureInviteSettingsDialog()
    end

    local invitesPanel = createPanel(host, { x = 18, y = -66, w = contentWidth, h = 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, { x = 0, y = 0, w = 22, h = 20 }, "?", function()
        openSettingsHelpArticle("recruiter")
    end, false)
    helpButton:ClearAllPoints()
    helpButton:SetPoint("LEFT", title, "RIGHT", 8, -1)
    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 startScanButton = createButton(toolbarRow, { x = 0, y = 0, w = 92, h = 24 }, (scanCooldownSeconds > 0 and not scanState.sessionActive) and string.format("Wait %d sec", scanCooldownSeconds) or "Start Scan", function()
        local ok, message = service:StartInviteScan()
        if not ok then
            addon:Print(message or "Unable to start the invite scan.")
            return
        end
        state.maintenanceInvitePage = 1
        addon:Print("Recruiter scan session started.")
        queueRecruiterRefresh()
    end, false)
    startScanButton:SetEnabled(summary.scanStatus ~= "scanning" and scanCooldownSeconds <= 0)

    local scanNextButton = createButton(toolbarRow, { x = 100, y = 0, w = 92, h = 24 }, (scanCooldownSeconds > 0 and scanState.sessionActive and not scanState.sessionCompleted and summary.scanStatus ~= "scanning") and string.format("Wait %d sec", scanCooldownSeconds) or "Scan Next", 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, false)
    scanNextButton:SetEnabled((scanState.sessionActive and not scanState.sessionCompleted and summary.scanStatus ~= "scanning" and scanCooldownSeconds <= 0) and true or false)

    local inviteNextButton = createButton(toolbarRow, { x = 200, y = 0, w = 104, h = 24 }, string.format("%s Next", inviteActionLabel), 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, false)
    inviteNextButton:SetEnabled(canSendInviteAction)

    local inviteBatchButton = createButton(toolbarRow, { x = 312, y = 0, w = 112, h = 24 }, string.format("%s Batch", inviteActionLabel), 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, false)
    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())))
    positionRecruiterInviteBatchButton(inviteBatchButton, inviteBatchPayload, inviteBatchPreparedMacro)

    createButton(toolbarRow, { x = 428, y = 0, w = 92, h = 24 }, "Select All", function()
        service:SetAllInviteCandidateSelected(true)
        refreshSettingsPage()
    end, false)

    createButton(toolbarRow, { x = 524, y = 0, w = 92, h = 24 }, "Select None", function()
        service:SetAllInviteCandidateSelected(false)
        refreshSettingsPage()
    end, false)

    createButton(toolbarRow, { x = 620, y = 0, w = 88, h = 24 }, "Settings", function()
        ensureInviteSettingsDialog():Show()
    end, false)

    createButton(toolbarRow, { x = 712, y = 0, w = 92, h = 24 }, "Reset Scan", function()
        service:ClearInviteScan()
        state.maintenanceInvitePage = 1
        queueRecruiterRefresh()
    end, false)

    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, { x = 0, y = 0, w = 888, h = 232 })
    queuePanel:ClearAllPoints()
    queuePanel:SetPoint("TOPLEFT", statusText, "BOTTOMLEFT", 0, -8)
    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, { x = 692, y = -12, w = 72, h = 22 }, "< Prev", function()
        state.maintenanceInvitePage = math.max(1, currentPage - 1)
        refreshSettingsPage()
    end, false):SetEnabled(currentPage > 1)

    createButton(queuePanel, { x = 844, y = -12, w = 72, h = 22 }, "Next >", function()
        state.maintenanceInvitePage = math.min(totalPages, currentPage + 1)
        refreshSettingsPage()
    end, false):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 = createListRow(queueContent, { x = 0, y = -((visibleIndex - 1) * 40), w = 844, h = 34 }, visibleIndex % 2 == 0)
            if entry.isSelected then
                row:SetBackdropColor(0.11, 0.16, 0.22, 0.98)
            end

            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, { x = 582, y = -7, w = 72, h = 20 }, entry.isSelected and "Selected" or "Select", function()
                service:ToggleInviteCandidateSelected(entry.candidateName)
                queueRecruiterRefresh()
            end, entry.isSelected)
            selectButton:SetEnabled(not entry.isBlacklisted and not entry.isPending)

            local inviteButton = createButton(row, { x = 662, y = -7, w = 60, h = 20 }, inviteActionLabel, 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, false)
            inviteButton:SetEnabled(not entry.isBlacklisted and not entry.isSuppressed and not entry.isPending and canSendInviteAction)

            createButton(row, { x = 730, y = -7, w = 54, h = 20 }, entry.isBlacklisted and "Blocked" or "Block", function()
                service:BlockInviteName(entry.candidateName, "scan")
                service:SkipInviteCandidate(entry.candidateName)
                queueRecruiterRefresh()
            end, false):SetEnabled(not entry.isBlacklisted and not entry.isPending)

            createButton(row, { x = 792, y = -7, w = 46, h = 20 }, "Skip", function()
                service:SkipInviteCandidate(entry.candidateName)
                queueRecruiterRefresh()
            end, false):SetEnabled(not entry.isPending)

            totalHeight = totalHeight + 40
        end

        queueContent:SetHeight(math.max(totalHeight, 34))
    end

    local whisperPanel = createPanel(invitesPanel, { x = 0, y = 0, w = 436, h = 116 })
    whisperPanel:ClearAllPoints()
    whisperPanel:SetPoint("TOPLEFT", queuePanel, "BOTTOMLEFT", 0, -12)
    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, { x = 0, y = 0, w = 436, h = 116 })
    actionsPanel:ClearAllPoints()
    actionsPanel:SetPoint("TOPLEFT", whisperPanel, "TOPRIGHT", 16, 0)
    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)
end

local function renderMaintenanceAuditTab(host, rect, service, state)
    local settings = service:GetSettings()
    local auditSummary = service:GetAuditSummary()
    local entries = service:ListAuditEntries() or {}
    local pageSize = 10
    local totalPages = math.max(1, math.ceil(#entries / pageSize))
    local currentPage = math.min(state.maintenanceAuditPage or 1, totalPages)
    local startIndex = ((currentPage - 1) * pageSize) + 1
    local endIndex = math.min(#entries, startIndex + pageSize - 1)
    local contentWidth = math.max(918, math.floor((tonumber(rect.w) or 954) - 36))

    state.maintenanceAuditPage = currentPage

    local function refreshAuditView(resetPage)
        if resetPage then
            state.maintenanceAuditPage = 1
        end
        refreshSettingsPage()
    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 page._maintenanceAuditColorDialog then
            local dialog = page._maintenanceAuditColorDialog
            syncDialogState(dialog)
            return dialog
        end

        local dialog = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        dialog:SetFrameStrata("FULLSCREEN_DIALOG")
        dialog:SetFrameLevel(220)
        dialog:SetSize(432, 316)
        dialog:SetPoint("CENTER")
        dialog:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8X8",
            edgeFile = "Interface\\Buttons\\WHITE8X8",
            edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        dialog:SetBackdropColor(0.02, 0.03, 0.05, 0.985)
        dialog:SetBackdropBorderColor(0.62, 0.48, 0.18, 1)
        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 = createButton(dialog, { x = 246, y = rowOffset + 4, w = 86, h = 22 }, "Color", 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, false)

            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

        createButton(dialog, { x = 330, y = -278, w = 88, h = 24 }, "Save", function()
            service:UpdateSettings({ auditTextColors = dialog.pendingColors })
            dialog:Hide()
            refreshAuditView(false)
        end, false)

        createButton(dialog, { x = 232, y = -278, w = 88, h = 24 }, "Close", function()
            dialog:Hide()
        end, false)

        page._maintenanceAuditColorDialog = dialog
        syncDialogState(dialog)
        return dialog
    end

    local summaryText = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    summaryText:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -58)
    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 = createPanel(host, { x = 18, y = -98, w = contentWidth, h = 116 })

    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))

    createButton(settingsPanel, { x = 270, y = -70, w = 94, h = 22 }, "Apply Limits", function()
        service:UpdateSettings({
            auditMaxRows = rowsInput:GetText(),
            auditRetentionDays = daysInput:GetText(),
        })
        refreshAuditView(true)
    end, false)

    local colorButton = createButton(settingsPanel, { x = 376, y = -70, w = 76, h = 22 }, "Color", function()
        ensureAuditColorDialog():Show()
    end, false)
    attachHoverNote(colorButton, "Audit Colors", {
        "Open the audit log color settings popup.",
        "Save inside the popup to keep new colors.",
    })

    createButton(settingsPanel, { x = 464, y = -70, w = 86, h = 22 }, "Clear Log", function()
        service:ClearAuditLog()
        refreshAuditView(true)
    end, false)

    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, { x = 18, y = -220, w = contentWidth, h = math.max((tonumber(rect.h) or 638) - 238, 300) })

    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 = createListRow(listHost, { x = 20, y = -54, w = 848, h = 24 }, false)
    local function createAuditHeader(parentFrame, text, x, width)
        local label = parentFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        label:SetPoint("LEFT", parentFrame, "LEFT", x, 0)
        label:SetWidth(width)
        label:SetJustifyH("LEFT")
        label:SetText(text)
        styleHighlightText(label)
        return label
    end
    createAuditHeader(columnHeaderRow, "When", 8, 126)
    createAuditHeader(columnHeaderRow, "Event", 142, 92)
    createAuditHeader(columnHeaderRow, "Member", 242, 162)
    createAuditHeader(columnHeaderRow, "Detail", 412, 394)
    createAuditHeader(columnHeaderRow, "Src", 812, 42)

    local scrollFrame = CreateFrame("ScrollFrame", nil, listHost, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", listHost, "TOPLEFT", 20, -82)
    scrollFrame:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -30, 48)

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetWidth(848)
    scrollChild:SetHeight(1)
    scrollFrame:SetScrollChild(scrollChild)

    if #entries == 0 then
        local empty = scrollChild:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        empty:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 20, -8)
        empty:SetWidth(792)
        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, false)
        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 = createListRow(scrollChild, { x = 6, y = -((index - 1) * rowPitch), w = 842, h = rowHeight }, index % 2 == 0)

            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, false)

            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, false)

            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)

            if settings.auditUseColors ~= false then
                applyAuditRowVisual(row, { whenLabel, eventLabel, memberLabel, detailLabel, sourceLabel }, 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)

    local previousButton = createButton(listHost, { x = 0, y = 0, w = 100, h = 30 }, "Previous", function()
        if state.maintenanceAuditPage > 1 then
            state.maintenanceAuditPage = state.maintenanceAuditPage - 1
            refreshSettingsPage()
        end
    end, false)
    previousButton:ClearAllPoints()
    previousButton:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -120, 10)
    previousButton:SetEnabled(currentPage > 1)

    local nextButton = createButton(listHost, { x = 0, y = 0, w = 100, h = 30 }, "Next", function()
        if state.maintenanceAuditPage < totalPages then
            state.maintenanceAuditPage = state.maintenanceAuditPage + 1
            refreshSettingsPage()
        end
    end, false)
    nextButton:ClearAllPoints()
    nextButton:SetPoint("BOTTOMRIGHT", listHost, "BOTTOMRIGHT", -10, 10)
    nextButton:SetEnabled(currentPage < totalPages)

    local resetButton = createButton(listHost, { x = 0, y = 0, w = 100, h = 30 }, "Reset", function()
        state.maintenanceAuditPage = 1
        refreshSettingsPage()
    end, false)
    resetButton:ClearAllPoints()
    resetButton:SetPoint("BOTTOMLEFT", listHost, "BOTTOMLEFT", 10, 10)
end

local function renderMaintenancePane(parent, rect)
    local service = addon.MaintenanceService
    local mainFrame = addon.MainFrame
    if mainFrame and mainFrame.EnsureState then
        mainFrame:EnsureState()
    end

    local state = (mainFrame and mainFrame.state) or page.State
    state.maintenanceTab = (state.maintenanceTab == "invites" or state.maintenanceTab == "audit") and state.maintenanceTab or "cleanup"

    local host = CreateFrame("Frame", nil, parent)
    ApplyRect(host, parent, rect)

    local title = host:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -8)
    title:SetText("Maintenance")
    styleTitleText(title, 18)

    createButton(host, { x = 18, y = -30, w = 126, h = 24 }, state.maintenanceTab == "cleanup" and "Clean-up *" or "Clean-up", function()
        state.maintenanceTab = "cleanup"
        refreshSettingsPage()
    end, state.maintenanceTab == "cleanup")
    createButton(host, { x = 152, y = -30, w = 126, h = 24 }, state.maintenanceTab == "invites" and "Invites *" or "Invites", function()
        state.maintenanceTab = "invites"
        refreshSettingsPage()
    end, state.maintenanceTab == "invites")
    createButton(host, { x = 286, y = -30, w = 126, h = 24 }, state.maintenanceTab == "audit" and "Audit Logs *" or "Audit Logs", function()
        state.maintenanceTab = "audit"
        refreshSettingsPage()
    end, state.maintenanceTab == "audit")

    if not service then
        hideRecruiterInviteBatchButton()
        local unavailable = host:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        unavailable:SetPoint("TOPLEFT", host, "TOPLEFT", 18, -74)
        unavailable:SetText("Maintenance service is unavailable.")
        styleBodyText(unavailable, false)
        return host
    end

    if state.maintenanceTab == "invites" then
        renderMaintenanceInvitesTab(host, rect, service, state)
    elseif state.maintenanceTab == "audit" then
        hideRecruiterInviteBatchButton()
        renderMaintenanceAuditTab(host, rect, service, state)
    else
        hideRecruiterInviteBatchButton()
        renderMaintenanceCleanupTab(host, rect, service, state)
    end

    return host
end

refreshPage = function(parent, snapshot)
    if page._rootFrame then
        cleanupFrameTree(page._rootFrame)
        page._rootFrame = nil
    end
    page:Render(parent, snapshot)
end

function page:Render(parent, snapshot)
    local settingsLayout = addon.NewUILayout and addon.NewUILayout.Settings or {}
    local root = CreateFrame("Frame", nil, parent)
    local tabs = settingsLayout.Tabs or {}
    local activePane = page.State.activePane or "settings"
    local hostRect = buildUnionRect(activePane == "datasync" and { settingsLayout.DataSyncPane and settingsLayout.DataSyncPane.Actions, settingsLayout.DataSyncPane and settingsLayout.DataSyncPane.Peers, settingsLayout.DataSyncPane and settingsLayout.DataSyncPane.Diagnostics, settingsLayout.DataSyncPane and settingsLayout.DataSyncPane.Conflicts }
        or activePane == "setup" and { settingsLayout.SetupPane and settingsLayout.SetupPane.Header, settingsLayout.SetupPane and settingsLayout.SetupPane.Party, settingsLayout.SetupPane and settingsLayout.SetupPane.Linked }
        or activePane == "help" and { settingsLayout.HelpPane and settingsLayout.HelpPane.Nav, settingsLayout.HelpPane and settingsLayout.HelpPane.Article }
        or activePane == "maintenance" and { settingsLayout.MaintenancePane and settingsLayout.MaintenancePane.Toolbar, settingsLayout.MaintenancePane and settingsLayout.MaintenancePane.Panel }
        or activePane == "tester" and { settingsLayout.TesterPane and settingsLayout.TesterPane.Panel }
        or { settingsLayout.SettingsPane and settingsLayout.SettingsPane.Panel })

    ApplyRect(root, parent, settingsLayout.Page or { x = 0, y = 0, w = tonumber(parent:GetWidth()) or 1380, h = tonumber(parent:GetHeight()) or 742 })
    page._rootFrame = root
    page._parentFrame = parent
    page._snapshot = snapshot
    if activePane ~= "maintenance" then
        hideRecruiterInviteBatchButton()
    end
    createButton(root, tabs.Settings or { x = 20, y = -2, w = 112, h = 28 }, "Settings", function() page.State.activePane = "settings"; refreshPage(parent, snapshot) end, activePane == "settings")
    createButton(root, tabs.DataSync or { x = 140, y = -2, w = 122, h = 28 }, "DataSync", function() page.State.activePane = "datasync"; refreshPage(parent, snapshot) end, activePane == "datasync")
    createButton(root, tabs.LinkedGuildSetup or { x = 270, y = -2, w = 172, h = 28 }, "Linked Guild Setup", function() page.State.activePane = "setup"; refreshPage(parent, snapshot) end, activePane == "setup")
    createButton(root, tabs.Help or { x = 450, y = -2, w = 88, h = 28 }, "Help", function() page.State.activePane = "help"; refreshPage(parent, snapshot) end, activePane == "help")
    createButton(root, tabs.Maintenance or { x = 546, y = -2, w = 142, h = 28 }, "Maintenance", function() page.State.activePane = "maintenance"; refreshPage(parent, snapshot) end, activePane == "maintenance")
    createButton(root, tabs.Tester or { x = 696, y = -2, w = 92, h = 28 }, "Tester", function() page.State.activePane = "tester"; refreshPage(parent, snapshot) end, activePane == "tester")

    if activePane == "tester" then
        renderTesterPane(root, hostRect)

        if page.State.previewOverlay == "bis-loot-helper" then
            renderBISLootPreviewOverlay(root)
        elseif page.State.previewOverlay == "mythic-summary" then
            renderMythicSummaryPreviewOverlay(root)
        elseif page.State.previewOverlay == "mythic-timer" then
            renderMythicTimerPreviewOverlay(root)
        end
    elseif activePane == "settings" then
        renderSettingsPane(root, hostRect)
    elseif activePane == "datasync" then
        renderDataSyncPane(root, hostRect)
    elseif activePane == "setup" then
        renderSetupPane(root, hostRect)
    elseif activePane == "help" then
        renderHelpPane(root, hostRect)
    elseif activePane == "maintenance" then
        renderMaintenancePane(root, hostRect)
    end

    return root
end

return page