local addonName, TOAST = ...

local FRAME_W  = 745
local BANNER_H = 100
local LABELS_H = 20
local ROW_H    = 26

-- Table viewport sizing: derive the frame/scrollframe height from named
-- constants so exactly VISIBLE_ROWS rows fit with no partial row, instead of
-- an independently eyeballed FRAME_H.
local SEARCH_AREA_HEIGHT    = 46  -- header bottom -> table header top (search box + spacing)
local TABLE_HEADER_GAP      = 3   -- grey separator (1px) + 2px clearance before the scrollframe
local VISIBLE_ROWS          = 20
local TABLE_VIEWPORT_HEIGHT = ROW_H * VISIBLE_ROWS
local BOTTOM_PADDING        = 20  -- clearance below the scrollframe for the backdrop border

local SF_TOP  = BANNER_H + SEARCH_AREA_HEIGHT + LABELS_H + TABLE_HEADER_GAP
local FRAME_H = SF_TOP + TABLE_VIEWPORT_HEIGHT + BOTTOM_PADDING

local GOLD = { 0.88, 0.75, 0.36 }
local TEAL = { 0, 0.92, 0.79 }  -- #00EAC9
local BORDER = { 1, 1, 1, 0.12 }
-- Every non-live line of the presence tooltip: an age and "offline" alike.
-- One constant so the two can't drift into slightly different greys.
local PRESENCE_MUTED = { 0.65, 0.65, 0.65 }
-- Owned by presence.lua, which loads earlier in the .toc. Kept as a reference
-- rather than a local literal so the dot, its tooltip and the dashboard crew
-- count can only ever share one window.
local PRESENCE_RECENT_SECONDS = TOAST.PRESENCE_RECENT_SECONDS

