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 }

-- name -> TOAST.members entry, built once in BuildLeaderboard(); module-level
-- so GetAddonStatus() and RefreshStatusDots() (both outside BuildLeaderboard's
-- closure) can look up a leaderboard entry's discord_id too.
local memberLookup = {}

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 FRESH_THRESHOLD = 3600

local function GetAddonStatus(entry)
    -- Primair: C_Club native online check
    if TOAST._onlineMembers and TOAST._onlineMembers[entry.name] then
        return "online"
    end
    -- Alt-aware: deze speler zelf niet online, maar wel op een alt
    local memberEntry = memberLookup[entry.name]
    if memberEntry and memberEntry.discord_id
       and TOAST._onlineCharByDiscordId
       and TOAST._onlineCharByDiscordId[memberEntry.discord_id] then
        return "online"
    end
    -- Fallback: lastSeen via comms
    local data = TOAST.keys and TOAST.keys[entry.name]
    if not data or not data.lastSeen then return nil end
    local age = GetServerTime() - data.lastSeen
    if age < FRESH_THRESHOLD then
        return "online"
    else
        return "known"
    end
end

local lb
local rows = {}

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 = TOAST.leaderboard[r.index]

            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.keys and TOAST.keys[entry.name]
                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.keys and TOAST.keys[entry.name]
                keyMinMatch = (keyData and keyData.level >= activeKeyMin) or false
            end

            if searchMatch and roleMatch and prefixMatch and keyMinMatch then
                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", "GameFontNormal")
            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", "GameFontNormal")
            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", "GameFontNormal")
            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", "GameFontNormal")
            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()
    memberLookup = {}
    for _, m in ipairs(TOAST.members) do
        memberLookup[m.name] = m
    end

    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", "GameFontNormalHuge")
    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", "GameFontNormalSmall")
    subFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    subFS:SetTextColor(1, 1, 1, 0.45)
    subFS:SetText("Season 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
    local ApplyFilters  -- assigned 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("Fonts\\FRIZQT__.TTF", 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("Fonts\\FRIZQT__.TTF", 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("Fonts\\FRIZQT__.TTF", 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", "GameFontNormalSmall")
    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 isEven = (i % 2 == 0)
        local bg = row:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        if isEven then
            bg:SetColorTexture(0.1, 0.1, 0.3, 0.15)
        else
            bg:SetColorTexture(0, 0, 0, 0)
        end

        local entryMember = memberLookup[entry.name]
        local isSelf = myDiscordId ~= nil and entryMember ~= nil and entryMember.discord_id == myDiscordId
        if isSelf then
            bg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.12)
            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", function()
            if isSelf then
                bg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.12)
            elseif isEven then
                bg:SetColorTexture(0.1, 0.1, 0.3, 0.15)
            else
                bg:SetColorTexture(0, 0, 0, 0)
            end
        end)

        row:SetScript("OnMouseUp", function(self, button)
            if button ~= "RightButton" then return end
            local target = entry.name .. "-" .. entry.realm:gsub(" ", "")
            local memberEntry = memberLookup[entry.name]

            -- Invite/Whisper the character that's actually online, if that's
            -- an alt rather than the leaderboard entry's own character.
            -- Copy Name always stays on the leaderboard entry itself.
            local onlineTarget = target
            if memberEntry and memberEntry.discord_id and TOAST._onlineCharByDiscordId then
                local onlineCharName = TOAST._onlineCharByDiscordId[memberEntry.discord_id]
                if onlineCharName and onlineCharName ~= entry.name then
                    local onlineMember = memberLookup[onlineCharName]
                    if onlineMember and onlineMember.realm then
                        onlineTarget = onlineCharName .. "-" .. onlineMember.realm:gsub(" ", "")
                    end
                end
            end

            MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
                if memberEntry and memberEntry.discord_id then
                    local crewCount = 0
                    for _, m in ipairs(TOAST.members) do
                        if m.discord_id == memberEntry.discord_id then
                            crewCount = crewCount + 1
                        end
                    end
                    if crewCount > 1 then
                        rootDescription:CreateButton("View Crew", function()
                            TOAST.ShowCrewPopup(memberEntry.discord_id)
                        end)
                    end
                end
                rootDescription:CreateButton("Copy Name", function()
                    TOAST.ShowCopyPopup(target)
                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.keys and TOAST.keys[entry.name]
        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 keyFS
        local dotTex
        for ci, col in ipairs(COLS) do
            if ci == 2 then
                local p, name = ParseDiscordPrefix(entry.discord_nickname)
                local memberEntry = memberLookup[entry.name]

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

                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", "GameFontHighlight")
                    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", "GameFontHighlight")
                    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", "GameFontHighlight")
                    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", "GameFontHighlight")
                fs:SetPoint("CENTER", row, "LEFT", RIO_CENTER_X, 0)
                fs:SetText(tostring(vals[ci]))
            else
                local fs = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                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
            end
        end
        rows[i] = { frame = row, name = entry.name:lower(), discord = entry.discord_nickname:lower(),
                    role = entry.role:lower(), realm = entry.realm:lower(), rio = tostring(entry.rio),
                    index = i, keyFS = keyFS, dotTex = dotTex }
    end
    content:SetHeight(#TOAST.leaderboard * ROW_H)
end

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

TOAST.RefreshStatusDots = function()
    if TOAST.RefreshOnlineStatus then TOAST.RefreshOnlineStatus() end
    for i, r in ipairs(rows) do
        local entry = TOAST.leaderboard[r.index]
        if entry and r.dotTex then
            local status = GetAddonStatus(entry)
            if status == "online" then
                r.dotTex:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
                r.dotTex:Show()
            elseif status == "known" then
                r.dotTex:SetColorTexture(0.5, 0.5, 0.5, 0.6)
                r.dotTex:Show()
            else
                r.dotTex:Hide()
            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.
local function ShowLeaderboard()
    if not lb then BuildLeaderboard() end
    lb:Show()
    if TOAST.RefreshUnknowns then TOAST.RefreshUnknowns() end
    TOAST.RefreshStatusDots()
end
TOAST.ShowLeaderboard = ShowLeaderboard

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