local COLS = {
    { label = "#",          width = 28,  xOff = 4,   align = "RIGHT"  },
    { label = "Discord",    width = 140, xOff = 36,  align = "LEFT",
      -- sub-offsets within the Discord column, shared by header + row rendering.
      -- statusOff/iconOff are both fixed (never conditional on online status,
      -- never shift with each other) so the status dot reads as its own
      -- small column and the Discord identity group (icon/label/name) always
      -- starts at the same X regardless of whether a dot is shown. Label and
      -- name are anchored relative to the icon/label (not fixed columns, see
      -- row rendering below) so the icon->label->name gaps match the Status
      -- Report card and View Crew popup exactly.
      statusOff = 4,   -- online/known status dot
      iconOff   = 18,  -- prefix icon (or plain name when there's no prefix) -- Discord identity group start
    },
    { label = "Char-Realm", width = 150, xOff = 180, align = "LEFT"   },
    { label = "Role",       width = 50,  xOff = 334, align = "LEFT"   },
    { label = "RIO",  width = 55, xOff = 388, align = "CENTER" },
    { label = "Resi", width = 90, xOff = 468, align = "CENTER",
      headerTex = "Interface\\AddOns\\TOAST\\media\\discord\\resi_256x128.tga", texW = 96, texH = 48 },
    { label = "Key",  width = 80, xOff = 576, align = "CENTER" },
}

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

local PREFIX_MAP = {
    ["🌊ᴛɪᴅᴇ"] = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\tide", label = "TIDE" },
    ["⚡ᴛɪᴅᴇ"]  = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\raid", label = "TIDE" },
    ["🔱ᴛɪᴅᴇ"] = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\daan", label = "TIDE" },
    ["🧭ᴛɪᴅᴇ"] = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\way",  label = "TIDE" },
    ["💦ꜱᴍɴ"]  = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\smn",  label = "SMN"  },
    ["⚓ᴄᴘᴛ"]  = { tex = "Interface\\AddOns\\TOAST\\media\\prefixes\\cpt",  label = "CPT"  },
}

local ROLE_TEX = {
    HEAL = "Interface\\AddOns\\TOAST\\media\\roles\\heal_32.tga",
    DPS  = "Interface\\AddOns\\TOAST\\media\\roles\\dps_32.tga",
    TANK = "Interface\\AddOns\\TOAST\\media\\roles\\tank_32.tga",
}

TOAST.CLASS_TOKEN = CLASS_TOKEN
TOAST.PREFIX_MAP  = PREFIX_MAP
TOAST.ROLE_TEX    = ROLE_TEX

local function ParseDiscordPrefix(discordNickname)
    local prefix = discordNickname:match("^(%S+)")
    local p = prefix and PREFIX_MAP[prefix]
    local name = discordNickname:match("^%S+ (.+)$") or discordNickname
    return p, name
end
TOAST.ParseDiscordPrefix = ParseDiscordPrefix

-- Column-header filters (leaderboard): each prefix label may have several
-- emoji variants in PREFIX_MAP (different servers/roles); pick one icon per
-- label to represent the whole group in the filter header.
local PREFIX_ICON_BY_LABEL = {}
for _, p in pairs(PREFIX_MAP) do
    if not PREFIX_ICON_BY_LABEL[p.label] then
        PREFIX_ICON_BY_LABEL[p.label] = p.tex
    end
end

local ROLE_FILTER_ORDER = { "HEAL", "TANK", "DPS" }
local PREFIX_CYCLE = { "TIDE", "SMN", "CPT" }
local KEY_CYCLE     = { 15, 18, 20, 22 }

-- Role header icon layout, shared between the clickable header group
-- (BuildFilterBar) and the row role icon below it (BuildLeaderboard), so the
-- row icon can be centered under the header group instead of hardcoding a
-- second copy of these numbers.
local ROLE_ICON_SIZE   = 20  -- was 14; stays under the 22px row role icon size
local ROLE_ICON_GAP    = 4
local ROLE_HEADER_XOFF = 6  -- shifts the header icon group right to line up with row icons
local ROLE_ROW_ICON_CENTER_XOFF = ROLE_HEADER_XOFF
    + (ROLE_ICON_SIZE * #ROLE_FILTER_ORDER + ROLE_ICON_GAP * (#ROLE_FILTER_ORDER - 1)) / 2

-- Column centers used to evenly space [Role icons] [RIO] [Resi] in the
-- header: RIO sits at the exact midpoint between the other two instead of
-- an eyeballed xOff.
local function ColByLabel(label)
    for _, col in ipairs(COLS) do
        if col.label == label then return col end
    end
end
local ROLE_COL, RESI_COL = ColByLabel("Role"), ColByLabel("Resi")
local ROLE_CENTER_X = ROLE_COL.xOff + ROLE_ROW_ICON_CENTER_XOFF
local RESI_CENTER_X = RESI_COL.xOff + RESI_COL.width / 2
local RIO_CENTER_X  = math.floor((ROLE_CENTER_X + RESI_CENTER_X) / 2)

-- Discord prefix icon + label layout, shared between building the header
-- (icon size, icon-to-text gap) and sizing the active-filter underline so
-- the underline can't drift out of sync with the actual icon/text spacing.
local DISCORD_ICON_SIZE = 16
local DISCORD_ICON_GAP  = 4

local function CycleNext(list, current)
    if current == nil then return list[1] end
    for i, v in ipairs(list) do
        if v == current then return list[i + 1] end
    end
    return list[1]
end

local function GetAddonStatus(entry)
    -- The crew id is what lets an online alt light up the row of its main.
    -- GetPresenceStatus answers for the row's own character first, so no
    -- direct-match pre-check is needed here.
    if not TOAST.GetPresenceStatus then return nil end
    local _, discordId = TOAST.CrewState.ResolveEntry(entry)

    -- A tail call, never an `and`/`or` expression: that form is an expression
    -- and truncates the call to a single value, which dropped activeCharacter
    -- and seenAt and left every row that is only present through a crew mate
    -- grey, however correct the crew id was.
    return TOAST.GetPresenceStatus(entry.name, entry.realm, discordId)
end

local function PresenceAge(timestamp)
    timestamp = tonumber(timestamp)
    if not timestamp or timestamp <= 0 then return "offline" end
    local seconds = math.max(0, GetServerTime() - timestamp)
    if seconds >= PRESENCE_RECENT_SECONDS then return "offline" end
    return math.floor(seconds / 60) .. "m ago"
end

local function IsRecentPresence(seenAt)
    seenAt = tonumber(seenAt)
    return seenAt and seenAt > 0
        and (GetServerTime() - seenAt) < PRESENCE_RECENT_SECONDS
end

local function ShowPresenceTooltip(anchor, entry)
    local status, activeCharacter, seenAt = GetAddonStatus(entry)
    if not status then return end
    GameTooltip:Hide()
    GameTooltip:SetOwner(anchor, "ANCHOR_RIGHT")
    GameTooltip._toastPresenceOnly = true
    GameTooltip:SetText(entry.name .. "-" .. entry.realm, 1, 1, 1)
    if activeCharacter then
        GameTooltip:AddLine("now", TEAL[1], TEAL[2], TEAL[3])
    else
        -- Teal is reserved for the "now" branch above. An age and "offline"
        -- both describe someone who isn't here, so they share one grey.
        GameTooltip:AddLine(PresenceAge(seenAt),
            PRESENCE_MUTED[1], PRESENCE_MUTED[2], PRESENCE_MUTED[3])
    end
    GameTooltip:Show()
    -- The generic tooltip hook has already run synchronously during Show.
    GameTooltip._toastPresenceOnly = nil
end

local function HidePresenceTooltip()
    GameTooltip._toastPresenceOnly = nil
    GameTooltip:Hide()
end

TOAST.ShowLeaderboardPresenceTooltip = ShowPresenceTooltip
TOAST.HideLeaderboardPresenceTooltip = HidePresenceTooltip

local lb
local rows = {}
-- Module-level (not BuildLeaderboard-local) so RefreshResolvedLeaderboardRows
-- (called from ShowLeaderboard, outside BuildLeaderboard's closure) can
-- re-run it after reordering `rows` into resolved rank order.
local ApplyFilters

local activeRoleFilter   = nil  -- nil = all, "HEAL"/"TANK"/"DPS"
local activePrefixFilter = nil  -- nil = all, "TIDE"/"SMN"/"CPT"
local activeKeyMin       = nil  -- nil = all, 15/18/20/22

-- Builds the clickable column-header filter row and returns the shared
-- ApplyFilters() closure (search + role + prefix + key, AND'd together).
-- Split out from BuildLeaderboard to keep that function's own upvalue count
-- down, since it already builds ~20 header/row widgets.
local function BuildFilterBar(colBar, content, scrollToTop, searchBox, resetBtn)
    local roleButtons = {}
    local discordBtn, discordIcon, discordText, discordUnderline
    local keyText, keyUnderline

    -- Resting (non-hover) look for a role icon: normal and undimmed while no
    -- role filter is active; once one is, the unselected icons dim so the
    -- active one stands out.
    local function RoleIconRestState(role)
        if activeRoleFilter == nil then
            return 1.0, false
        elseif activeRoleFilter == role then
            return 1.0, true
        else
            return 0.3, false
        end
    end

    -- Sized to the current Discord label (+ padding) instead of the full
    -- header width, so it reads as a small clickable/active indicator rather
    -- than a section divider. Re-run whenever discordText's content changes:
    -- with a prefix filter active it covers [icon] + [label] together (the
    -- actual clickable identity), not just the label text on its own.
    local function ResizeDiscordUnderline()
        discordUnderline:ClearAllPoints()
        local groupCenterX
        if activePrefixFilter then
            local groupWidth = DISCORD_ICON_SIZE + DISCORD_ICON_GAP + discordText:GetStringWidth()
            discordUnderline:SetWidth(groupWidth + 8)
            groupCenterX = groupWidth / 2
        else
            discordUnderline:SetWidth(discordText:GetStringWidth() + 8)
            groupCenterX = discordText:GetStringWidth() / 2
        end
        -- Same Y anchor as the Role/Key underlines (button's own BOTTOM,
        -- offset -3), so the gold line consistently falls right on the grey
        -- header separator instead of floating at a different height.
        discordUnderline:SetPoint("BOTTOM", discordBtn, "BOTTOMLEFT", groupCenterX, -3)
    end

    local function RefreshHeaderVisuals()
        for role, btn in pairs(roleButtons) do
            local alpha, showUnderline = RoleIconRestState(role)
            btn.icon:SetAlpha(alpha)
            if showUnderline then btn.underline:Show() else btn.underline:Hide() end
        end

        if activePrefixFilter then
            discordIcon:SetTexture(PREFIX_ICON_BY_LABEL[activePrefixFilter])
            discordIcon:Show()
            discordText:SetPoint("LEFT", discordIcon, "RIGHT", DISCORD_ICON_GAP, 0)
            discordText:SetText(activePrefixFilter)
            ResizeDiscordUnderline()
            discordUnderline:Show()
        else
            discordIcon:Hide()
            discordText:SetPoint("LEFT", discordText:GetParent(), "LEFT", 0, 0)
            discordText:SetText("Discord")
            ResizeDiscordUnderline()
            discordUnderline:Hide()
        end

        if activeKeyMin then
            keyText:SetText("Key " .. activeKeyMin .. "+")
            keyUnderline:Show()
        else
            keyText:SetText("Key")
            keyUnderline:Hide()
        end

        if activeRoleFilter or activePrefixFilter or activeKeyMin then
            resetBtn:Show()
        else
            resetBtn:Hide()
        end
    end

    local function ApplyFilters()
        local query = searchBox and strtrim(searchBox:GetText()):lower() or ""
        if query == "search name, dungeon, role..." then query = "" end

        local visibleIndex = 0
        for _, r in ipairs(rows) do
            local entry = r.entry

            local searchMatch = (query == ""
                or r.name:find(query, 1, true)
                or r.discord:find(query, 1, true)
                or r.role:find(query, 1, true)
                or r.realm:find(query, 1, true)
                or r.rio:find(query, 1, true))
            if not searchMatch then
                local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
                local keyStr = keyData and ("+" .. keyData.level .. " " .. (keyData.short or "")):lower() or ""
                if keyStr:find(query, 1, true) then searchMatch = true end
            end

            local roleMatch = (activeRoleFilter == nil or entry.role == activeRoleFilter)

            local prefixMatch = true
            if activePrefixFilter then
                local p = ParseDiscordPrefix(entry.discord_nickname)
                prefixMatch = (p and p.label == activePrefixFilter) or false
            end

            local keyMinMatch = true
            if activeKeyMin then
                local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
                keyMinMatch = (keyData and keyData.level >= activeKeyMin) or false
            end

            if searchMatch and roleMatch and prefixMatch and keyMinMatch then
                -- Zebra striping follows the current VISIBLE position, not
                -- the unfiltered resolved order RefreshResolvedLeaderboardRows
                -- last set r.isEven to - a hidden row between two shown ones
                -- must not leave a gap in the alternation. Global resolved
                -- rank (r.rank/r.rankFS) is untouched here; only the
                -- background follows the filtered order.
                local visiblePosition = visibleIndex + 1
                r.isEven = (visiblePosition % 2 == 0)
                r.restBg()

                r.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -visibleIndex * ROW_H)
                r.frame:Show()
                visibleIndex = visibleIndex + 1
            else
                r.frame:Hide()
            end
        end
        content:SetHeight(math.max(visibleIndex * ROW_H, 1))
        if scrollToTop then scrollToTop() end
    end

    for _, col in ipairs(COLS) do
        if col.label == "Role" then
            for idx, role in ipairs(ROLE_FILTER_ORDER) do
                local btn = CreateFrame("Button", nil, colBar)
                btn:SetSize(ROLE_ICON_SIZE, ROLE_ICON_SIZE)
                btn:SetPoint("LEFT", colBar, "LEFT",
                    col.xOff + ROLE_HEADER_XOFF + (idx - 1) * (ROLE_ICON_SIZE + ROLE_ICON_GAP), 0)

                local icon = btn:CreateTexture(nil, "OVERLAY")
                icon:SetTexture(ROLE_TEX[role])
                icon:SetAllPoints()
                btn.icon = icon

                -- Gold underline marks the active role; unselected icons dim
                -- once a role filter is active (see RoleIconRestState).
                local underline = btn:CreateTexture(nil, "OVERLAY")
                underline:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
                underline:SetSize(ROLE_ICON_SIZE, 2)
                underline:SetPoint("BOTTOM", btn, "BOTTOM", 0, -3)
                underline:Hide()
                btn.underline = underline

                btn:SetScript("OnClick", function()
                    if activeRoleFilter == role then
                        activeRoleFilter = nil
                    else
                        activeRoleFilter = role
                    end
                    RefreshHeaderVisuals()
                    ApplyFilters()
                end)

                btn:SetScript("OnEnter", function(self)
                    self.icon:SetAlpha(1.0)
                    self.underline:Show()
                end)

                btn:SetScript("OnLeave", function(self)
                    local alpha, showUnderline = RoleIconRestState(role)
                    self.icon:SetAlpha(alpha)
                    if showUnderline then self.underline:Show() else self.underline:Hide() end
                end)

                roleButtons[role] = btn
            end
        elseif col.label == "Discord" then
            local headerXOff = col.xOff + col.iconOff
            local headerWidth = col.width - col.iconOff

            local btn = CreateFrame("Button", nil, colBar)
            btn:SetSize(headerWidth, LABELS_H)
            btn:SetPoint("LEFT", colBar, "LEFT", headerXOff, 0)
            discordBtn = btn

            discordIcon = btn:CreateTexture(nil, "OVERLAY")
            discordIcon:SetSize(DISCORD_ICON_SIZE, DISCORD_ICON_SIZE)
            discordIcon:SetPoint("LEFT", btn, "LEFT", 0, 0)
            discordIcon:Hide()

            discordText = btn:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
            discordText:SetJustifyH("LEFT")
            discordText:SetPoint("LEFT", btn, "LEFT", 0, 0)
            discordText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            discordText:SetText("Discord")

            -- Width/position set by ResizeDiscordUnderline (RefreshHeaderVisuals),
            -- not headerWidth, so it hugs the label text instead of the column.
            discordUnderline = btn:CreateTexture(nil, "OVERLAY")
            discordUnderline:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
            discordUnderline:SetHeight(2)
            discordUnderline:Hide()

            btn:SetScript("OnClick", function()
                activePrefixFilter = CycleNext(PREFIX_CYCLE, activePrefixFilter)
                RefreshHeaderVisuals()
                ApplyFilters()
            end)

            btn:SetScript("OnEnter", function() discordUnderline:Show() end)
            btn:SetScript("OnLeave", function()
                if not activePrefixFilter then discordUnderline:Hide() end
            end)
        elseif col.label == "Key" then
            local btn = CreateFrame("Button", nil, colBar)
            btn:SetSize(col.width, LABELS_H)
            btn:SetPoint("LEFT", colBar, "LEFT", col.xOff, 0)

            keyText = btn:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
            keyText:SetWidth(col.width)
            keyText:SetJustifyH(col.align)
            keyText:SetPoint("LEFT", btn, "LEFT", 0, 0)
            keyText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            keyText:SetText("Key")

            keyUnderline = btn:CreateTexture(nil, "OVERLAY")
            keyUnderline:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
            keyUnderline:SetSize(col.width, 2)
            keyUnderline:SetPoint("BOTTOM", btn, "BOTTOM", 0, -3)
            keyUnderline:Hide()

            btn:SetScript("OnClick", function()
                activeKeyMin = CycleNext(KEY_CYCLE, activeKeyMin)
                RefreshHeaderVisuals()
                ApplyFilters()
            end)

            btn:SetScript("OnEnter", function() keyUnderline:Show() end)
            btn:SetScript("OnLeave", function()
                if not activeKeyMin then keyUnderline:Hide() end
            end)
        elseif col.label == "RIO" then
            -- Centered at the exact midpoint between the Role icon group and
            -- the Resi header (see RIO_CENTER_X above), not col.xOff/width.
            local fs = colBar:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
            fs:SetPoint("CENTER", colBar, "LEFT", RIO_CENTER_X, 0)
            fs:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            fs:SetText(col.label)
        elseif col.headerTex then
            local tex = colBar:CreateTexture(nil, "OVERLAY")
            tex:SetTexture(col.headerTex)
            tex:SetSize(col.texW, col.texH)
            tex:SetPoint("CENTER", colBar, "LEFT", col.xOff + col.width / 2, 0)
        else
            local fs = colBar:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
            fs:SetWidth(col.width)
            fs:SetJustifyH(col.align)
            fs:SetPoint("LEFT", colBar, "LEFT", col.xOff, 0)
            fs:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            fs:SetText(col.label)
        end
    end

    resetBtn:SetScript("OnClick", function()
        activeRoleFilter, activePrefixFilter, activeKeyMin = nil, nil, nil
        if searchBox then searchBox:SetText("") end
        RefreshHeaderVisuals()
        ApplyFilters()
    end)

    RefreshHeaderVisuals()
    return ApplyFilters
end

local function BuildLeaderboard()
    lb = CreateFrame("Frame", "TOASTLeaderboard", UIParent, "BackdropTemplate")
    Mixin(lb, BackdropTemplateMixin)
    lb:SetSize(FRAME_W, FRAME_H)
    TOAST.RestoreWindowPosition(lb, "leaderboard")
    lb:SetMovable(true)
    lb:SetClampedToScreen(true)
    lb:SetFrameStrata("HIGH")
    lb:Hide()

    local bg = lb:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 1.0)

    local borderTop = lb:CreateTexture(nil, "BORDER")
    borderTop:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
    borderTop:SetPoint("TOPLEFT", lb, "TOPLEFT", 0, 0)
    borderTop:SetPoint("TOPRIGHT", lb, "TOPRIGHT", 0, 0)
    borderTop:SetHeight(1)

    local borderBottom = lb:CreateTexture(nil, "BORDER")
    borderBottom:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
    borderBottom:SetPoint("BOTTOMLEFT", lb, "BOTTOMLEFT", 0, 0)
    borderBottom:SetPoint("BOTTOMRIGHT", lb, "BOTTOMRIGHT", 0, 0)
    borderBottom:SetHeight(1)

    local borderLeft = lb:CreateTexture(nil, "BORDER")
    borderLeft:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
    borderLeft:SetPoint("TOPLEFT", lb, "TOPLEFT", 0, 0)
    borderLeft:SetPoint("BOTTOMLEFT", lb, "BOTTOMLEFT", 0, 0)
    borderLeft:SetWidth(1)

    local borderRight = lb:CreateTexture(nil, "BORDER")
    borderRight:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
    borderRight:SetPoint("TOPRIGHT", lb, "TOPRIGHT", 0, 0)
    borderRight:SetPoint("BOTTOMRIGHT", lb, "BOTTOMRIGHT", 0, 0)
    borderRight:SetWidth(1)

    -- ── Header banner ──────────────────────────────────────────────
    local header = CreateFrame("Frame", nil, lb)
    header:SetSize(FRAME_W, BANNER_H)
    header:SetPoint("TOPLEFT", lb, "TOPLEFT", 0, 0)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function() lb:StartMoving() end)
    header:SetScript("OnDragStop",  function()
        lb:StopMovingOrSizing()
        TOAST.SaveWindowPosition(lb, "leaderboard")
    end)

    -- Gold top accent, on its own frame leveled above the header so the
    -- tentacle banner (a header child frame, which outranks lb's own layers)
    -- can't draw over it and muddy the gold — same fix as the divider below.
    local topAccentFrame = CreateFrame("Frame", nil, lb)
    topAccentFrame:SetAllPoints(lb)
    topAccentFrame:SetFrameLevel(header:GetFrameLevel() + 1)

    local topAccent = topAccentFrame:CreateTexture(nil, "ARTWORK")
    topAccent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    topAccent:SetPoint("TOPLEFT", lb, "TOPLEFT", 0, 0)
    topAccent:SetPoint("TOPRIGHT", lb, "TOPRIGHT", 0, 0)
    topAccent:SetHeight(2)

    -- Nautical tentacle artwork spanning the full header; the asset itself
    -- is dark/empty on the left and fades into tentacles on the right.
    local tentacle = header:CreateTexture(nil, "BACKGROUND")
    tentacle:SetTexture("Interface\\AddOns\\TOAST\\media\\banners\\tentakel.png")
    tentacle:SetTexCoord(0, 1, 0, 1)
    tentacle:SetAllPoints(header)
    tentacle:SetVertexColor(1, 1, 1, 0.48)

    local logo = header:CreateTexture(nil, "OVERLAY")
    logo:SetTexture("Interface\\AddOns\\TOAST\\media\\discord\\toast_128.tga")
    logo:SetSize(64, 64)
    logo:SetPoint("TOPLEFT", header, "TOPLEFT", 12, -8)

    local titleFS = header:CreateFontString(nil, "OVERLAY", "TOASTFontNormalHuge")
    titleFS:SetPoint("TOPLEFT", logo, "TOPRIGHT", 10, -8)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    titleFS:SetText("TOAST LEADERBOARD")

    local subFS = header:CreateFontString(nil, "OVERLAY", "TOASTFontNormalSmall")
    subFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    subFS:SetJustifyH("LEFT")
    subFS:SetTextColor(1, 1, 1, 0.45)
    subFS:SetText("Totally Overengineered Aquatic Support Tech\nSeason Rankings  \226\128\162  " .. #TOAST.leaderboard .. " members  \226\128\162  v" .. TOAST.VERSION)

    TOAST.CreateCloseButton(lb, function() TOAST.commands["lb"]() end)

    local backBtn = TOAST.CreateActionButton(lb, "DASHBOARD", "gold", 90, 22)
    backBtn:SetPoint("TOPRIGHT", lb, "TOPRIGHT", -36, -10)
    backBtn:SetFrameLevel(lb:GetFrameLevel() + 5)
    -- Right-click opens the dashboard alongside the leaderboard instead of
    -- switching to it, so both windows can stay open side by side.
    backBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    backBtn:SetScript("OnClick", function(self, button)
        if button == "RightButton" then
            TOAST.ShowDashboard()
        else
            TOAST.commands["lb"]()
            TOAST.commands["dashboard"]()
        end
    end)

    -- Slightly more solid background so the tentacle banner behind it reads
    -- less busy (overrides just this button's own bg alpha, not the shared style).
    local backBtnBg = select(1, backBtn:GetRegions())
    if backBtnBg then
        backBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30)
        backBtn:SetScript("OnEnter", function() backBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.42) end)
        backBtn:SetScript("OnLeave", function() backBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30) end)
    end

    -- ── Gold separator with anchor medallion (y = -BANNER_H) ───────
    -- Leveled above the header (options.aboveFrame), so the medallion (which
    -- overlaps the header's bottom edge) always draws on top of the tentacle
    -- banner instead of being covered by it.
    local dividerY = -(BANNER_H + 1)  -- vertical center of the old 2px line
    TOAST.CreateAnchorDivider(lb, 0, FRAME_W, dividerY, {
        medallionSize = 32,
        gap = 6,
        aboveFrame = header,
    })

    -- ── Search box (between gold sep and column labels) ───────────
    rows = {}
    local content  -- pre-declared so the OnTextChanged closure captures it as an upvalue
    local scrollToTop  -- pre-declared so the OnTextChanged closure captures it as an upvalue
    -- ApplyFilters itself is module-level (see top of file); assigned below
    -- once BuildFilterBar runs. OnTextChanged captures it as an upvalue.

    local searchContainer = CreateFrame("Frame", nil, lb)
    searchContainer:SetSize(200, 26)
    searchContainer:SetPoint("TOPRIGHT", lb, "TOPRIGHT", -10, -(BANNER_H + 12))

    local searchBg = searchContainer:CreateTexture(nil, "BACKGROUND")
    searchBg:SetAllPoints()
    searchBg:SetColorTexture(0.04, 0.05, 0.07, 0.8)

    local sBorderTop = searchContainer:CreateTexture(nil, "BORDER")
    sBorderTop:SetPoint("TOPLEFT", searchContainer, "TOPLEFT", 0, 0)
    sBorderTop:SetPoint("TOPRIGHT", searchContainer, "TOPRIGHT", 0, 0)
    sBorderTop:SetHeight(1)

    local sBorderBottom = searchContainer:CreateTexture(nil, "BORDER")
    sBorderBottom:SetPoint("BOTTOMLEFT", searchContainer, "BOTTOMLEFT", 0, 0)
    sBorderBottom:SetPoint("BOTTOMRIGHT", searchContainer, "BOTTOMRIGHT", 0, 0)
    sBorderBottom:SetHeight(1)

    local sBorderLeft = searchContainer:CreateTexture(nil, "BORDER")
    sBorderLeft:SetPoint("TOPLEFT", searchContainer, "TOPLEFT", 0, 0)
    sBorderLeft:SetPoint("BOTTOMLEFT", searchContainer, "BOTTOMLEFT", 0, 0)
    sBorderLeft:SetWidth(1)

    local sBorderRight = searchContainer:CreateTexture(nil, "BORDER")
    sBorderRight:SetPoint("TOPRIGHT", searchContainer, "TOPRIGHT", 0, 0)
    sBorderRight:SetPoint("BOTTOMRIGHT", searchContainer, "BOTTOMRIGHT", 0, 0)
    sBorderRight:SetWidth(1)

    local function SetSearchBorderAlpha(a)
        sBorderTop:SetColorTexture(1, 1, 1, a)
        sBorderBottom:SetColorTexture(1, 1, 1, a)
        sBorderLeft:SetColorTexture(1, 1, 1, a)
        sBorderRight:SetColorTexture(1, 1, 1, a)
    end
    SetSearchBorderAlpha(0.08)

    local searchBox = CreateFrame("EditBox", "TOASTLeaderboardSearch", searchContainer)
    searchBox:SetAllPoints()
    searchBox:SetFont(TOAST.FONT_PATH, 12, "")
    searchBox:SetTextColor(1, 1, 1, 1)
    searchBox:SetTextInsets(10, 24, 0, 0)
    searchBox:SetAutoFocus(false)
    searchBox:SetMaxLetters(30)

    local searchPlaceholder = searchContainer:CreateFontString(nil, "OVERLAY")
    searchPlaceholder:SetFont(TOAST.FONT_PATH, 12, "")
    searchPlaceholder:SetTextColor(1, 1, 1, 0.3)
    searchPlaceholder:SetPoint("LEFT", searchContainer, "LEFT", 10, 0)
    searchPlaceholder:SetText("Search name, dungeon, role...")

    local clearBtn = CreateFrame("Button", nil, searchContainer)
    clearBtn:SetSize(20, 20)
    clearBtn:SetPoint("RIGHT", searchContainer, "RIGHT", -2, 0)
    clearBtn:Hide()

    local clearLabel = clearBtn:CreateFontString(nil, "OVERLAY")
    clearLabel:SetFont(TOAST.FONT_PATH, 14, "")
    clearLabel:SetPoint("CENTER")
    clearLabel:SetText("\195\151")
    clearLabel:SetTextColor(1, 1, 1, 0.35)

    clearBtn:SetScript("OnEnter", function() clearLabel:SetTextColor(1, 1, 1, 1) end)
    clearBtn:SetScript("OnLeave", function() clearLabel:SetTextColor(1, 1, 1, 0.35) end)
    clearBtn:SetScript("OnClick", function()
        searchBox:SetText("")
        searchBox:ClearFocus()
    end)

    -- Reset-all-filters affordance, left of the search box; only shown while
    -- a column filter (role/prefix/key) is active. Wired up by BuildFilterBar
    -- once it exists, alongside the column headers.
    local resetBtn = CreateFrame("Button", nil, lb)
    resetBtn:SetSize(50, 20)
    resetBtn:SetPoint("RIGHT", searchContainer, "LEFT", -8, 0)
    resetBtn:Hide()

    local resetLabel = resetBtn:CreateFontString(nil, "OVERLAY", "TOASTFontNormalSmall")
    resetLabel:SetAllPoints()
    resetLabel:SetJustifyH("RIGHT")
    resetLabel:SetText("Reset")
    resetLabel:SetTextColor(1, 1, 1, 0.5)

    resetBtn:SetScript("OnEnter", function() resetLabel:SetTextColor(1, 1, 1, 1) end)
    resetBtn:SetScript("OnLeave", function() resetLabel:SetTextColor(1, 1, 1, 0.5) end)

    searchBox:SetScript("OnEditFocusGained", function() SetSearchBorderAlpha(0.15) end)
    searchBox:SetScript("OnEditFocusLost", function() SetSearchBorderAlpha(0.08) end)
    searchBox:SetScript("OnTextChanged", function(self)
        if self:GetText() == "" then
            searchPlaceholder:Show()
            clearBtn:Hide()
        else
            searchPlaceholder:Hide()
            clearBtn:Show()
        end
        if ApplyFilters then ApplyFilters() end
    end)

    -- ── Column labels (below search box) ───────────────────────────
    local colBar = CreateFrame("Frame", nil, lb)
    colBar:SetSize(FRAME_W - 20, LABELS_H)
    colBar:SetPoint("TOPLEFT", lb, "TOPLEFT", 10, -(BANNER_H + SEARCH_AREA_HEIGHT))
    -- The Resi header icon renders taller than this bar and overflows into
    -- the table area below; table rows nest 3 levels deep (sf > content >
    -- row) so they'd otherwise draw over that overflow. Keep colBar above it.
    colBar:SetFrameLevel(lb:GetFrameLevel() + 10)

    -- ── Grey separator below labels ────────────────────────────────
    local greySep = lb:CreateTexture(nil, "ARTWORK")
    greySep:SetColorTexture(0.5, 0.5, 0.5, 0.4)
    greySep:SetSize(FRAME_W, 1)
    greySep:SetPoint("TOPLEFT", lb, "TOPLEFT", 0, -(BANNER_H + SEARCH_AREA_HEIGHT + LABELS_H + 1))

    -- ── Scroll frame ───────────────────────────────────────────────
    -- Height comes directly from TABLE_VIEWPORT_HEIGHT (ROW_H * VISIBLE_ROWS),
    -- not back-derived from FRAME_H, so the two can never drift out of sync.
    local sf = CreateFrame("ScrollFrame", "TOASTLeaderboardSF", lb)
    sf:SetSize(FRAME_W - 54, TABLE_VIEWPORT_HEIGHT)
    sf:SetPoint("TOPLEFT", lb, "TOPLEFT", 10, -SF_TOP)

    content = CreateFrame("Frame", nil, sf)
    content:SetSize(FRAME_W - 54, #TOAST.leaderboard * ROW_H)
    sf:SetScrollChild(content)

    _, _, scrollToTop = TOAST.CreateCustomScrollbar(sf, content)

    ApplyFilters = BuildFilterBar(colBar, content, scrollToTop, searchBox, resetBtn)

    -- Own-row highlight is keyed off the recognized Discord ID, not the
    -- currently-logged-in character name, so the leaderboard-listed
    -- character still highlights gold while playing an alt.
    local myDiscordId = TOAST_DB.me and TOAST_DB.me.discord_id

    for i, entry in ipairs(TOAST.leaderboard) do
        local row = CreateFrame("Frame", nil, content)
        row:SetSize(FRAME_W - 54, ROW_H)
        row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i - 1) * ROW_H)

        local bg = row:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()

        local _, entryDiscordId = TOAST.CrewState.ResolveEntry(entry)
        local isSelf = myDiscordId ~= nil and entryDiscordId == myDiscordId

        -- r is this row's permanent state: entry never changes even as
        -- RefreshResolvedLeaderboardRows reorders `rows` into resolved rank
        -- order; isEven/rank/rio do change, and RestBg() (below) always
        -- reads the CURRENT r.isEven/r.isSelf instead of a value captured
        -- once at build time, so hover-leave and reordering can never leave
        -- a stale alternating background.
        local r = {
            frame = row,
            entry = entry,
            isSelf = isSelf,
            isEven = (i % 2 == 0),
            name = entry.name:lower(),
            discord = entry.discord_nickname:lower(),
            role = entry.role:lower(),
            realm = entry.realm:lower(),
            rio = "",
            originalIndex = i,
        }

        local function RestBg()
            if r.isSelf then
                bg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.12)
            elseif r.isEven then
                bg:SetColorTexture(1, 1, 1, 0.025)
            else
                bg:SetColorTexture(0, 0, 0, 0)
            end
        end
        r.restBg = RestBg
        RestBg()

        if isSelf then
            local border = row:CreateTexture(nil, "OVERLAY")
            border:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.6)
            border:SetSize(FRAME_W - 54, 1)
            border:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
            local border2 = row:CreateTexture(nil, "OVERLAY")
            border2:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.6)
            border2:SetSize(FRAME_W - 54, 1)
            border2:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
        end

        row:EnableMouse(true)
        row:SetScript("OnEnter", function()
            bg:SetColorTexture(0.11, 0.13, 0.15, 0.50)
        end)
        row:SetScript("OnLeave", RestBg)

        row:SetScript("OnMouseUp", function(self, button)
            if button ~= "RightButton" then return end
            local target = TOAST.FormatCharacterTarget(entry.name, entry.realm)
            local _, discordId = TOAST.CrewState.ResolveEntry(entry)

            -- Actions use the character that's actually online, if that's an
            -- alt rather than the leaderboard entry's own character. The online
            -- record carries its own realm, so the target can't be rebuilt from
            -- the wrong same-name character on another realm.
            local onlineTarget = target
            local onlineChar = TOAST.GetOnlineCrewCharacter(discordId)
            if onlineChar and onlineChar.name ~= entry.name and onlineChar.realm then
                onlineTarget = TOAST.FormatCharacterTarget(onlineChar.name, onlineChar.realm)
            end

            MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
                local crewCount = discordId and TOAST.CrewState.GetCrewMemberCount(discordId) or 0
                if crewCount > 1 then
                    rootDescription:CreateButton("View Crew", function()
                        TOAST.ShowCrewPopup(discordId)
                    end)
                end
                rootDescription:CreateButton("Copy Name", function()
                    TOAST.ShowCopyPopup(onlineTarget)
                end)
                rootDescription:CreateButton("Invite", function()
                    C_PartyInfo.InviteUnit(onlineTarget)
                end)
                rootDescription:CreateButton("Whisper", function()
                    ChatFrame_OpenChat("/w " .. onlineTarget .. " ", DEFAULT_CHAT_FRAME)
                end)
            end)
        end)

        -- Class-colored name, realm in the same muted grey as the prefix label.
        local token = CLASS_TOKEN[entry.class_]
        local c = token and RAID_CLASS_COLORS[token]
        local charRealm
        if c then
            charRealm = string.format("|cff%02x%02x%02x%s|r|cffCCCCCC-%s|r",
                math.floor(c.r * 255), math.floor(c.g * 255), math.floor(c.b * 255),
                entry.name, entry.realm)
        else
            charRealm = entry.name .. "|cffCCCCCC-" .. entry.realm .. "|r"
        end

        local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
        local keyVal  = keyData and ("+" .. keyData.level .. " " .. keyData.short) or "\226\128\148"

        local vals = {
            entry.rank,
            "",  -- ci==2 handled below via PREFIX_MAP
            charRealm,
            entry.role,
            entry.rio,
            entry.resi,
            keyVal,
        }

        local rankFS
        local rioFS
        local keyFS
        local dotTex
        for ci, col in ipairs(COLS) do
            if ci == 2 then
                local p, name = ParseDiscordPrefix(entry.discord_nickname)

                local dot = row:CreateTexture(nil, "OVERLAY")
                dot:SetSize(6, 6)
                dot:SetPoint("LEFT", row, "LEFT", col.xOff + col.statusOff, 0)
                local status, activeCharacter, seenAt = GetAddonStatus(entry)
                if activeCharacter or IsRecentPresence(seenAt) then
                    dot:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
                elseif status then
                    dot:SetColorTexture(0.5, 0.5, 0.5, 0.6)
                else
                    dot:Hide()
                end
                dotTex = dot

                local statusHover = CreateFrame("Frame", nil, row)
                statusHover:SetSize(16, ROW_H)
                statusHover:SetPoint("LEFT", row, "LEFT", col.xOff - 1, 0)
                statusHover:EnableMouse(true)
                statusHover.entry = entry
                statusHover.row = row
                statusHover:SetScript("OnEnter", function(self)
                    TOAST.ShowLeaderboardPresenceTooltip(self, self.entry)
                end)
                statusHover:SetScript("OnLeave", function()
                    TOAST.HideLeaderboardPresenceTooltip()
                end)
                statusHover:SetScript("OnMouseUp", function(self, button)
                    if button ~= "RightButton" then return end
                    local handler = self.row:GetScript("OnMouseUp")
                    if handler then handler(self.row, button) end
                end)
                if not status then statusHover:Hide() end
                r.statusHover = statusHover

                if p then
                    local icon = row:CreateTexture(nil, "OVERLAY")
                    icon:SetTexture(p.tex)
                    icon:SetSize(20, 20)
                    icon:SetPoint("LEFT", row, "LEFT", col.xOff + col.iconOff, 0)

                    local labelFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
                    labelFS:SetPoint("LEFT", icon, "RIGHT", 0, 0)
                    labelFS:SetText(p.label)
                    labelFS:SetTextColor(0.8, 0.8, 0.8)       -- #CCCCCC

                    local nameFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
                    nameFS:SetJustifyH("LEFT")
                    nameFS:SetWordWrap(false)
                    nameFS:SetPoint("LEFT", labelFS, "RIGHT", 4, 0)
                    nameFS:SetPoint("RIGHT", row, "LEFT", col.xOff + col.width, 0)
                    nameFS:SetText(name)
                else
                    local fs = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
                    fs:SetWidth(col.width - col.iconOff)
                    fs:SetJustifyH(col.align)
                    fs:SetPoint("LEFT", row, "LEFT", col.xOff + col.iconOff, 0)
                    fs:SetText(name)
                end
            elseif ci == 4 then
                local tex = row:CreateTexture(nil, "OVERLAY")
                tex:SetTexture(ROLE_TEX[entry.role] or "")
                tex:SetSize(20, 20)
                tex:SetPoint("CENTER", row, "LEFT", col.xOff + ROLE_ROW_ICON_CENTER_XOFF, 0)
            elseif ci == 5 then
                -- RIO: centered at RIO_CENTER_X (see module-level block above),
                -- matching the header instead of col.xOff/width.
                local fs = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
                fs:SetPoint("CENTER", row, "LEFT", RIO_CENTER_X, 0)
                fs:SetText(tostring(vals[ci]))
                rioFS = fs
            else
                local fs = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
                fs:SetWidth(col.width)
                fs:SetJustifyH(col.align)
                fs:SetPoint("LEFT", row, "LEFT", col.xOff, 0)
                fs:SetText(tostring(vals[ci]))
                if ci == 7 then keyFS = fs end
                if ci == 1 then rankFS = fs end
            end
        end

        -- rankFS/rioFS text and r.rank/r.rio start from the generated values
        -- above purely as an initial placeholder - RefreshResolvedLeaderboardRows
        -- (called right after BuildLeaderboard from ShowLeaderboard) immediately
        -- overwrites both with the resolved rank/score before the frame ever shows.
        r.rankFS = rankFS
        r.rioFS = rioFS
        r.keyFS = keyFS
        r.dotTex = dotTex
        r.rank = entry.rank

        rows[i] = r
    end
    content:SetHeight(#TOAST.leaderboard * ROW_H)
end

-- Re-derives the locally best-known leaderboard ordering (see
-- TOAST.GetResolvedLeaderboardSnapshot in scores.lua) and rebinds every
-- EXISTING physical row to it by entry reference equality - the row
-- frames/widgets themselves are never destroyed or rebuilt, only their
-- displayed rank/RIO and their position in `rows` (and therefore on
-- screen, via the ApplyFilters() re-run below) change. Called every time
-- the Leaderboard opens (see ShowLeaderboard), including reopening an
-- already-built frame, so a score resolved after the frame was first built
-- still shows up.
local function RefreshResolvedLeaderboardRows()
    if not lb then return end
    if not TOAST.GetResolvedLeaderboardSnapshot then return end
    local snapshot = TOAST.GetResolvedLeaderboardSnapshot()

    local rowByEntry = {}
    for _, r in ipairs(rows) do
        rowByEntry[r.entry] = r
    end

    local newRows = {}
    for _, wrapper in ipairs(snapshot) do
        local r = rowByEntry[wrapper.entry]
        if r then
            r.rank = wrapper.rank
            r.rankFS:SetText(tostring(wrapper.rank))

            if wrapper.resolved then
                r.rioFS:SetText(tostring(wrapper.resolved.score))
                r.rio = tostring(wrapper.resolved.score)
            else
                r.rioFS:SetText("\226\128\148")
                -- Unresolved: don't leave the old generated RIO behind as
                -- searchable text for a value no longer actually displayed.
                r.rio = ""
            end

            tinsert(newRows, r)
        end
    end

    -- Baseline zebra striping for the unfiltered resolved order, repainted
    -- immediately (not just flagged, in case nothing hovers a row before it
    -- shows) so no row is left showing a color from its old generated-order
    -- position. This is only the starting point, not the final word: the
    -- ApplyFilters() call below re-derives striping from each row's actual
    -- VISIBLE position once hidden rows are excluded (see its own comment).
    for i, r in ipairs(newRows) do
        r.isEven = (i % 2 == 0)
        r.restBg()
    end

    rows = newRows

    if ApplyFilters then ApplyFilters() end
end

TOAST.RefreshKeyColumn = function()
    if not lb then return end
    for _, r in ipairs(rows) do
        local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(r.entry.name, r.entry.realm)
        r.keyFS:SetText(keyData and ("+" .. keyData.level .. " " .. keyData.short) or "\226\128\148")
    end
    TOAST.RefreshStatusDots()
end

TOAST.RefreshStatusDots = function(refreshSources)
    if refreshSources ~= false and TOAST.RefreshOnlineStatus then
        TOAST.RefreshOnlineStatus()
    end
    for _, r in ipairs(rows) do
        if r.dotTex then
            local status, activeCharacter, seenAt = GetAddonStatus(r.entry)
            if activeCharacter or IsRecentPresence(seenAt) then
                r.dotTex:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
                r.dotTex:Show()
                if r.statusHover then r.statusHover:Show() end
            elseif status then
                r.dotTex:SetColorTexture(0.5, 0.5, 0.5, 0.6)
                r.dotTex:Show()
                if r.statusHover then r.statusHover:Show() end
            else
                r.dotTex:Hide()
                if r.statusHover then r.statusHover:Hide() end
            end
        end
    end
end

-- Unconditional show (not a toggle) so the DASHBOARD button's right-click
-- can open this window without caring whether it's already open. Also the
-- one path that ever builds the frame, so first construction can never
-- happen before RefreshOwnScore() below - see TOAST.commands["lb"].
local function ShowLeaderboard()
    if TOAST.CanOpenMajorWindow and not TOAST.CanOpenMajorWindow() then return end
    if TOAST.RefreshOwnScore then TOAST.RefreshOwnScore() end
    if not lb then BuildLeaderboard() end
    RefreshResolvedLeaderboardRows()
    lb:Show()
    if TOAST.RefreshUnknowns then TOAST.RefreshUnknowns() end
    TOAST.RefreshStatusDots()

    -- Club data can become readable a fraction after a major window opens.
    -- Redraw once more so the row dot shares View Crew's freshly-read state.
    C_Timer.After(0.5, function()
        if lb and lb:IsShown() then
            TOAST.RefreshStatusDots()
        end
    end)
end
TOAST.ShowLeaderboard = ShowLeaderboard

TOAST.commands["lb"] = function()
    if lb and lb:IsShown() then
        lb:Hide()
    else
        ShowLeaderboard()
    end
end
