local addonName, WB = ...

WB.UI = WB.UI or {}

local FRAME_WIDTH  = 930
local FRAME_HEIGHT = 690
local GAP          = 12   -- spacing between columns and between stacked panels
local RANK_ICON_SIZE = 54  -- Service Record rank badge size

-- ── Palette ───────────────────────────────────────────────────────────────────
local GOLD         = { 0.95, 0.78, 0.35 }
local PANEL_BG     = { 0.05, 0.04, 0.03, 0.88 }
local PANEL_BORDER = { 0.50, 0.40, 0.22, 0.90 }
local LABEL_COLOR  = { 0.68, 0.66, 0.60 }
local VALUE_COLOR  = { 0.92, 0.86, 0.66 }
local MUTED_COLOR  = { 0.55, 0.50, 0.42 }
local HORDE_RED     = { 0.55, 0.10, 0.10 }
local ALLIANCE_BLUE = { 0.12, 0.28, 0.58 }
local ACCENT        = HORDE_RED   -- faction-selected; reassigned in CreateWarBoardFrame

-- Brighter faction colours for player names (leaderboard etc.).
local FACTION_NAME_COLOR = {
    Horde    = { 0.93, 0.27, 0.24 },
    Alliance = { 0.39, 0.58, 0.93 },
}

-- Faction-specific frame artwork (atlas names). Identical NineSlice logic for
-- both factions — only the textures (and accent colour) differ. Chosen from the
-- player's faction on login.
local FACTION_ART = {
    Horde = {
        corner     = "Horde-NineSlice-Corner",
        edgeTop    = "_Horde-NineSlice-EdgeTop",
        edgeBottom = "_Horde-NineSlice-EdgeBottom",
        edgeLeft   = "!Horde-NineSlice-EdgeLeft",
        edgeRight  = "!Horde-NineSlice-EdgeRight",
        header     = "UI-Frame-Horde-Header",
        background = "UI-Frame-Horde-BackgroundTile",
        accent     = HORDE_RED,
        headerY    = 55,   -- crest protrusion above the frame top
    },
    Alliance = {
        corner     = "Alliance-NineSlice-Corner",
        edgeTop    = "_Alliance-NineSlice-EdgeTop",
        edgeBottom = "_Alliance-NineSlice-EdgeBottom",
        edgeLeft   = "!Alliance-NineSlice-EdgeLeft",
        edgeRight  = "!Alliance-NineSlice-EdgeRight",
        header     = "UI-Frame-Alliance-Header",
        background = "UI-Frame-Alliance-BackgroundTile",
        accent     = ALLIANCE_BLUE,
        headerY    = 79,   -- taller crest → raised more so its bottom meets the top border
    },
}

-- Player's faction art + name. Neutral (unchosen pandaren) defaults to Horde.
local function GetFactionArt()
    if UnitFactionGroup("player") == "Alliance" then
        return FACTION_ART.Alliance, "Alliance"
    end
    return FACTION_ART.Horde, "Horde"
end

-- SetAtlas caches atlas state internally and re-applies its own UVs, overriding any
-- SetTexCoord call we make afterwards. Using SetTexture + SetTexCoord directly avoids this.
local function SetAtlasTexture(tex, atlasName, flipH, flipV)
    local info = C_Texture.GetAtlasInfo(atlasName)
    if not info then return end
    tex:SetTexture(info.file)
    local l, r, t, b = info.leftTexCoord, info.rightTexCoord, info.topTexCoord, info.bottomTexCoord
    if flipH then l, r = r, l end
    if flipV then t, b = b, t end
    tex:SetTexCoord(l, r, t, b)
    tex:SetSize(info.width, info.height)
end

local PLUNDER_COIN_ATLAS = "plunderstorm-icon-plunderCoins"
local function SetWarBoardCoinIcon(tex, size)
    local info = C_Texture.GetAtlasInfo(PLUNDER_COIN_ATLAS)
    if not info then return end
    tex:SetTexture(info.file)
    local l, r, t, b = info.leftTexCoord, info.rightTexCoord, info.topTexCoord, info.bottomTexCoord
    local crop = 0
    local dx, dy = (r - l) * crop, (b - t) * crop
    tex:SetTexCoord(l + dx, r - dx, t + dy, b - dy)
    tex:SetSize(size, size)
end

local function ApplyFactionNineSlice(frame, art)
    -- Corners: single atlas, rotated via UV flipping
    local tl = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(tl, art.corner)
    tl:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)

    local tr = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(tr, art.corner, true, false)
    tr:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)

    local bl = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(bl, art.corner, false, true)
    bl:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)

    local br = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(br, art.corner, true, true)
    br:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)

    local cw = tl:GetWidth()
    local ch = tl:GetHeight()
    if cw == 0 then cw = 32 end
    if ch == 0 then ch = 32 end

    -- Top edge (tiles horizontally)
    local edgeTop = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeTop, art.edgeTop)
    edgeTop:SetHorizTile(true)
    edgeTop:SetPoint("TOPLEFT",  frame, "TOPLEFT",  cw,  0)
    edgeTop:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -cw, 0)

    -- Bottom edge (tiles horizontally)
    local edgeBottom = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeBottom, art.edgeBottom)
    edgeBottom:SetHorizTile(true)
    edgeBottom:SetPoint("BOTTOMLEFT",  frame, "BOTTOMLEFT",  cw,  0)
    edgeBottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -cw, 0)

    -- Left edge (tiles vertically)
    local edgeLeft = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeLeft, art.edgeLeft)
    edgeLeft:SetVertTile(true)
    edgeLeft:SetPoint("TOPLEFT",    frame, "TOPLEFT",    0, -ch)
    edgeLeft:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0,  ch)

    -- Right edge (tiles vertically)
    local edgeRight = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeRight, art.edgeRight)
    edgeRight:SetVertTile(true)
    edgeRight:SetPoint("TOPRIGHT",    frame, "TOPRIGHT",    0, -ch)
    edgeRight:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0,  ch)
end

-- ── Reusable building blocks ──────────────────────────────────────────────────

-- A bordered dark sub-panel ("block").
local function CreatePanel(parent)
    local p = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    p:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 14,
        insets   = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    p:SetBackdropColor(PANEL_BG[1], PANEL_BG[2], PANEL_BG[3], PANEL_BG[4])
    p:SetBackdropBorderColor(PANEL_BORDER[1], PANEL_BORDER[2], PANEL_BORDER[3], PANEL_BORDER[4])
    return p
end

-- Gold section header text, centred in its container. The caller anchors its
-- TOPLEFT; we add the matching TOPRIGHT so the string spans the panel width and
-- the text centres (while the header's BOTTOMLEFT still sits at the panel's left
-- edge, keeping any left-aligned content anchored beneath it in place).
local function SectionHeader(parent, text)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
    fs:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -12, -12)
    fs:SetJustifyH("CENTER")
    fs:SetText(text)
    fs:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    return fs
end

local function HLine(parent)
    local t = parent:CreateTexture(nil, "ARTWORK")
    t:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.22)
    t:SetHeight(1)
    return t
end

-- "Name-Realm" (realm omitted when blank). Shown in hover tooltips across the feeds.
local function FullPlayerName(name, realm)
    name = name or "?"
    if realm and realm ~= "" then return name .. "-" .. realm end
    return name
end

-- A transparent mouse region that shows a player's full Name-Realm on hover. Anchor
-- it over a name fontstring (hit:SetAllPoints(nameFS)) and set hit._fullName each
-- render; the tooltip reads it live.
local function MakeNameHover(parent)
    local hit = CreateFrame("Frame", nil, parent)
    hit:EnableMouse(true)
    hit:SetScript("OnEnter", function(self)
        if not self._fullName then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(self._fullName, 1, 0.95, 0.8)
        GameTooltip:Show()
    end)
    hit:SetScript("OnLeave", function() GameTooltip:Hide() end)
    return hit
end

-- Texture → zone (shown on hover). Delegates to the shared resolver in
-- WarEffort_Objectives.lua so the tooltip and the kill tracker stay in sync.
local function ZoneFromTexture(texture)
    return WB.WarEffort and WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(texture)
end

-- Refresh one objective card's progress text from the saved tally (called by the
-- kill tracker when an objective advances).
function WB.UI.RefreshObjectiveProgress(id)
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.id == id and card.progress then
            local cur = (WB.WarEffort and WB.WarEffort.GetProgress and WB.WarEffort.GetProgress(id)) or 0
            card.progress:SetText(string.format("%d / %d", cur, card.goal or 0))
            if card.SetComplete then
                local done = WB.WarEffort and WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(id)
                card:SetComplete(done and true or false)
            end
            if card.RefreshReward then
                card:RefreshReward()
            end
            return
        end
    end
end

-- Update the Service Record's banked gold / war credit from saved totals.
function WB.UI.RefreshCurrency()
    local rec = WB.UI.serviceRecord
    if not rec then return end
    local gold = (WB.WarEffort and WB.WarEffort.GetGold      and WB.WarEffort.GetGold())      or 0
    local wc   = (WB.WarEffort and WB.WarEffort.GetWarCredit and WB.WarEffort.GetWarCredit()) or 0
    if rec.gold      then rec.gold:SetText(tostring(gold)) end
    if rec.warCredit then rec.warCredit:SetText(tostring(wc)) end
    if WB.UI.RefreshRank then WB.UI.RefreshRank() end   -- rank + progression scale with war credit
end

function WB.UI.RefreshAllObjectives()
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.id then WB.UI.RefreshObjectiveProgress(card.id) end
    end
end

function WB.UI.RefreshObjectiveBackgrounds()
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.RefreshBackground then card:RefreshBackground() end
    end
end

-- Re-anchor the War Effort cards so the current "priority zone" objectives float to the
-- top (in their authored relative order), the rest following (also authored order). The
-- card list itself stays in authored order — we just recompute anchors from the sticky
-- WB.WarEffort.GetPriorityZone() each call, so it's idempotent and safe to call live.
function WB.UI.RelayoutObjectives()
    local list, cards = WB.UI.objectiveList, WB.UI.objectiveCards
    if not list or not cards or #cards == 0 then return end
    local cardHeight, cardGap = 92, 8
    local priority = WB.WarEffort and WB.WarEffort.GetPriorityZone and WB.WarEffort.GetPriorityZone()
    local ordered, rest = {}, {}
    for _, card in ipairs(cards) do
        if priority and card.zone == priority then ordered[#ordered + 1] = card else rest[#rest + 1] = card end
    end
    for _, c in ipairs(rest) do ordered[#ordered + 1] = c end
    for i, card in ipairs(ordered) do
        card:ClearAllPoints()
        card:SetPoint("TOPLEFT",  list, "TOPLEFT",  4, -((i - 1) * (cardHeight + cardGap)))
        card:SetPoint("TOPRIGHT", list, "TOPRIGHT", -4, -((i - 1) * (cardHeight + cardGap)))
    end
end

-- Faction rank names. Backend still refers to ranks by number (1-3); only the
-- displayed "Rank:" text differs per faction.
local RANK_NAMES = {
    Horde    = { [0] = "No Rank", [1] = "Scout",   [2] = "Grunt",    [3] = "Sergeant" },
    Alliance = { [0] = "No Rank", [1] = "Private", [2] = "Corporal", [3] = "Sergeant" },
}

-- Rank is earned by TOTAL war credit: 1000 = rank 1, 2500 = rank 2, 5000 = rank 3.
local RANK_THRESHOLDS = { [1] = 1000, [2] = 2500, [3] = 5000 }
local function RankFromWC(wc)
    wc = tonumber(wc) or 0
    if wc >= RANK_THRESHOLDS[3] then return 3 end
    if wc >= RANK_THRESHOLDS[2] then return 2 end
    if wc >= RANK_THRESHOLDS[1] then return 1 end
    return 0
end

-- Set the displayed rank: 1-3 swaps the icon to the matching prestige badge and
-- shows the faction rank name; 0/nil = no rank (the faction badge, "Rank: No Rank").
function WB.UI.SetRank(rank)
    local rec = WB.UI.serviceRecord
    if not rec or not rec.icon then return end
    rank = tonumber(rank)
    local names = RANK_NAMES[WB.faction] or RANK_NAMES.Horde
    if rank and rank >= 1 and rank <= 3 then
        rec.icon:SetAtlas("honorsystem-icon-prestige-" .. rank)
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
        if rec.rank then rec.rank:SetText(names[rank] or tostring(rank)) end
    else
        rec.icon:SetAtlas(rec.badge)
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
        if rec.rank then rec.rank:SetText(names[0] or "No Rank") end
    end
end

-- An objective "pill" card: zone-art background (darkened for legibility), gold
-- title, description, progress, and rewards. Caller sets LEFT/RIGHT/TOP anchors.
-- data = { obj, title, desc, texture, current, goal, gold, warCredit }
local function CreateObjectiveCard(parent, data)
    local card = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    card:SetHeight(92)
    card:SetClipsChildren(true)
    card:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    card:SetBackdropColor(0, 0, 0, 1)
    if data.elite then
        -- Elite objectives recolor the border to the faction colour (Horde red,
        -- Alliance blue) instead of the usual muted gold.
        local ec = (WB.faction == "Alliance") and ALLIANCE_BLUE or HORDE_RED
        card:SetBackdropBorderColor(ec[1], ec[2], ec[3], 1)
    else
        card:SetBackdropBorderColor(PANEL_BORDER[1], PANEL_BORDER[2], PANEL_BORDER[3], 0.95)
    end

    -- Zone-art background, inset inside the border. Keep it above the frame's
    -- black backdrop; BACKGROUND-layer textures can lose draw order to the
    -- BackdropTemplate bg after reloads/reopens.
    local bg = card:CreateTexture(nil, "BACKGROUND")
    bg:SetDrawLayer("ARTWORK", -8)
    bg:SetPoint("TOPLEFT",     card, "TOPLEFT",      4, -4)
    bg:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -4,  4)
    card.bg = bg
    card.texturePath = data.texture
    card.texCoord    = data.texCoord   -- optional {l,r,t,b} crop to trim baked-in art borders

    function card:RefreshBackground()
        if not self.bg or not self.texturePath then return end
        self.bg:SetTexture(self.texturePath)
        if self.texCoord then
            self.bg:SetTexCoord(self.texCoord[1], self.texCoord[2], self.texCoord[3], self.texCoord[4])
        else
            self.bg:SetTexCoord(0, 1, 0, 1)
        end
        self.bg:SetDrawLayer("ARTWORK", -8)
        self.bg:Show()
    end
    card:RefreshBackground()
    card:SetScript("OnShow", function(self)
        self:RefreshBackground()
        if C_Timer then
            C_Timer.After(0, function()
                if self:IsShown() then self:RefreshBackground() end
            end)
        end
    end)

    local shade = card:CreateTexture(nil, "ARTWORK")
    shade:SetDrawLayer("ARTWORK", -7)
    shade:SetAllPoints(bg)
    shade:SetColorTexture(0, 0, 0, 0.5)

    local title = card:CreateFontString(nil, "OVERLAY")
    local titleSize = (data.title and #data.title > 22) and 12 or 13
    title:SetFont("Fonts\\FRIZQT__.TTF", titleSize, "OUTLINE")
    title:SetPoint("TOPLEFT", card, "TOPLEFT", 10, -9)
    title:SetPoint("RIGHT",   card, "RIGHT",   -10, 0)
    title:SetJustifyH("LEFT")
    title:SetWordWrap(true)
    if title.SetMaxLines then title:SetMaxLines(2) end
    title:SetText(data.title)
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -3)
    desc:SetPoint("RIGHT",   card,  "RIGHT",     -10, 0)
    desc:SetJustifyH("LEFT")
    desc:SetText(data.desc)
    desc:SetTextColor(0.90, 0.88, 0.82)

    local progress = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progress:SetPoint("BOTTOMLEFT", card, "BOTTOMLEFT", 10, 9)
    progress:SetText(string.format("%d / %d", data.current or 0, data.goal or 0))
    progress:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    -- War credit reward (far right): amount + faction symbol.
    local symbol = card:CreateTexture(nil, "OVERLAY")
    symbol:SetAtlas((WB.faction == "Alliance") and "AllianceSymbol" or "HordeSymbol")
    symbol:SetSize(20, 20)
    symbol:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -9, 3)

    local warCredit = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    -- +2 keeps the number on the gold-coin baseline while the symbol box sits a
    -- touch lower, so the crest reads on par with the coin instead of riding high.
    warCredit:SetPoint("RIGHT", symbol, "LEFT", -4, 2)
    warCredit:SetText(tostring(data.warCredit or 0))
    warCredit:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    -- Gold reward (left of war credit): amount + gold coin.
    local coin = card:CreateTexture(nil, "OVERLAY")
    SetWarBoardCoinIcon(coin, 24)
    coin:SetPoint("RIGHT", warCredit, "LEFT", -8, 0)

    local goldText = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    goldText:SetPoint("RIGHT", coin, "LEFT", -2, 0)
    goldText:SetText(tostring(data.gold or 0))
    goldText:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    card.title, card.desc, card.progress    = title, desc, progress
    card.gold, card.warCredit               = goldText, warCredit
    card.obj                                = data.obj

    function card:RefreshReward()
        local gold, wc = data.gold or 0, data.warCredit or 0
        if self.obj and WB.WarEffort and WB.WarEffort.GetRewardForObjective then
            gold, wc = WB.WarEffort.GetRewardForObjective(self.obj)
        end
        if self.gold then self.gold:SetText(tostring(gold or 0)) end
        if self.warCredit then self.warCredit:SetText(tostring(wc or 0)) end
    end
    card:RefreshReward()

    -- "Completed" banner + checkmark, top-right corner of the card. The card
    -- clips its children, so these are anchored just inside the corner (a flag
    -- hanging into the card) rather than protruding past the edge.
    --
    -- Parented to a thin overlay frame one frame-level above the card so the
    -- banner is guaranteed to draw ON TOP of the quest-name title text where the
    -- two overlap (frame level wins over draw layer/sublevel between frames).
    local flair = CreateFrame("Frame", nil, card)
    flair:SetAllPoints(card)
    flair:SetFrameLevel(card:GetFrameLevel() + 5)

    -- Sublevels: banner under the checkmark so the tick reads on top of the flag.
    local banner = flair:CreateTexture(nil, "OVERLAY", nil, 6)
    banner:SetAtlas("housing-dashboard-activity-listitem-flag", true)
    banner:SetPoint("TOPRIGHT", card, "TOPRIGHT", -2, -2)

    local check = flair:CreateTexture(nil, "OVERLAY", nil, 7)
    check:SetAtlas("housing-dashboard-small-checkmark", true)
    check:SetPoint("CENTER", banner, "CENTER", 0, 0)

    card.completeBanner, card.completeCheck = banner, check

    -- Toggle the completion flair. Driven by the tracker: shown once the objective
    -- has been finished at least once (WB.WarEffort.IsComplete).
    function card:SetComplete(done)
        if done then
            self.completeBanner:Show()
            self.completeCheck:Show()
        else
            self.completeBanner:Hide()
            self.completeCheck:Hide()
        end
    end
    card:SetComplete(data.complete and true or false)

    -- Hover tooltip: an explicit label if given, else the zone inferred from the texture.
    local zone = ZoneFromTexture(data.texture)
    card.zone = zone
    local zoneLabel = data.zoneLabel or zone
    if zoneLabel then
        card:EnableMouse(true)
        card:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(zoneLabel, 1, 0.82, 0.2)
            GameTooltip:Show()
        end)
        card:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    return card
end

-- ── Column builders ───────────────────────────────────────────────────────────

-- LEFT: two stacked blocks — Service Record (top) then War Effort (below).
local function BuildLeftColumn(col)
    -- Service Record (own block)
    local sr = CreatePanel(col)
    sr:SetPoint("TOPLEFT",  col, "TOPLEFT",  0, 0)
    sr:SetPoint("TOPRIGHT", col, "TOPRIGHT", 0, 0)
    sr:SetHeight(182)

    local srHeader = SectionHeader(sr, "Service Record")
    srHeader:SetPoint("TOPLEFT", sr, "TOPLEFT", 12, -12)

    -- Rank icon (stands alone — no base portrait): the faction "no rank" badge by
    -- default; SetRank swaps in the prestige badge for ranks 1-3.
    local rankBadge = (WB.faction == "Alliance")
        and "pvpqueue-sidebar-honorbar-badge-alliance"
        or  "pvpqueue-sidebar-honorbar-badge-horde"

    local rankIcon = sr:CreateTexture(nil, "ARTWORK")
    rankIcon:SetAtlas(rankBadge)
    rankIcon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
    rankIcon:SetPoint("TOPLEFT", srHeader, "BOTTOMLEFT", 0, -8)

    -- Name + stats sit to the right of the rank icon.
    local name = sr:CreateFontString(nil, "OVERLAY")
    name:SetFont("Fonts\\FRIZQT__.TTF", 16, "OUTLINE")
    name:SetPoint("TOPLEFT", rankIcon, "TOPRIGHT", 10, -2)
    name:SetText(UnitName("player") or "Soldier")
    name:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
    local nameHit = MakeNameHover(sr)
    nameHit:SetAllPoints(name)
    nameHit._fullName = FullPlayerName(UnitName("player"), GetNormalizedRealmName())

    local function StatRow(anchor, labelText, valueText)
        local label = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -6)
        label:SetText(labelText)
        label:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])
        local value = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        value:SetPoint("LEFT", label, "RIGHT", 4, 0)
        value:SetText(valueText)
        value:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        return label, value
    end

    local rankRow, rankVal = StatRow(name,    "Rank:", "No Rank")
    local goldRow, goldVal = StatRow(rankRow, "Gold:", "0")
    local _,       wcVal   = StatRow(goldRow, "War Credit:", "0")

    -- Gold coin + war-credit crest icons after their values (follow the value text).
    local goldCoin = sr:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(goldCoin, 32)
    goldCoin:SetPoint("LEFT", goldVal, "RIGHT", 4, 0)

    local wcSym = sr:CreateTexture(nil, "ARTWORK")
    wcSym:SetAtlas((WB.faction == "Alliance") and "AllianceSymbol" or "HordeSymbol")
    wcSym:SetSize(20, 20)
    wcSym:SetPoint("LEFT", wcVal, "RIGHT", 3, -3)

    WB.UI.serviceRecord = {
        name = name, rank = rankVal, gold = goldVal, warCredit = wcVal,
        icon = rankIcon, badge = rankBadge,
    }

    -- ── Rank-up progression bar (below the stats) ──────────────────────────────
    local progTitle = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progTitle:SetPoint("TOPLEFT", sr, "TOPLEFT", 12, -118)
    progTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    progTitle:SetText("Progress to Next Rank")

    local progIcon = sr:CreateTexture(nil, "ARTWORK")   -- small next-rank badge
    progIcon:SetSize(14, 14)
    progIcon:SetPoint("LEFT", progTitle, "RIGHT", 5, 0)

    local progBar = CreateFrame("Frame", nil, sr)
    progBar:SetPoint("TOPLEFT", progTitle, "BOTTOMLEFT", 0, -6)
    progBar:SetPoint("RIGHT",   sr, "RIGHT", -12, 0)
    progBar:SetHeight(15)
    local progBorder = progBar:CreateTexture(nil, "BACKGROUND", nil, -2)
    progBorder:SetColorTexture(0.45, 0.40, 0.28, 0.55)
    progBorder:SetPoint("TOPLEFT",     progBar, "TOPLEFT",     -1,  1)
    progBorder:SetPoint("BOTTOMRIGHT", progBar, "BOTTOMRIGHT",  1, -1)
    local progBg = progBar:CreateTexture(nil, "BACKGROUND", nil, -1)
    progBg:SetAllPoints(progBar)
    progBg:SetColorTexture(0.05, 0.04, 0.03, 0.9)
    local progFill = progBar:CreateTexture(nil, "ARTWORK")
    progFill:SetAtlas("activities-bar-background-glow")
    progFill:SetPoint("TOPLEFT",    progBar, "TOPLEFT",    1, -1)
    progFill:SetPoint("BOTTOMLEFT", progBar, "BOTTOMLEFT", 1,  1)
    progFill:SetWidth(1)
    progBar.fill, progBar.frac = progFill, 0
    -- Re-apply the fill width whenever the bar gets/changes its real width (covers the
    -- case where the seed ran before layout computed a width).
    progBar:SetScript("OnSizeChanged", function(self)
        local bw = (self:GetWidth() or 0) - 2
        self.fill:SetWidth(math.max(1, (self.frac or 0) * math.max(1, bw)))
    end)

    local progText = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progText:SetPoint("TOPLEFT", progBar, "BOTTOMLEFT", 0, -4)
    progText:SetTextColor(0.72, 0.76, 0.84)

    WB.UI.rankProgress = { title = progTitle, icon = progIcon, bar = progBar, fill = progFill, text = progText }

    WB.UI.RefreshCurrency()  -- seed gold / war credit + rank + progression

    -- War Effort (own block, fills the rest of the column; no quests yet)
    local we = CreatePanel(col)
    we:SetPoint("TOPLEFT",  sr,  "BOTTOMLEFT",  0, -GAP)
    we:SetPoint("TOPRIGHT", sr,  "BOTTOMRIGHT", 0, -GAP)
    we:SetPoint("BOTTOM",   col, "BOTTOM",      0, 0)

    local weHeader = SectionHeader(we, "War Effort")
    weHeader:SetPoint("TOPLEFT", we, "TOPLEFT", 12, -12)

    -- Description is shown as a tooltip on the title rather than inline, so the
    -- card list gets the full panel height.
    local WE_DESC = "Complete objectives to earn gold and war credit. Finished objectives can be repeated for 10% of their reward. Daily objectives reset each day."
    local weHeaderHit = CreateFrame("Frame", nil, we)
    weHeaderHit:SetPoint("CENTER", weHeader, "CENTER", 0, 0)
    weHeaderHit:SetSize((weHeader:GetStringWidth() or 80) + 16, 22)
    weHeaderHit:EnableMouse(true)
    weHeaderHit:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("War Effort", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine(WE_DESC, 0.90, 0.88, 0.82, true)
        GameTooltip:Show()
    end)
    weHeaderHit:SetScript("OnLeave", function() GameTooltip:Hide() end)

    local objectiveScroll = CreateFrame("ScrollFrame", nil, we, "UIPanelScrollFrameTemplate")
    objectiveScroll:SetPoint("TOPLEFT",     weHeader, "BOTTOMLEFT", -4, -8)
    objectiveScroll:SetPoint("BOTTOMRIGHT", we,       "BOTTOMRIGHT", -28, 10)

    local objectiveList = CreateFrame("Frame", nil, objectiveScroll)
    objectiveList:SetSize(1, 1)
    objectiveScroll:SetScrollChild(objectiveList)
    objectiveScroll:SetScript("OnSizeChanged", function(self, width)
        objectiveList:SetWidth(width)
    end)
    objectiveScroll:EnableMouseWheel(true)
    objectiveScroll:SetScript("OnMouseWheel", function(self, delta)
        local nextScroll = self:GetVerticalScroll() - (delta * 32)
        local maxScroll = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nextScroll, maxScroll)))
    end)

    -- Objectives are authored in WarEffort_Objectives.lua. Enemy faction is the
    -- player's opposite; a "%s" in a desc is replaced with it.
    local enemy = (WB.faction == "Alliance") and "Horde" or "Alliance"
    -- Only show objectives for this faction. faction = "horde"/"alliance"
    -- restricts visibility; objectives without a faction show to both.
    local myFaction = (WB.faction or "Horde"):lower()
    local OBJECTIVES = {}
    for _, obj in ipairs((WB.WarEffort and WB.WarEffort.Objectives) or {}) do
        local f = obj.faction and obj.faction:lower()
        -- `retired` objectives stay in ById (so old records still validate) but are no
        -- longer offered as cards — the forward-compatible way to remove an objective.
        if (not f or f == myFaction) and not obj.retired then
            OBJECTIVES[#OBJECTIVES + 1] = obj
        end
    end

    WB.UI.objectiveCards = {}
    if #OBJECTIVES == 0 then
        objectiveList:SetHeight(24)
        local weEmpty = objectiveList:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        weEmpty:SetPoint("TOPLEFT", objectiveList, "TOPLEFT", 4, 0)
        weEmpty:SetPoint("RIGHT",   objectiveList, "RIGHT",  -4, 0)
        weEmpty:SetJustifyH("LEFT")
        weEmpty:SetText("No active objectives.")
        weEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    else
        local cardHeight, cardGap = 92, 8
        objectiveList:SetHeight((#OBJECTIVES * cardHeight) + ((#OBJECTIVES - 1) * cardGap))
        for i, obj in ipairs(OBJECTIVES) do
            local desc = (obj.desc or ""):gsub("%%s", enemy)
            local gold, warCredit = obj.gold, obj.warCredit
            if WB.WarEffort and WB.WarEffort.GetRewardForObjective then
                gold, warCredit = WB.WarEffort.GetRewardForObjective(obj)
            end
            local card = CreateObjectiveCard(objectiveList, {
                obj       = obj,
                title     = obj.title,
                desc      = desc,
                texture   = obj.texture,
                texCoord  = obj.texCoord,
                zoneLabel = obj.zoneLabel,
                current   = (WB.WarEffort.GetProgress and WB.WarEffort.GetProgress(obj.id)) or 0,
                goal      = obj.goal,
                gold      = gold,
                warCredit = warCredit,
                complete  = (WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(obj.id)) or false,
                elite     = obj.elite,
            })
            card.id   = obj.id
            card.goal = obj.goal
            card.zone = WB.WarEffort and WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(obj.texture)
            card:SetPoint("TOPLEFT",  objectiveList, "TOPLEFT",  4, -((i - 1) * (cardHeight + cardGap)))
            card:SetPoint("TOPRIGHT", objectiveList, "TOPRIGHT", -4, -((i - 1) * (cardHeight + cardGap)))
            WB.UI.objectiveCards[#WB.UI.objectiveCards + 1] = card
        end
    end

    WB.UI.warEffortBody   = we
    WB.UI.warEffortScroll = objectiveScroll
    WB.UI.objectiveList   = objectiveList
    -- Apply the sticky current-zone ordering to the freshly built cards.
    if WB.UI.RelayoutObjectives then WB.UI.RelayoutObjectives() end
end

local function FmtNumber(n)
    n = n or 0
    if BreakUpLargeNumbers then return BreakUpLargeNumbers(n) end
    return tostring(n)
end

local function EllipsizeText(fs, text, maxWidth)
    text = tostring(text or "")
    fs:SetText(text)
    if not maxWidth or maxWidth <= 0 or fs:GetStringWidth() <= maxWidth then return text end

    local suffix = "..."
    local lo, hi, best = 0, #text, suffix
    while lo <= hi do
        local mid = math.floor((lo + hi) / 2)
        local candidate = text:sub(1, mid) .. suffix
        fs:SetText(candidate)
        if fs:GetStringWidth() <= maxWidth then
            best = candidate
            lo = mid + 1
        else
            hi = mid - 1
        end
    end
    fs:SetText(best)
    return best
end

-- Compute the player's rank from total war credit and drive the Service Record's rank
-- icon/text + the "Progress to Next Rank" bar.
function WB.UI.RefreshRank()
    local rec = WB.UI.serviceRecord
    if not rec then return end
    -- Rank is lifetime WC. Prefer the synced "service record" floor (survives a fresh install /
    -- the windowed sync); fall back to the live ledger total if the roster isn't up yet.
    local wc    = (WB.Roster and WB.Roster.MyLifetimeWC and WB.Roster.MyLifetimeWC())
                  or (WB.WarEffort and WB.WarEffort.GetWarCredit and WB.WarEffort.GetWarCredit()) or 0
    local rank  = RankFromWC(wc)
    local names = RANK_NAMES[WB.faction] or RANK_NAMES.Horde

    if rec.icon then
        if rank >= 1 then rec.icon:SetAtlas("honorsystem-icon-prestige-" .. rank)
        else              rec.icon:SetAtlas(rec.badge) end
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
    end
    if rec.rank then rec.rank:SetText(names[rank] or "No Rank") end

    local p = WB.UI.rankProgress
    if not p then return end
    local function ApplyFill(frac)
        p.bar.frac = frac
        local bw = (p.bar:GetWidth() or 0) - 2
        if bw < 1 then bw = 166 end   -- fallback before layout; OnSizeChanged corrects it
        p.fill:SetWidth(math.max(1, frac * bw))
    end
    local nextT = RANK_THRESHOLDS[rank + 1]
    if nextT then
        p.title:SetText("Progress to Next Rank: " .. (names[rank + 1] or ("Rank " .. (rank + 1))))
        p.icon:SetAtlas("honorsystem-icon-prestige-" .. (rank + 1))
        p.icon:Show()
        local frac = math.min(1, wc / nextT)
        ApplyFill(frac)
        p.text:SetText(("%s / %s WC   \226\128\148   %d%%"):format(FmtNumber(wc), FmtNumber(nextT), math.floor(frac * 100 + 0.5)))
    else
        p.title:SetText("Max Rank: " .. (names[3] or "Sergeant"))
        p.icon:SetAtlas("honorsystem-icon-prestige-3"); p.icon:Show()
        ApplyFill(1)
        p.text:SetText(("%s WC earned"):format(FmtNumber(wc)))
    end
end

-- War Effort tug of war: Horde war credit vs Alliance war credit. The divider
-- sits at Horde's share of the combined total (50/50 with no data); each side's
-- contribution is shown below the bar.
function WB.UI.RefreshWarEffort()
    local p = WB.UI.warEffortProgress
    if not p then return end
    local fwc = WB.Ledger and WB.Ledger.FactionWarCredit
    local hordeWC = (fwc and fwc("Horde"))    or 0
    local allyWC  = (fwc and fwc("Alliance")) or 0
    if p.hordeVal    then p.hordeVal:SetText(FmtNumber(hordeWC) .. " WC") end
    if p.allianceVal then p.allianceVal:SetText(FmtNumber(allyWC) .. " WC") end

    local total = hordeWC + allyWC
    local frac  = (total > 0) and (hordeWC / total) or 0.5   -- Horde's share of the bar
    local w = (p.barBg and p.barBg:GetWidth()) or 0
    if w > 0 and p.hordeFill and p.allianceFill then
        p.hordeFill:SetWidth(math.max(0.01, w * frac))
        p.allianceFill:SetWidth(math.max(0.01, w * (1 - frac)))
    end
end

-- Absolute date/time + relative "ago" for an activity record's timestamp (local time).
local function FmtActivityTime(t)
    t = tonumber(t)
    if not t or t <= 0 then return nil end
    local rel, ago = time() - t, nil
    if     rel < 60    then ago = "just now"
    elseif rel < 3600  then ago = math.floor(rel / 60)    .. "m ago"
    elseif rel < 86400 then ago = math.floor(rel / 3600)  .. "h ago"
    else                    ago = math.floor(rel / 86400) .. "d ago" end
    return date("%b %d, %I:%M %p", t) .. "  (" .. ago .. ")"
end

-- Compact "Xd Yh Zm" duration (drops leading zero units).
local function FmtDHM(secs)
    secs = math.max(0, math.floor(tonumber(secs) or 0))
    local d = math.floor(secs / 86400)
    local h = math.floor((secs % 86400) / 3600)
    local m = math.floor((secs % 3600) / 60)
    if d > 0 then return string.format("%dd %dh %dm", d, h, m) end
    if h > 0 then return string.format("%dh %dm", h, m) end
    return string.format("%dm", m)
end

-- Campaign Status: the active-warfront title card + the "Campaign ends in" clock,
-- plus the Leader Board's reset countdown. All driven by the WB.Campaign module.
function WB.UI.RefreshCampaign()
    if not WB.Campaign then return end
    local c = WB.UI.campaign
    if c then
        if c.name then c.name:SetText(WB.Campaign.ActiveWarfront() or "Unknown") end
        if c.art then
            local tex = WB.Campaign.ActiveWarfrontTexture()
            if tex then c.art:SetTexture(tex); c.art:Show() else c.art:Hide() end
        end
        -- While the just-ended campaign finalizes, the bar/board show that settling
        -- campaign — the countdown reflects the lock timer, not the next end.
        local finalizing = WB.Campaign.IsFinalizing and WB.Campaign.IsFinalizing()
        if c.endsLabel and c.endsValue then
            if finalizing then
                c.endsLabel:SetText("Reconciliation:")
                c.endsValue:SetText(FmtDHM(WB.Campaign.SecondsUntilFinalized()))
            else
                c.endsLabel:SetText("Campaign ends in:")
                c.endsValue:SetText(FmtDHM(WB.Campaign.SecondsUntilEnd()))
            end
        end
        if c.endsRow and c.endsLabel and c.endsValue then
            local w = (c.endsLabel:GetStringWidth() or 0) + 4 + 18 + 3 + (c.endsValue:GetStringWidth() or 0)
            c.endsRow:SetWidth(w)
        end
    end
    if WB.UI.lbResetFS then
        WB.UI.lbResetFS:SetText("Leader Board resets in: " .. FmtDHM(WB.Campaign.SecondsUntilEnd()))
    end
end

-- Render the Leader Board from the synchronized ledger: per-player war credit /
-- gold / kills aggregated across all activity records, sorted by war credit, plus
-- the local player's own standing for the footer.
function WB.UI.RefreshLeaderboard()
    local pane = WB.UI.leaderboardPane
    if not pane or not pane.SetLeaderboard then return end
    local list = (WB.Ledger and WB.Ledger.Leaderboard and WB.Ledger.Leaderboard()) or {}
    -- Rank by the active tab's metric (War Credit by default, Kills on the Kills tab),
    -- with the other metric then name as tie-breakers.
    local byKills = (pane.mode == "kills")
    table.sort(list, function(a, b)
        local av = byKills and (a.kills or 0) or (a.warCredit or 0)
        local bv = byKills and (b.kills or 0) or (b.warCredit or 0)
        if av ~= bv then return av > bv end
        local a2 = byKills and (a.warCredit or 0) or (a.kills or 0)
        local b2 = byKills and (b.warCredit or 0) or (b.kills or 0)
        if a2 ~= b2 then return a2 > b2 end
        return (a.name or "") < (b.name or "")
    end)
    local standing
    if WB.Ledger and WB.Ledger.MyIdentity then
        local myName, myRealm, myGuid = WB.Ledger.MyIdentity()
        for i, e in ipairs(list) do
            local isMe = (myGuid and e.guid == myGuid)
                      or (e.name == myName and (e.realm or "") == (myRealm or ""))
            if isMe then
                standing = { rank = i, kills = e.kills, warCredit = e.warCredit }
                break
            end
        end
    end
    pane:SetLeaderboard(list, standing)
end

-- Builds the Leader Board pane: TOP CONTRIBUTORS table (# / Name / Kills / War
-- Credit) over a "your standing" footer + reset countdown. Data is injected via
-- pane:SetLeaderboard(entries) / WB.UI.SetLeaderboard(entries) — seeded with
-- placeholder standings for now (real per-faction tally comes later).
local function BuildLeaderboardPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    -- The board mixes both factions, so per-row art/colour comes from each entry's
    -- own faction rather than the viewer's.
    local function FactionSymbol(faction)
        return (faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"
    end

    -- Rank icon to the left of a name: prestige badge for ranks 1-3, otherwise the
    -- faction quest-type icon for "no rank" (rank 0).
    local function RankIcon(rank, faction)
        rank = tonumber(rank) or 0
        if rank >= 1 and rank <= 3 then
            return "honorsystem-icon-prestige-" .. rank
        end
        return (faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde"
    end

    -- Shared column offsets — header labels and row cells use identical anchors
    -- (both span the same width) so the columns line up.
    local X_RANK, X_CREST, X_NAME    = 6, 28, 54
    local R_KILLS, R_WCNUM, R_WCSYM  = -132, -38, -10

    -- ── Tabs: War Credit | Kills (centered pills) ───────────────────────────────
    local function MakePill(text)
        local b = CreateFrame("Button", nil, pane, "BackdropTemplate")
        b:SetSize(92, 19)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 10,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        b.label = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        b.label:SetPoint("CENTER")
        b.label:SetText(text)
        return b
    end
    local pillWC = MakePill("War Credit")
    local pillK  = MakePill("Kills")
    pillWC:SetPoint("TOPRIGHT", pane, "TOP", -2, -3)
    pillK:SetPoint("TOPLEFT",   pane, "TOP",  2, -3)

    -- Title + subtitle (text flips per tab; set in ApplyMode).
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -26)
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    local subtitle = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subtitle:SetPoint("TOP", title, "BOTTOM", 0, -2)
    subtitle:SetJustifyH("CENTER")
    subtitle:SetTextColor(0.74, 0.70, 0.60)

    -- Column header (# / Name / value). The value column label flips per tab.
    local head = CreateFrame("Frame", nil, pane)
    head:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -62)
    head:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -62)
    head:SetHeight(16)
    local function HeaderLabel(text, justify, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.80, 0.72)
        fs:SetJustifyH(justify)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeaderLabel("#",    "LEFT",  "LEFT",  X_RANK)
    HeaderLabel("Name", "LEFT",  "LEFT",  X_NAME)
    local valueHeader = HeaderLabel("War Credit", "RIGHT", "RIGHT", R_WCSYM)

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -3)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -3)

    -- Tab styling + switching.
    local function UpdatePills()
        local kills = (pane.mode == "kills")
        local function style(b, on)
            if on then
                b:SetBackdropColor(0.42, 0.11, 0.09, 0.95)
                b:SetBackdropBorderColor(0.95, 0.70, 0.35, 1)
                b.label:SetTextColor(1, 0.92, 0.72)
            else
                b:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
                b:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.9)
                b.label:SetTextColor(0.68, 0.64, 0.54)
            end
        end
        style(pillWC, not kills)
        style(pillK,  kills)
    end
    local function ApplyMode()
        UpdatePills()
        if pane.mode == "kills" then
            title:SetText("TOP KILLERS")
            subtitle:SetText("Players who have earned the most kills this campaign")
            valueHeader:SetText("Kills")
        else
            title:SetText("TOP CONTRIBUTORS")
            subtitle:SetText("Players who have earned the most War Credit this campaign")
            valueHeader:SetText("War Credit")
        end
        if WB.UI.RefreshLeaderboard then WB.UI.RefreshLeaderboard() end
    end
    pillWC:SetScript("OnClick", function() pane.mode = "wc";    ApplyMode() end)
    pillK:SetScript("OnClick",  function() pane.mode = "kills"; ApplyMode() end)
    pane.mode = pane.mode or "wc"

    -- Footer (bottom-up): reset countdown, "your standing" row. (The Community button now
    -- lives in the War Board's top-left corner — built in the frame builder below.)
    -- The Leader Board resets at each campaign end (not weekly). RefreshCampaign
    -- keeps this text live via WB.UI.lbResetFS.
    local resetFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    resetFS:SetPoint("BOTTOM", pane, "BOTTOM", 0, 8)
    resetFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    WB.UI.lbResetFS = resetFS
    local csecs = WB.Campaign and WB.Campaign.SecondsUntilEnd and WB.Campaign.SecondsUntilEnd()
    resetFS:SetText("Leader Board resets in: " .. FmtDHM(csecs))

    local mine = CreateFrame("Frame", nil, pane)
    mine:SetPoint("BOTTOM", resetFS, "TOP",   0, 8)
    mine:SetPoint("LEFT",   pane,    "LEFT",  12, 0)
    mine:SetPoint("RIGHT",  pane,    "RIGHT", -12, 0)
    mine:SetHeight(20)
    local mineLine = HLine(pane)
    mineLine:SetPoint("BOTTOMLEFT",  mine, "TOPLEFT",  0, 5)
    mineLine:SetPoint("BOTTOMRIGHT", mine, "TOPRIGHT", 0, 5)

    local function LabeledText(label, value)
        return string.format("|cffc8b48c%s|r |cffffffff%s|r", label, value)
    end
    local myRank = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myRank:SetPoint("LEFT", mine, "LEFT", X_RANK, 0)
    local myKills = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myKills:SetPoint("CENTER", mine, "CENTER", 0, 0)
    local myWC = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myWC:SetPoint("RIGHT", mine, "RIGHT", R_WCSYM, 0)

    -- Row host fills the space between the header rule and the footer rule.
    local rowHost = CreateFrame("Frame", nil, pane)
    rowHost:SetPoint("TOPLEFT",  hLine,    "BOTTOMLEFT",  0, -4)
    rowHost:SetPoint("TOPRIGHT", hLine,    "BOTTOMRIGHT", 0, -4)
    rowHost:SetPoint("BOTTOM",   mineLine, "TOP",         0, 4)

    local ROW_H = 30
    local rows  = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, rowHost)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  rowHost, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", rowHost, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(r)
        bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.025 or 0.05)

        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.rank:SetPoint("LEFT", r, "LEFT", X_RANK, 0)
        r.rank:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        -- Rank icon (set per-entry in SetLeaderboard); a touch larger than the
        -- old crest. Falls back to the faction symbol for unranked players.
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(20, 20)
        r.rankIcon:SetPoint("LEFT", r, "LEFT", X_CREST, 0)

        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("LEFT", r, "LEFT", X_NAME, 0)  -- colour set per-entry (faction)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)

        -- Kills cell lives at the right edge (shown only on the Kills tab, where it's
        -- the value column); the War Credit number/symbol occupy the same area on the WC tab.
        r.kills = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.kills:SetPoint("RIGHT", r, "RIGHT", R_WCSYM, 0)
        r.kills:SetJustifyH("RIGHT")
        r.kills:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.wc:SetPoint("RIGHT", r, "RIGHT", R_WCNUM, 0)
        r.wc:SetJustifyH("RIGHT")
        r.wc:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        r.wcSym = r:CreateTexture(nil, "ARTWORK")  -- atlas set per-entry (faction)
        r.wcSym:SetSize(16, 16)
        r.wcSym:SetPoint("RIGHT", r, "RIGHT", R_WCSYM, 0)

        rows[i] = r
        return r
    end

    function pane:SetLeaderboard(entries, standing)
        entries = entries or {}
        for _, r in ipairs(rows) do r:Hide() end
        local kills = (pane.mode == "kills")
        local MAX_ROWS = 11
        for i = 1, math.min(#entries, MAX_ROWS) do
            local e = entries[i]
            local r  = GetRow(i)
            local nc = FACTION_NAME_COLOR[e.faction] or FACTION_NAME_COLOR.Horde
            r.rank:SetText(tostring(i))
            r.rankIcon:SetAtlas(RankIcon(e.rank, e.faction))
            r.name:SetText(e.name or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(e.name, e.realm)
            if kills then
                r.kills:SetText(FmtNumber(e.kills)); r.kills:Show()
                r.wc:Hide(); r.wcSym:Hide()
            else
                r.wc:SetText(FmtNumber(e.warCredit)); r.wc:Show()
                r.wcSym:SetAtlas(FactionSymbol(e.faction)); r.wcSym:Show()
                r.kills:Hide()
            end
            r:Show()
        end
        standing = standing or {}
        myRank:SetText(LabeledText("Your Rank:",  standing.rank      and tostring(standing.rank) or "\226\128\148"))
        myKills:SetText(LabeledText("Kills:",      FmtNumber(standing.kills)))
        myWC:SetText(LabeledText("War Credit:",    FmtNumber(standing.warCredit)))
    end

    WB.UI.leaderboardPane = pane
    WB.UI.SetLeaderboard  = function(entries, standing) pane:SetLeaderboard(entries, standing) end

    -- Apply the initial tab (TOP CONTRIBUTORS / War Credit) + render from the ledger.
    ApplyMode()
end

-- Builds the Activity pane: RECENT ACTIVITY feed (Time / Player / Objective /
-- Reward) with a green "completed" mark at the left and a Show More pager. Data
-- comes from WB.WarEffort.GetActivity(); re-render via WB.UI.RefreshActivity().
local function BuildActivityPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end
    pane.shownCount = 10

    -- War-credit reward icon is the currency crest (viewer's faction); the Player
    -- column crest, by contrast, reflects each entry's own faction.
    local viewerSym = (WB.faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"

    -- Column offsets (header labels + row cells share these). The entry timestamp
    -- stays in the data for ordering/verification but is not displayed.
    local X_CREST, X_NAME, X_OBJ = 44, 64, 160
    local OBJ_RIGHT = -132  -- objective text right bound, clear of the reward cluster

    -- Title
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -2)
    title:SetText("RECENT ACTIVITY")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- Column header
    local head = CreateFrame("Frame", nil, pane)
    head:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -30)
    head:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -30)
    head:SetHeight(16)
    local function HeaderLabel(text, justify, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.80, 0.72)
        fs:SetJustifyH(justify)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeaderLabel("Player", "LEFT",  "LEFT",  X_CREST)
    HeaderLabel("Event",  "LEFT",  "LEFT",  X_OBJ)
    HeaderLabel("Reward / Details", "RIGHT", "RIGHT", -8)

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -3)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -3)

    -- Show More button (bottom)
    local moreBtn = CreateFrame("Button", nil, pane, "BackdropTemplate")
    moreBtn:SetHeight(24)
    moreBtn:SetPoint("BOTTOMLEFT",  pane, "BOTTOMLEFT",  40, 4)
    moreBtn:SetPoint("BOTTOMRIGHT", pane, "BOTTOMRIGHT", -40, 4)
    moreBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    moreBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    moreBtn:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85)
    local moreLabel = moreBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    moreLabel:SetPoint("CENTER")
    moreLabel:SetText("Show More")
    moreLabel:SetTextColor(0.85, 0.80, 0.66)
    moreBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.70, 0.55, 0.32, 1) end)
    moreBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85) end)

    -- Scrollable row area (bare scroll, mouse-wheel only — keeps the full width).
    local ROW_H  = 44
    local scroll = CreateFrame("ScrollFrame", nil, pane)
    scroll:SetPoint("TOPLEFT",  hLine,   "BOTTOMLEFT",  0, -4)
    scroll:SetPoint("TOPRIGHT", hLine,   "BOTTOMRIGHT", 0, -4)
    scroll:SetPoint("BOTTOM",   moreBtn, "TOP",         0, 4)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local nv = self:GetVerticalScroll() - delta * ROW_H
        self:SetVerticalScroll(math.max(0, math.min(nv, maxScroll)))
    end)

    local emptyFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    emptyFS:SetPoint("TOP", scroll, "TOP", 0, -16)
    emptyFS:SetText("No recent activity yet.")
    emptyFS:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    emptyFS:Hide()

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, content)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(r)
        bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.025 or 0.05)

        -- Green "completed" mark (same banner + check as the objective cards).
        r.flag = r:CreateTexture(nil, "OVERLAY", nil, 1)
        r.flag:SetAtlas("housing-dashboard-activity-listitem-flag", true)
        r.flag:SetPoint("LEFT", r, "LEFT", 4, 0)
        r.check = r:CreateTexture(nil, "OVERLAY", nil, 2)
        r.check:SetAtlas("housing-dashboard-small-checkmark", true)
        r.check:SetPoint("CENTER", r.flag, "CENTER", 0, 0)

        r.crest = r:CreateTexture(nil, "ARTWORK")  -- player faction; set per-entry
        r.crest:SetSize(16, 16)
        r.crest:SetPoint("LEFT", r, "LEFT", X_CREST, 0)

        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("LEFT", r, "LEFT", X_NAME, 0)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)

        r.obj = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.obj:SetPoint("LEFT",  r, "LEFT",  X_OBJ, 0)
        r.obj:SetPoint("RIGHT", r, "RIGHT", OBJ_RIGHT, 0)
        r.obj:SetJustifyH("LEFT")
        r.obj:SetWordWrap(false)
        r.obj:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Hover region over the objective column: shows the full (untruncated)
        -- name and whether it was a repeat completion.
        r.objHit = CreateFrame("Frame", nil, r)
        r.objHit:SetPoint("TOPLEFT",     r, "TOPLEFT",     X_OBJ, 0)
        r.objHit:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", OBJ_RIGHT, 0)
        r.objHit:EnableMouse(true)
        r.objHit:SetScript("OnEnter", function(self)
            if not r.objFull then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(r.objFull, GOLD[1], GOLD[2], GOLD[3])
            if r.objRepeat then
                GameTooltip:AddLine("Repeat completion", 0.70, 0.70, 0.70)
            end
            local ts = FmtActivityTime(r.objTime)
            if ts then GameTooltip:AddLine(ts, 0.58, 0.58, 0.58) end
            GameTooltip:Show()
        end)
        r.objHit:SetScript("OnLeave", function() GameTooltip:Hide() end)

        -- Reward cluster (right → left): wc crest, wc number, coin, gold number.
        r.wcSym = r:CreateTexture(nil, "ARTWORK")
        r.wcSym:SetAtlas(viewerSym)
        r.wcSym:SetSize(16, 16)
        r.wcSym:SetPoint("RIGHT", r, "RIGHT", -8, 0)
        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.wc:SetPoint("RIGHT", r.wcSym, "LEFT", -3, 0)
        r.wc:SetJustifyH("RIGHT")
        r.wc:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        r.coin = r:CreateTexture(nil, "ARTWORK")
        SetWarBoardCoinIcon(r.coin, 32)
        r.coin:SetPoint("RIGHT", r.wc, "LEFT", -8, 0)
        r.gold = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.gold:SetPoint("RIGHT", r.coin, "LEFT", -3, 0)
        r.gold:SetJustifyH("RIGHT")
        r.gold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        rows[i] = r
        return r
    end

    function pane:RenderActivity()
        local data  = (WB.WarEffort and WB.WarEffort.GetActivity and WB.WarEffort.GetActivity()) or {}
        local total = #data
        local show  = math.min(self.shownCount or 10, total)

        for _, r in ipairs(rows) do r:Hide() end
        for i = 1, show do
            local e  = data[i]
            local r  = GetRow(i)
            local nc = FACTION_NAME_COLOR[e.faction] or FACTION_NAME_COLOR.Horde
            local atype = e.atype or "objective"
            local isObjective = (atype == "objective")

            r.crest:SetAtlas((e.faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde")
            r.name:SetText(e.name or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(e.name, e.realm)

            -- Green completed-flag only for objective completions.
            r.flag:SetShown(isObjective)
            r.check:SetShown(isObjective)

            -- Event column text + colour. The title (e.obj) can originate on another
            -- player's client, so sanitize it before display. Records from a newer War
            -- Board version we couldn't fully verify are flagged "(unverified)".
            local objText    = (WB.SanitizeDisplay and WB.SanitizeDisplay(e.obj, 80)) or e.obj
            local unverified = (e.st == "U")
            local eventText, ecol
            if atype == "bounty_cost" then
                eventText = "Placed a bounty on " .. (objText or "an enemy") .. "."
                ecol = { 0.90, 0.58, 0.50 }
            elseif atype == "bounty_reward" or atype == "bounty_claim" then
                eventText = "Claimed bounty on " .. (objText or "an enemy") .. "."
                ecol = { 0.55, 0.85, 0.52 }
            elseif isObjective then
                eventText = (objText or "a War Effort objective") .. (e.isRepeat and " (Repeat)" or "")
                ecol = GOLD
            else
                -- An activity type this version doesn't recognize (newer War Board) —
                -- show it generically rather than mislabel it as an objective.
                eventText = objText or "Recorded war activity"
                ecol = GOLD
            end
            if unverified then eventText = eventText .. "  |cff8a8a8a(unverified)|r" end
            r.obj:SetText(eventText)
            r.obj:SetTextColor(ecol[1], ecol[2], ecol[3])
            r.objFull   = eventText
            r.objRepeat = isObjective and e.isRepeat or false
            r.objTime   = e.t

            -- Reward / details: signed gold (red when spent) + war credit; zero parts hidden.
            local gold = tonumber(e.gold) or 0
            local wc   = tonumber(e.warCredit) or 0
            if wc ~= 0 then
                r.wc:SetText((wc < 0 and "-" or "+") .. FmtNumber(math.abs(wc)))
                r.wcSym:Show(); r.wc:Show()
                r.coin:ClearAllPoints(); r.coin:SetPoint("RIGHT", r.wc, "LEFT", -8, 0)
            else
                r.wcSym:Hide(); r.wc:Hide()
                r.coin:ClearAllPoints(); r.coin:SetPoint("RIGHT", r, "RIGHT", -8, 0)
            end
            if gold ~= 0 then
                local neg = gold < 0
                r.gold:SetText((neg and "-" or "+") .. FmtNumber(math.abs(gold)))
                r.gold:SetTextColor(neg and 0.92 or VALUE_COLOR[1], neg and 0.40 or VALUE_COLOR[2], neg and 0.38 or VALUE_COLOR[3])
                r.gold:Show(); r.coin:Show()
            else
                r.gold:Hide(); r.coin:Hide()
            end

            r:Show()
        end

        content:SetHeight(math.max(1, show * ROW_H))
        emptyFS:SetShown(total == 0)
        moreBtn:SetShown(total > show)
    end

    moreBtn:SetScript("OnClick", function()
        pane.shownCount = (pane.shownCount or 10) + 10
        pane:RenderActivity()
    end)

    WB.UI.activityPane    = pane
    WB.UI.RefreshActivity = function() pane:RenderActivity() end
    pane:RenderActivity()
end

-- Builds the Bounties pane: top posting form + bottom available-bounties list.
-- Data hooks for the future bounty/comm module:
--   WB.UI.SetBountyCandidates({ { name, level, race, class, guild, faction, rank, portrait } })
--   WB.UI.SetAvailableBounties({ { targetName, targetLevel, targetRace, targetClass, targetGuild,
--                                  targetFaction, rank, posterName, posterFaction,
--                                  rewardGold, rewardWarCredit, postedAt, message } })
local function BuildBountiesPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    local function FactionSymbol(faction)
        return (faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"
    end

    local function NameColor(faction)
        return FACTION_NAME_COLOR[faction] or VALUE_COLOR
    end

    local function RankIcon(rank, faction)
        rank = tonumber(rank) or 0
        if rank >= 1 and rank <= 3 then
            return "honorsystem-icon-prestige-" .. rank
        end
        -- No rank → faction quest-type icon.
        return (faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde"
    end

    -- Bounty cost/reward scale by the target's rank (0-3). The rank TITLE is
    -- faction-specific (RANK_NAMES); rank 0 = "No Rank".
    -- Cost/reward tiers live in WB.Bounties.TIERS so the Verifier's deep audit charges
    -- from the SAME table the Post panel does (a mismatch would quarantine real bounties).
    local function RankInfo(rank, faction)
        local r = tonumber(rank) or 0
        local t = (WB.Bounties and WB.Bounties.RankTier and WB.Bounties.RankTier(r))
                  or { cost = 500, gold = 400, wc = 200 }
        local names = RANK_NAMES[faction] or RANK_NAMES.Horde
        return (names[r] or "No Rank"), t.cost, t.gold, t.wc
    end

    -- Faction lookup by race file token (for filtering community members).
    local RACE_FACTION = {
        human = "Alliance", dwarf = "Alliance", nightelf = "Alliance", gnome = "Alliance",
        draenei = "Alliance", worgen = "Alliance", voidelf = "Alliance", lightforgeddraenei = "Alliance",
        darkironwarf = "Alliance", darkirondwarf = "Alliance", kultiran = "Alliance", kultiranhuman = "Alliance", mechagnome = "Alliance",
        orc = "Horde", scourge = "Horde", undead = "Horde", tauren = "Horde", troll = "Horde",
        bloodelf = "Horde", goblin = "Horde", nightborne = "Horde", highmountaintauren = "Horde",
        magharorc = "Horde", zandalaritroll = "Horde", vulpera = "Horde",
    }
    -- API race-file token → char-create atlas token, for allied/special races whose
    -- atlas name differs from the race file (e.g. "HighmountainTauren" → "highmountain").
    local RACE_ATLAS_ALT = {
        scourge            = "undead",
        highmountaintauren = "highmountain",
        lightforgeddraenei = "lightforged",
        darkirondwarf      = "darkiron",
        magharorc          = "maghar",
        zandalaritroll     = "zandalari",
        earthendwarf       = "earthen",
        kultiranhuman      = "kultiran",
        harronir           = "haranir",   -- race file is "Harronir", atlas is "haranir"
    }

    -- Race icon from the character-create atlas: raceicon128-<race>-<sex>. Builds a
    -- list of candidate tokens (explicit mapping, the raw token, and allied-race
    -- suffix stripping), prefers one C_Texture validates, but FALLS BACK to the best
    -- guess so brand-new races still show even when GetAtlasInfo doesn't know them yet.
    local function RaceIcon(raceFile, sex)
        if not raceFile or raceFile == "" then return nil end
        local s  = (sex == 3) and "female" or "male"
        local rl = raceFile:lower():gsub("[^%a]", "")

        local candidates = {}
        local function add(tok) if tok and tok ~= "" then candidates[#candidates + 1] = tok end end
        add(RACE_ATLAS_ALT[rl])   -- explicit mapping wins
        add(rl)                   -- direct match (covers most core races)
        for _, suffix in ipairs({ "tauren", "draenei", "dwarf", "orc", "troll", "gnome", "human" }) do
            if #rl > #suffix and rl:sub(-#suffix) == suffix then add(rl:sub(1, #rl - #suffix)) end
        end

        local getInfo = C_Texture and C_Texture.GetAtlasInfo
        local best
        for _, token in ipairs(candidates) do
            local name = "raceicon128-" .. token .. "-" .. s
            best = best or name
            if not getInfo or getInfo(name) then return name end
        end
        return best
    end

    -- Paint the circular race-icon portrait; transparent (black backing) if unknown.
    local function SetPortrait(tex, entry)
        tex:SetTexCoord(0, 1, 0, 1)
        local atlas = entry and RaceIcon(entry.raceFile, entry.sex)
        tex:SetAtlas(atlas or "charactercreate-gendericon-male")   -- generic silhouette when empty
    end

    local function CreateRedButton(parent, text)
        local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
        b:SetHeight(26)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        b:SetBackdropColor(0.48, 0.03, 0.02, 0.94)
        b:SetBackdropBorderColor(0.95, 0.55, 0.25, 0.95)
        b.label = b:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        b.label:SetPoint("CENTER")
        b.label:SetText(text)
        b.label:SetTextColor(1, 0.88, 0.66)
        b:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(1.0, 0.80, 0.38, 1) end)
        b:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.95, 0.55, 0.25, 0.95) end)
        return b
    end

    -- Shared with the Active Bounties cards (BuildRightColumn).
    WB.UI.bountyHelpers = {
        SetPortrait = SetPortrait, RaceIcon = RaceIcon, RankIcon = RankIcon, RankInfo = RankInfo,
        FactionSymbol = FactionSymbol, NameColor = NameColor, CreateRedButton = CreateRedButton,
    }

    local post = CreatePanel(pane)
    post:SetPoint("TOPLEFT", pane, "TOPLEFT", 0, 0)
    post:SetPoint("TOPRIGHT", pane, "TOPRIGHT", 0, 0)
    post:SetHeight(288)

    local title = SectionHeader(post, "POST A BOUNTY")
    title:SetPoint("TOPLEFT", post, "TOPLEFT", 12, -10)

    local desc = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 6, -6)
    desc:SetPoint("RIGHT", post, "RIGHT", -14, 0)
    desc:SetJustifyH("CENTER")
    desc:SetText("Place a bounty on the head of your enemies. The cost to post and the reward for claiming are based on the target's rank.")
    desc:SetTextColor(0.82, 0.78, 0.68)
    desc:SetWordWrap(true)

    -- Circular target portrait, framed by the HUD ring; black when empty. Click it
    -- to use your current in-game target.
    local PORT = 54
    local portFrame = CreateFrame("Button", nil, post)
    portFrame:SetSize(PORT, PORT)
    portFrame:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", -2, -14)

    local portBG = portFrame:CreateTexture(nil, "BACKGROUND")
    portBG:SetAllPoints(portFrame)
    portBG:SetColorTexture(0, 0, 0, 1)

    local portrait = portFrame:CreateTexture(nil, "ARTWORK")
    portrait:SetAllPoints(portFrame)

    local portMask = portFrame:CreateMaskTexture()
    portMask:SetAllPoints(portFrame)
    portMask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    portrait:AddMaskTexture(portMask)
    portBG:AddMaskTexture(portMask)

    local portRing = portFrame:CreateTexture(nil, "OVERLAY")
    portRing:SetAtlas("hud-PlayerFrame-portraitring-large", false)
    portRing:SetSize(PORT + 22, PORT + 22)
    portRing:SetPoint("CENTER", portFrame, "CENTER", 0, 0)

    local targetName = post:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    targetName:SetPoint("TOPLEFT", portFrame, "TOPRIGHT", 18, -2)
    targetName:SetText("No target selected")
    targetName:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local targetInfo = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    targetInfo:SetPoint("TOPLEFT", targetName, "BOTTOMLEFT", 0, -4)
    targetInfo:SetText("Level --  Race  Class")
    targetInfo:SetTextColor(0.82, 0.78, 0.68)

    local targetGuild = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    targetGuild:SetPoint("TOPLEFT", targetInfo, "BOTTOMLEFT", 0, -4)
    targetGuild:SetText("<No guild>")
    targetGuild:SetTextColor(0.25, 0.85, 0.25)

    -- Rank line, inline beneath the guild (icon + tier name).
    local rankIcon = post:CreateTexture(nil, "ARTWORK")
    rankIcon:SetSize(16, 16)
    rankIcon:SetPoint("TOPLEFT", targetGuild, "BOTTOMLEFT", 0, -5)
    rankIcon:SetAtlas(FactionSymbol(WB.faction))
    local rankText = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rankText:SetPoint("LEFT", rankIcon, "RIGHT", 5, 0)
    rankText:SetText("Rank: |cffffffff--|r")
    rankText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- "Pick a Player" button (where the rank block used to be): opposite-faction
    -- War Board community members.
    local pickBtn = CreateFrame("Button", nil, post, "BackdropTemplate")
    pickBtn:SetPoint("TOPLEFT", portFrame, "BOTTOMLEFT", -2, -16)
    pickBtn:SetSize(150, 24)
    pickBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    pickBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    pickBtn:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.95)
    pickBtn.label = pickBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    pickBtn.label:SetPoint("CENTER")
    pickBtn.label:SetText("Pick a Player")
    pickBtn.label:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    pickBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    pickBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.95) end)

    -- Cost / reward box: Posting Cost over a divider over Reward; values right-aligned.
    local costBox = CreateFrame("Frame", nil, post, "BackdropTemplate")
    costBox:SetPoint("TOPLEFT", pickBtn, "BOTTOMLEFT", 0, -8)
    costBox:SetSize(216, 54)
    costBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    costBox:SetBackdropColor(0, 0, 0, 0.20)
    costBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.65)

    local costText = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    costText:SetPoint("TOPLEFT", costBox, "TOPLEFT", 8, -8)
    costText:SetText("Posting Cost:")
    costText:SetTextColor(0.82, 0.78, 0.68)
    local costCoin = costBox:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(costCoin, 32)
    costCoin:SetPoint("TOPRIGHT", costBox, "TOPRIGHT", -8, 3)
    local costValue = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    costValue:SetPoint("RIGHT", costCoin, "LEFT", -4, 0)
    costValue:SetText("--")
    costValue:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    local costDivider = costBox:CreateTexture(nil, "ARTWORK")
    costDivider:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.18)
    costDivider:SetHeight(1)
    costDivider:SetPoint("LEFT",  costBox, "LEFT",  8, 0)
    costDivider:SetPoint("RIGHT", costBox, "RIGHT", -8, 0)
    costDivider:SetPoint("TOP",   costBox, "TOP",   0, -28)

    local rewardText = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardText:SetPoint("BOTTOMLEFT", costBox, "BOTTOMLEFT", 8, 8)
    rewardText:SetText("Reward:")
    rewardText:SetTextColor(0.82, 0.78, 0.68)
    local rewardSym = costBox:CreateTexture(nil, "ARTWORK")
    rewardSym:SetAtlas(FactionSymbol(WB.faction))
    rewardSym:SetSize(16, 16)
    rewardSym:SetPoint("BOTTOMRIGHT", costBox, "BOTTOMRIGHT", -8, 7)
    local rewardWC = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardWC:SetPoint("RIGHT", rewardSym, "LEFT", -4, 0)
    rewardWC:SetText("--")
    rewardWC:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
    local rewardCoin = costBox:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(rewardCoin, 32)
    rewardCoin:SetPoint("RIGHT", rewardWC, "LEFT", -10, 0)
    local rewardGold = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardGold:SetPoint("RIGHT", rewardCoin, "LEFT", -4, 0)
    rewardGold:SetText("--")
    rewardGold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    local messageLabel = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    messageLabel:SetPoint("TOPLEFT", post, "TOPLEFT", 244, -62)
    messageLabel:SetText("Message (Optional)")
    messageLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- POST BOUNTY pinned to the bottom; the count sits just above it; the message
    -- box fills everything between the label and the count (top→bottom anchored, so
    -- it's as tall as the space allows regardless of SetHeight).
    local postBtn = CreateRedButton(post, "POST BOUNTY")
    postBtn:SetHeight(26)
    postBtn:SetPoint("BOTTOMLEFT", post, "BOTTOMLEFT", 244, 14)
    postBtn:SetPoint("RIGHT",      post, "RIGHT",      -14, 0)

    local count = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    count:SetPoint("BOTTOMLEFT", postBtn, "TOPLEFT", 0, 6)
    count:SetText("0 / 120")
    count:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local messageBox = CreateFrame("EditBox", nil, post, "BackdropTemplate")
    messageBox:SetPoint("TOPLEFT", messageLabel, "BOTTOMLEFT", 0, -6)
    messageBox:SetPoint("RIGHT",   post,  "RIGHT", -14, 0)
    messageBox:SetPoint("BOTTOM",  count, "TOP",   0, 4)
    messageBox:SetAutoFocus(false)
    messageBox:SetMultiLine(true)
    messageBox:SetMaxLetters(120)
    messageBox:SetFontObject("GameFontNormalSmall")
    messageBox:SetTextInsets(6, 6, 6, 6)
    messageBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    messageBox:SetBackdropColor(0, 0, 0, 0.45)
    messageBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.75)
    messageBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    messageBox:SetScript("OnTabPressed",    function(self) self:ClearFocus() end)
    messageBox:SetScript("OnTextChanged", function(self)
        count:SetText((#(self:GetText() or "")) .. " / 120")
    end)

    local selector = CreatePanel(pane)
    selector:SetFrameLevel(post:GetFrameLevel() + 20)
    selector:SetPoint("TOPLEFT", pickBtn, "BOTTOMLEFT", 0, -2)
    selector:SetSize(260, 300)
    selector:Hide()

    local selectedTarget
    local candidateRows = {}
    local RenderSelector            -- forward declaration (searchBox references it)
    local SEL_ROW_H = 42

    -- Accent/diacritic → plain ASCII, so typing "Woopzy" matches "Wôopzy" (the same
    -- normalization JARVIS uses for its history search).
    local accentMap = {
        ["À"]="a",["Á"]="a",["Â"]="a",["Ã"]="a",["Ä"]="a",["Å"]="a",["Æ"]="ae",["ß"]="b",
        ["Ç"]="c",["È"]="e",["É"]="e",["Ê"]="e",["Ë"]="e",
        ["Ì"]="i",["Í"]="i",["Î"]="i",["Ï"]="i",["Ð"]="d",["Ñ"]="n",
        ["Ò"]="o",["Ó"]="o",["Ô"]="o",["Õ"]="o",["Ö"]="o",["Ø"]="o",
        ["Ù"]="u",["Ú"]="u",["Û"]="u",["Ü"]="u",["Ý"]="y",["Þ"]="th",
        ["à"]="a",["á"]="a",["â"]="a",["ã"]="a",["ä"]="a",["å"]="a",["æ"]="ae",
        ["ç"]="c",["è"]="e",["é"]="e",["ê"]="e",["ë"]="e",
        ["ì"]="i",["í"]="i",["î"]="i",["ï"]="i",["ð"]="d",["ñ"]="n",
        ["ò"]="o",["ó"]="o",["ô"]="o",["õ"]="o",["ö"]="o",["ø"]="o",
        ["ù"]="u",["ú"]="u",["û"]="u",["ü"]="u",["ý"]="y",["þ"]="th",["ÿ"]="y",
    }
    local function NormalizeForSearch(str)
        if not str or str == "" then return "" end
        for accent, plain in pairs(accentMap) do str = str:gsub(accent, plain) end
        return str:lower()
    end

    -- Search box at the top of the picker.
    local searchText = ""
    local searchBox = CreateFrame("EditBox", nil, selector, "BackdropTemplate")
    searchBox:SetPoint("TOPLEFT",  selector, "TOPLEFT",  8, -8)
    searchBox:SetPoint("TOPRIGHT", selector, "TOPRIGHT", -8, -8)
    searchBox:SetHeight(22)
    searchBox:SetAutoFocus(false)
    searchBox:SetFontObject("GameFontHighlightSmall")
    searchBox:SetMaxLetters(50)
    searchBox:SetTextInsets(22, 6, 0, 0)
    searchBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    searchBox:SetBackdropColor(0, 0, 0, 0.5)
    searchBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.85)
    local searchIcon = searchBox:CreateTexture(nil, "OVERLAY")
    searchIcon:SetSize(14, 14)
    searchIcon:SetPoint("LEFT", searchBox, "LEFT", 5, 0)
    searchIcon:SetTexture("Interface\\Common\\UI-Searchbox-Icon")
    searchIcon:SetVertexColor(0.70, 0.65, 0.52)
    local searchHint = searchBox:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
    searchHint:SetPoint("LEFT", searchBox, "LEFT", 22, 0)
    searchHint:SetText("Search players...")
    searchBox:SetScript("OnEscapePressed", function(self) self:SetText(""); self:ClearFocus() end)
    searchBox:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    searchBox:SetScript("OnTextChanged", function(self)
        local t = self:GetText() or ""
        searchHint:SetShown(t == "")
        searchText = t
        if RenderSelector then RenderSelector() end
    end)

    -- Scrollable list below the search box (UIPanelScrollFrameTemplate; scrollbar lives
    -- in the right gutter — do NOT SetClipsChildren, that hides the scrollbar).
    local selScroll = CreateFrame("ScrollFrame", nil, selector, "UIPanelScrollFrameTemplate")
    selScroll:SetPoint("TOPLEFT",     searchBox, "BOTTOMLEFT",  0, -6)
    selScroll:SetPoint("BOTTOMRIGHT", selector,  "BOTTOMRIGHT", -26, 8)
    local selContent = CreateFrame("Frame", nil, selScroll)
    selContent:SetSize(1, 1)
    selScroll:SetScrollChild(selContent)
    selScroll:SetScript("OnSizeChanged", function(self, w) selContent:SetWidth(w) end)
    selScroll:EnableMouseWheel(true)
    selScroll:SetScript("OnMouseWheel", function(self, delta)
        local nv   = self:GetVerticalScroll() - (delta * SEL_ROW_H)
        local maxS = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    local selEmpty = selector:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    selEmpty:SetPoint("TOP", selScroll, "TOP", -8, -16)
    selEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    selEmpty:Hide()

    -- Set the guild line, shrinking the font if it would clip into the message box.
    local function SetGuild(text)
        targetGuild:SetFont("Fonts\\FRIZQT__.TTF", 10, "")
        targetGuild:SetText(text)
        local size = 10
        while size > 7 and targetGuild:GetStringWidth() > 158 do
            size = size - 1
            targetGuild:SetFont("Fonts\\FRIZQT__.TTF", size, "")
        end
    end

    local function SetTarget(entry)
        selectedTarget = entry
        if not entry then
            SetPortrait(portrait, nil)
            targetName:SetText("No target selected")
            targetName:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
            targetInfo:SetText("Level --  Race  Class")
            SetGuild("<No guild>")
            rankIcon:Hide()   -- no rank icon until a target is picked
            rankText:SetText("Rank: |cffffffff--|r")
            costValue:SetText("--")
            rewardGold:SetText("--")
            rewardWC:SetText("--")
            return
        end
        local nc = NameColor(entry.faction)
        local rankName, cost, rgold, rwc = RankInfo(entry.rank, entry.faction)
        SetPortrait(portrait, entry)
        targetName:SetText(entry.name or "?")
        targetName:SetTextColor(nc[1], nc[2], nc[3])
        local className = entry.class
            or (entry.classToken and ((LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[entry.classToken]) or entry.classToken))
            or "Unknown"
        targetInfo:SetText(("Level %s  %s  %s"):format(tostring(entry.level or "--"), entry.race or "Unknown", className))
        SetGuild(entry.guild and ("<" .. entry.guild .. ">") or "<No guild>")
        rankIcon:Show()
        rankIcon:SetAtlas(RankIcon(entry.rank, entry.faction))
        rankText:SetText("Rank: |cffffffff" .. rankName .. "|r")
        costValue:SetText(FmtNumber(cost))
        rewardGold:SetText(FmtNumber(rgold))
        rewardWC:SetText(FmtNumber(rwc))
    end

    -- Opposite-faction War Board players to bounty. Primary source is the synced
    -- roster directory (WB.Roster — reliable, includes offline users who've shared
    -- their profile); supplemented by the live club roster for anyone not yet in it.
    local function GetCommunityTargets()
        local myFaction = WB.faction or UnitFactionGroup("player") or "Horde"
        local enemy = (myFaction == "Alliance") and "Horde" or "Alliance"
        local out, seen = {}, {}

        if WB.Roster and WB.Roster.GetByFaction then
            for _, p in ipairs(WB.Roster.GetByFaction(enemy)) do
                if p.guid and not seen[p.guid] then
                    seen[p.guid] = true
                    out[#out + 1] = {
                        name = p.name, realm = p.realm, level = p.level, race = p.race or p.raceFile, raceFile = p.raceFile,
                        sex = p.sex, class = p.class, classToken = p.classToken, guild = p.guild,
                        faction = p.faction, guid = p.guid, rank = 0,
                    }
                end
            end
        end

        local clubId = WB.Community and WB.Community.CLUB_ID
        if clubId and C_Club and C_Club.GetClubMembers then
            local ok, members = pcall(C_Club.GetClubMembers, clubId)
            if ok and type(members) == "table" then
                local ids = {}
                for i = 1, #members do ids[i] = members[i] end
                for _, mid in ipairs(ids) do
                    local ok2, info = pcall(C_Club.GetMemberInfo, clubId, mid)
                    if ok2 and info and info.name and not info.isSelf and info.guid and not seen[info.guid] then
                        local lclass, eclass, lrace, erace, sex, _pname, prealm = GetPlayerInfoByGUID(info.guid)
                        local raceFile, raceName, faction = erace, lrace, nil
                        if raceFile then faction = RACE_FACTION[raceFile:lower():gsub("[^%a]", "")] end
                        if (not faction) and info.race and C_CreatureInfo then
                            local ri = C_CreatureInfo.GetRaceInfo(info.race)
                            if ri then raceFile, raceName = ri.clientFileString, ri.raceName end
                            local fi = C_CreatureInfo.GetFactionInfo(info.race)
                            if fi then faction = fi.groupTag end
                        end
                        if faction == enemy then
                            seen[info.guid] = true
                            out[#out + 1] = {
                                name = Ambiguate and Ambiguate(info.name, "short") or info.name,
                                realm = (prealm and prealm ~= "" and prealm) or GetNormalizedRealmName(),
                                level = info.level, race = raceName, raceFile = raceFile, sex = sex,
                                class = lclass, classToken = eclass, faction = faction, guid = info.guid, rank = 0,
                            }
                        end
                    end
                end
            end
        end
        table.sort(out, function(a, b) return (a.name or "") < (b.name or "") end)
        return out
    end

    local function CandidateList()
        if WB.Bounties and WB.Bounties.GetCandidates then
            return WB.Bounties.GetCandidates() or {}
        end
        return GetCommunityTargets()
    end

    -- (forward-declared above) Filter the candidate list by the search box, then render
    -- each match as a two-line row (circular race icon + name over Level/Race/Class) in
    -- the scrollable child.
    function RenderSelector()
        for _, row in ipairs(candidateRows) do row:Hide() end

        local all = CandidateList()
        local list, q = all, NormalizeForSearch(searchText)
        if q ~= "" then
            list = {}
            for _, e in ipairs(all) do
                if string.find(NormalizeForSearch(e.name or ""),  q, 1, true)
                or string.find(NormalizeForSearch(e.realm or ""), q, 1, true) then
                    list[#list + 1] = e
                end
            end
        end

        if #list == 0 then
            selEmpty:SetText((searchText ~= "") and "No players match your search."
                or "No opposite-faction targets found.")
            selEmpty:Show()
            selContent:SetHeight(1)
            return
        end
        selEmpty:Hide()

        for i = 1, #list do
            local row = candidateRows[i]
            if not row then
                row = CreateFrame("Button", nil, selContent, "BackdropTemplate")
                row:SetHeight(SEL_ROW_H - 4)
                row:SetPoint("TOPLEFT", selContent, "TOPLEFT", 2, -((i - 1) * SEL_ROW_H) - 2)
                row:SetPoint("RIGHT",   selContent, "RIGHT",   -2, 0)
                row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
                row:SetBackdropColor(1, 1, 1, 0.04)
                row:SetScript("OnEnter", function(self)
                    self:SetBackdropColor(1, 1, 1, 0.12)
                    if self._fullName then
                        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                        GameTooltip:SetText(self._fullName, 1, 0.95, 0.8)
                        GameTooltip:Show()
                    end
                end)
                row:SetScript("OnLeave", function(self)
                    self:SetBackdropColor(1, 1, 1, 0.04)
                    GameTooltip:Hide()
                end)
                -- circular race icon
                row.icon = row:CreateTexture(nil, "ARTWORK")
                row.icon:SetSize(30, 30)
                row.icon:SetPoint("LEFT", row, "LEFT", 4, 0)
                -- name (top line)
                row.text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                row.text:SetPoint("BOTTOMLEFT", row.icon, "RIGHT", 8, 2)
                row.text:SetPoint("RIGHT", row, "RIGHT", -6, 0)
                row.text:SetJustifyH("LEFT")
                -- Level / Race / Class (second line)
                row.sub = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.sub:SetPoint("TOPLEFT", row.icon, "RIGHT", 8, -2)
                row.sub:SetPoint("RIGHT", row, "RIGHT", -6, 0)
                row.sub:SetJustifyH("LEFT")
                row.sub:SetTextColor(0.62, 0.59, 0.52)
                candidateRows[i] = row
            end
            local entry = list[i]
            local nc = NameColor(entry.faction)
            SetPortrait(row.icon, entry)
            row.text:SetText(entry.name or "?")
            row.text:SetTextColor(nc[1], nc[2], nc[3])
            local className = entry.class
                or (entry.classToken and ((LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[entry.classToken]) or entry.classToken))
                or "Unknown"
            row.sub:SetText(("Level %s, %s, %s"):format(tostring(entry.level or "--"), entry.race or "Unknown", className))
            row._fullName = FullPlayerName(entry.name, entry.realm)
            row:SetScript("OnClick", function()
                SetTarget(entry)
                searchBox:SetText("")
                searchBox:ClearFocus()
                selector:Hide()
            end)
            row:Show()
        end
        selContent:SetHeight(math.max(1, (#list * SEL_ROW_H) + 4))
    end

    -- "Pick a Player" opens the community-member list.
    pickBtn:SetScript("OnClick", function()
        if selector:IsShown() then
            selector:Hide()
        else
            RenderSelector()
            selector:Show()
        end
    end)

    postBtn:SetScript("OnClick", function()
        if not selectedTarget then
            print(WB.TAG.." pick a player first.")
            return
        end
        if WB.Bounties and WB.Bounties.Post then
            local _, cost, rgold, rwc = RankInfo(selectedTarget.rank, selectedTarget.faction)
            local posted, reason = WB.Bounties.Post(selectedTarget, messageBox:GetText() or "", cost, rgold, rwc)
            if posted then
                print((WB.TAG.." bounty posted on %s (-%d gold)."):format(selectedTarget.name or "?", cost or 0))
                messageBox:SetText("")
                SetTarget(nil)
            elseif reason == "duplicate" then
                print((WB.TAG.." you already have a bounty on %s."):format(selectedTarget.name or "?"))
            elseif reason == "broke" then
                print((WB.TAG.." not enough gold \226\128\148 a bounty on %s costs %d gold."):format(selectedTarget.name or "?", cost or 0))
            elseif reason == "cooldown" then
                local left = (WB.Bounties.CooldownRemaining and WB.Bounties.CooldownRemaining(selectedTarget.guid)) or 0
                local hrs  = math.max(1, math.ceil(left / 3600))
                print((WB.TAG.." %s was recently claimed \226\128\148 can't be bountied again for ~%dh."):format(selectedTarget.name or "?", hrs))
            elseif reason == "unknownrank" then
                print((WB.TAG.." %s is a rank your War Board doesn't recognize \226\128\148 update to post a bounty on them."):format(selectedTarget.name or "?"))
            else
                print(WB.TAG.." couldn't post that bounty.")
            end
        end
    end)

    local available = CreatePanel(pane)
    available:SetPoint("TOPLEFT", post, "BOTTOMLEFT", 0, -8)
    available:SetPoint("BOTTOMRIGHT", pane, "BOTTOMRIGHT", 0, 0)

    local availTitle = SectionHeader(available, "AVAILABLE BOUNTIES")
    availTitle:SetPoint("TOPLEFT", available, "TOPLEFT", 12, -10)

    local ROW_H = 82
    -- Same scroll setup as the War Effort list (UIPanelScrollFrameTemplate, whose
    -- scrollbar lives in the right gutter). Note: do NOT SetClipsChildren — that
    -- clips the template's scrollbar out of view.
    local scroll = CreateFrame("ScrollFrame", nil, available, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", available, "TOPLEFT", 10, -40)
    scroll:SetPoint("BOTTOMRIGHT", available, "BOTTOMRIGHT", -28, 10)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local nextScroll = self:GetVerticalScroll() - (delta * ROW_H)
        local maxScroll = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nextScroll, maxScroll)))
    end)

    local empty = available:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    empty:SetPoint("CENTER", scroll, "CENTER", 0, 0)
    empty:SetText("No available bounties yet.")
    empty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local bountyRows = {}
    local function AvailableList()
        if WB.Bounties and WB.Bounties.GetAvailable then
            return WB.Bounties.GetAvailable() or {}
        end
        return pane.availableBounties or {}
    end

    local function AgoText(t)
        local d = math.max(0, time() - (t or 0))
        if d < 3600  then return math.floor(d / 60) .. "m ago" end
        if d < 86400 then return math.floor(d / 3600) .. "h ago" end
        return math.floor(d / 86400) .. "d ago"
    end

    local function GetBountyRow(i)
        if bountyRows[i] then return bountyRows[i] end
        local r = CreateFrame("Frame", nil, content, "BackdropTemplate")
        r:SetHeight(ROW_H - 6)
        r:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -((i - 1) * ROW_H))
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -((i - 1) * ROW_H))
        r:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        r:SetBackdropColor(0, 0, 0, 0.18)
        r:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.55)

        local CPORT = 36
        local pframe = CreateFrame("Frame", nil, r)
        pframe:SetSize(CPORT, CPORT)
        pframe:SetPoint("LEFT", r, "LEFT", 8, 0)
        local pbg = pframe:CreateTexture(nil, "BACKGROUND")
        pbg:SetAllPoints(pframe)
        pbg:SetColorTexture(0, 0, 0, 1)
        r.portrait = pframe:CreateTexture(nil, "ARTWORK")
        r.portrait:SetAllPoints(pframe)
        local pmask = pframe:CreateMaskTexture()
        pmask:SetAllPoints(pframe)
        pmask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        r.portrait:AddMaskTexture(pmask)
        pbg:AddMaskTexture(pmask)
        local pring = pframe:CreateTexture(nil, "OVERLAY")
        pring:SetAtlas("hud-PlayerFrame-portraitring-large", false)
        pring:SetSize(CPORT + 16, CPORT + 16)
        pring:SetPoint("CENTER", pframe, "CENTER", 0, 0)

        -- Left: name / level-race-class / guild / rank (rank under the guild),
        -- anchored to the row top so the block sits high and clears the bottom edge.
        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("TOPLEFT", r, "TOPLEFT", 56, -8)
        r.name:SetWidth(136)
        r.name:SetJustifyH("LEFT")
        r.name:SetWordWrap(false)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)
        r.info = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.info:SetPoint("TOPLEFT", r.name, "BOTTOMLEFT", 0, -2)
        r.info:SetWidth(136)
        r.info:SetJustifyH("LEFT")
        r.info:SetWordWrap(false)
        r.info:SetTextColor(0.82, 0.78, 0.68)
        r.guild = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.guild:SetPoint("TOPLEFT", r.info, "BOTTOMLEFT", 0, -2)
        r.guild:SetWidth(136)
        r.guild:SetJustifyH("LEFT")
        r.guild:SetWordWrap(false)
        r.guild:SetTextColor(0.25, 0.85, 0.25)
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(14, 14)
        r.rankIcon:SetPoint("TOPLEFT", r.guild, "BOTTOMLEFT", 0, -3)
        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rank:SetPoint("LEFT", r.rankIcon, "RIGHT", 4, 0)
        r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Middle: "Posted by:" / name / "Posted:" / time, each on its own line so
        -- it stays narrow and clear of the reward column.
        r.postedByLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedByLabel:SetPoint("TOPLEFT", r, "TOPLEFT", 204, -7)
        r.postedByLabel:SetText("Posted by:")
        r.postedByLabel:SetTextColor(0.72, 0.69, 0.60)
        r.poster = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.poster:SetPoint("TOPLEFT", r.postedByLabel, "BOTTOMLEFT", 0, -1)
        r.postedLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedLabel:SetPoint("TOPLEFT", r.poster, "BOTTOMLEFT", 0, -3)
        r.postedLabel:SetText("Posted:")
        r.postedLabel:SetTextColor(0.72, 0.69, 0.60)
        r.postedAgo = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedAgo:SetPoint("TOPLEFT", r.postedLabel, "BOTTOMLEFT", 0, -1)
        r.postedAgo:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

        -- Invisible hover region over the "Posted by" block: when several posters
        -- share a target the box just shows a count, and this tooltip lists them all.
        r.posterHover = CreateFrame("Frame", nil, r)
        r.posterHover:SetPoint("TOPLEFT", r.postedByLabel, "TOPLEFT", -2, 3)
        r.posterHover:SetPoint("BOTTOM", r.postedAgo, "BOTTOM", 0, -3)
        r.posterHover:SetWidth(100)
        r.posterHover:EnableMouse(true)
        r.posterHover:SetScript("OnEnter", function(self)
            local posters = r._posters
            if not posters or #posters == 0 then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine(#posters > 1 and ("Posted by " .. #posters .. " players") or "Posted by")
            for _, p in ipairs(posters) do
                local pc = NameColor(p.faction or WB.faction)
                GameTooltip:AddDoubleLine(FullPlayerName(p.name, p.realm), p.postedAt and AgoText(p.postedAt) or "",
                    pc[1], pc[2], pc[3], 0.65, 0.65, 0.65)
                if p.message then
                    GameTooltip:AddLine("  \"" .. p.message .. "\"", 0.7, 0.7, 0.55, true)
                end
            end
            GameTooltip:Show()
        end)
        r.posterHover:SetScript("OnLeave", function() GameTooltip:Hide() end)

        -- Right: reward (top) + accept (bottom).
        r.rewardLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rewardLabel:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -8)
        r.rewardLabel:SetText("Reward:")
        r.rewardLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
        r.sym = r:CreateTexture(nil, "ARTWORK")
        r.sym:SetSize(14, 14)
        r.sym:SetPoint("TOPRIGHT", r.rewardLabel, "BOTTOMRIGHT", 0, -4)
        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.wc:SetPoint("RIGHT", r.sym, "LEFT", -3, 0)
        r.wc:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        r.coin = r:CreateTexture(nil, "ARTWORK")
        SetWarBoardCoinIcon(r.coin, 30)
        r.coin:SetPoint("RIGHT", r.wc, "LEFT", -6, 0)
        r.gold = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.gold:SetPoint("RIGHT", r.coin, "LEFT", -3, 0)
        r.gold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        r.accept = CreateRedButton(r, "ACCEPT")
        r.accept:SetSize(80, 22)
        r.accept:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -10, 7)
        -- Shown in place of the ACCEPT button once you've taken the bounty up.
        r.acceptedLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.acceptedLabel:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -14, 12)
        r.acceptedLabel:SetText("Accepted")
        r.acceptedLabel:SetTextColor(0.45, 0.85, 0.45)
        r.acceptedLabel:Hide()

        bountyRows[i] = r
        return r
    end

    function pane:RenderBounties()
        local list = AvailableList()
        for _, r in ipairs(bountyRows) do r:Hide() end
        for i, b in ipairs(list) do
            local r = GetBountyRow(i)
            local nc = NameColor(b.targetFaction)
            local rankName = select(1, RankInfo(b.rank, b.targetFaction))
            SetPortrait(r.portrait, { raceFile = b.raceFile, sex = b.sex })
            r.name:SetText(b.targetName or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(b.targetName, b.targetRealm)
            r.info:SetText(("Level %s  %s  %s"):format(tostring(b.targetLevel or "--"), b.targetRace or "Unknown", b.targetClass or "Unknown"))
            EllipsizeText(r.guild, b.targetGuild and ("<" .. b.targetGuild .. ">") or "<No guild>", 136)
            r.rankIcon:SetAtlas(RankIcon(b.rank, b.targetFaction))
            r.rank:SetText("Rank: " .. rankName)
            r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

            -- One poster shows their name; several show a count with the hover list.
            local posters = b.posters or {}
            r._posters = posters
            local np = #posters
            if np > 1 then
                r.poster:SetText("|cffffd200" .. np .. " players|r")
            elseif np == 1 then
                local p = posters[1]
                local pc = NameColor(p.faction or WB.faction)
                r.poster:SetText("|cff" .. ("%02x%02x%02x"):format(pc[1]*255, pc[2]*255, pc[3]*255) .. (p.name or "?") .. "|r")
            else
                r.poster:SetText("--")
            end
            r.postedAgo:SetText(b.postedAt and AgoText(b.postedAt) or "--")

            r.gold:SetText(FmtNumber(b.rewardGold))
            r.wc:SetText(FmtNumber(b.rewardWarCredit))
            r.sym:SetAtlas(FactionSymbol(b.targetFaction))

            local mine     = WB.Bounties and WB.Bounties.IPostedOn and WB.Bounties.IPostedOn(b.targetGuid)
            local accepted = WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(b)
            if mine then
                -- You can't take your own contract — no accept button.
                r.accept:Hide()
                r.acceptedLabel:SetText("Your bounty")
                r.acceptedLabel:SetTextColor(0.85, 0.72, 0.40)
                r.acceptedLabel:Show()
            elseif accepted then
                r.accept:Hide()
                r.acceptedLabel:SetText("Accepted")
                r.acceptedLabel:SetTextColor(0.45, 0.85, 0.45)
                r.acceptedLabel:Show()
            else
                r.accept:Show()
                r.acceptedLabel:Hide()
            end
            r.accept:SetScript("OnClick", function()
                if WB.Bounties and WB.Bounties.Accept then
                    local ok, reason = WB.Bounties.Accept(b)
                    if not ok and reason == "limit" then
                        print((WB.TAG.." you can only have %d active bounties \226\128\148 abandon one first.")
                            :format((WB.Bounties.MaxActive and WB.Bounties.MaxActive()) or 3))
                    elseif not ok and reason == "ownbounty" then
                        print(WB.TAG.." you can't accept a bounty you placed.")
                    end
                end
            end)
            r:Show()
        end
        content:SetHeight(math.max(1, #list * ROW_H))
        empty:SetShown(#list == 0)
    end

    function WB.UI.SetBountyCandidates(list)
        pane.bountyCandidates = list or {}
        if selector:IsShown() then RenderSelector() end
    end
    function WB.UI.SetAvailableBounties(list)
        pane.availableBounties = list or {}
        pane:RenderBounties()
    end
    WB.UI.bountiesPane = pane
    WB.UI.RefreshBounties = function() pane:RenderBounties() end
    -- Called by the roster when a new profile arrives, so an open picker refreshes.
    WB.UI.RefreshBountyPicker = function() if selector:IsShown() then RenderSelector() end end

    SetTarget(nil)
    pane:RenderBounties()
end

-- Builds the Alerts pane: a military-style intelligence feed. Each row shows a
-- coloured category/status chip, a headline, a detail line, and a "Last Seen"
-- stamp. Data comes from WB.Alerts; re-render via WB.UI.RefreshAlerts().
local function BuildAlertsPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    -- Title
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -2)
    title:SetText("INTELLIGENCE FEED")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -26)
    hLine:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -26)

    -- Scroll area (bare scroll, mouse-wheel only).
    local ROW_H  = 76
    local scroll = CreateFrame("ScrollFrame", nil, pane)
    scroll:SetPoint("TOPLEFT",     hLine, "BOTTOMLEFT",  0, -4)
    scroll:SetPoint("TOPRIGHT",    hLine, "BOTTOMRIGHT", 0, -4)
    scroll:SetPoint("BOTTOM",      pane,  "BOTTOM",      0, 6)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local nv = self:GetVerticalScroll() - delta * ROW_H
        self:SetVerticalScroll(math.max(0, math.min(nv, maxScroll)))
    end)

    local emptyFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    emptyFS:SetPoint("TOP", scroll, "TOP", 0, -16)
    emptyFS:SetText("No active intel. The front is quiet.")
    emptyFS:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    emptyFS:Hide()

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, content)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        -- Parchment background — each alert reads like a torn intel note.
        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetPoint("TOPLEFT", r, "TOPLEFT", 0, -2)
        bg:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", 0, 4)
        bg:SetAtlas("TalkingHeads-Neutral-TextBackground")

        -- Left accent bar (category colour), inset so it sits on the paper.
        r.accent = r:CreateTexture(nil, "ARTWORK")
        r.accent:SetPoint("TOPLEFT",    r, "TOPLEFT", 8, -16)
        r.accent:SetPoint("BOTTOMLEFT", r, "BOTTOMLEFT", 8, 18)
        r.accent:SetWidth(3)

        -- Status / category chip — a small bevelled plate (dark fill, tan border).
        r.chip = CreateFrame("Frame", nil, r, "BackdropTemplate")
        r.chip:SetPoint("TOPLEFT", r, "TOPLEFT", 12, -14)
        r.chip:SetHeight(19)
        r.chip:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 10,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        r.chipText = r.chip:CreateFontString(nil, "OVERLAY")
        r.chipText:SetPoint("CENTER", r.chip, "CENTER", 0, 0)
        r.chipText:SetFont(STANDARD_TEXT_FONT, 11, "OUTLINE")
        r.chipText:SetTextColor(1, 1, 1)

        -- Headline (decorative serif, auto-width so a divider can follow it).
        r.title = r:CreateFontString(nil, "OVERLAY")
        r.title:SetFont("Fonts\\MORPHEUS.TTF", 12, "")
        r.title:SetPoint("LEFT", r.chip, "RIGHT", 9, 1)
        r.title:SetWordWrap(false)
        r.title:SetJustifyH("LEFT")

        -- "Last Seen" stamp (top-right).
        r.time = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.time:SetPoint("TOPRIGHT", r, "TOPRIGHT", -18, -16)
        r.time:SetJustifyH("RIGHT")
        r.time:SetTextColor(0.40, 0.28, 0.14)  -- dark, for the parchment

        -- Decorative divider filling the gap between the headline and the stamp.
        r.divider = r:CreateTexture(nil, "ARTWORK")
        r.divider:SetColorTexture(0.50, 0.38, 0.18, 0.55)
        r.divider:SetHeight(2)
        r.divider:SetPoint("LEFT",  r.title, "RIGHT", 12, 0)
        r.divider:SetPoint("RIGHT", r.time,  "LEFT", -14, 0)

        -- Detail line (wraps to 2 lines, with a little breathing room between them).
        r.body = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.body:SetPoint("TOPLEFT",  r, "TOPLEFT", 14, -36)
        r.body:SetPoint("RIGHT",    r, "RIGHT",  -10, 0)
        r.body:SetJustifyH("LEFT")
        r.body:SetWordWrap(true)
        r.body:SetSpacing(4)
        if r.body.SetMaxLines then r.body:SetMaxLines(2) end
        r.body:SetTextColor(0.26, 0.17, 0.07)  -- dark, for the parchment

        -- Hover recap: full headline, then who reported it + which shard.
        r:EnableMouse(true)
        r:SetScript("OnEnter", function(self)
            local al, d = self._alert, self._desc
            if not al or not d then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(d.title or "Alert", 1, 0.93, 0.78)
            if al.kind == "assault" then
                GameTooltip:AddLine("Reported by: " .. tostring(al.attacker or "Unknown"), 0.85, 0.85, 0.85)
                GameTooltip:AddLine("Shard: " .. (al.shardId and tostring(al.shardId) or "?"), 0.85, 0.85, 0.85)
                if al.place then
                    GameTooltip:AddLine(al.place .. (al.zone and (", " .. al.zone) or ""), 0.65, 0.65, 0.65)
                end
            elseif d.body and d.body ~= "" then
                GameTooltip:AddLine(d.body, 0.80, 0.80, 0.80, true)
            end
            GameTooltip:AddLine(d.timeText or "", 0.55, 0.55, 0.55)
            GameTooltip:Show()
        end)
        r:SetScript("OnLeave", function() GameTooltip:Hide() end)

        rows[i] = r
        return r
    end

    function pane:RenderAlerts()
        local list = (WB.Alerts and WB.Alerts.All and WB.Alerts.All()) or {}
        for _, r in ipairs(rows) do r:Hide() end
        for i, al in ipairs(list) do
            local r = GetRow(i)
            local d = WB.Alerts.Describe(al)
            r._alert, r._desc = al, d
            r.accent:SetColorTexture(d.accent[1], d.accent[2], d.accent[3], 0.95)
            r.chipText:SetText(d.tag)
            r.chip:SetWidth((r.chipText:GetStringWidth() or 20) + 18)
            r.chip:SetBackdropColor(d.chipFill[1], d.chipFill[2], d.chipFill[3], 0.96)
            r.chip:SetBackdropBorderColor(d.chipBorder[1], d.chipBorder[2], d.chipBorder[3], 1)
            r.title:SetText(d.title or "")
            r.title:SetTextColor(d.titleColor[1], d.titleColor[2], d.titleColor[3])
            r.body:SetText(d.body or "")
            r.time:SetText(d.timeText or "")
            r:Show()
        end
        content:SetHeight(math.max(1, #list * ROW_H))
        emptyFS:SetShown(#list == 0)
    end

    WB.UI.alertsPane    = pane
    WB.UI.RefreshAlerts = function() if pane.RenderAlerts then pane:RenderAlerts() end end
    pane:RenderAlerts()
end

-- MIDDLE: one panel with pill tabs + a switchable body.
local function BuildMiddleColumn(col)
    local panel = CreatePanel(col)
    panel:SetAllPoints(col)

    local TABS  = { "Leader Board", "Activity", "Bounties", "Alerts" }
    local pills = {}
    local panes = {}

    local body = CreateFrame("Frame", nil, panel)
    body:SetPoint("TOPLEFT",     panel, "TOPLEFT",     12, -52)
    body:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -12, 12)

    local function SelectTab(index)
        for i, pill in ipairs(pills) do
            pill:SetSelected(i == index)
            panes[i]:SetShown(i == index)
        end
        WB.UI.activeTab = index
    end

    local function CreatePill(text)
        local b = CreateFrame("Button", nil, panel, "BackdropTemplate")
        b:SetSize(96, 28)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        local label = b:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("CENTER")
        label:SetText(text)
        b.label = label

        function b:SetSelected(sel)
            if sel then
                self:SetBackdropColor(ACCENT[1], ACCENT[2], ACCENT[3], 0.92)
                self:SetBackdropBorderColor(0.90, 0.55, 0.32, 1)
                self.label:SetTextColor(1, 0.92, 0.72)
            else
                self:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
                self:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85)
                self.label:SetTextColor(0.75, 0.72, 0.62)
            end
        end
        b:SetScript("OnEnter", function(self)
            if WB.UI.activeTab and pills[WB.UI.activeTab] == self then return end
            self:SetBackdropBorderColor(0.70, 0.55, 0.32, 1)
        end)
        b:SetScript("OnLeave", function(self)
            self:SetSelected(WB.UI.activeTab and pills[WB.UI.activeTab] == self)
        end)
        return b
    end

    local pillW, pillGap = 96, 8
    for i, tabName in ipairs(TABS) do
        local pill = CreatePill(tabName)
        pill:SetPoint("TOPLEFT", panel, "TOPLEFT", 14 + (i - 1) * (pillW + pillGap), -12)
        pill:SetScript("OnClick", function() SelectTab(i) end)
        pills[i] = pill

        local pane = CreateFrame("Frame", nil, body)
        pane:SetAllPoints(body)
        pane:Hide()
        local placeholder = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        placeholder:SetPoint("CENTER")
        placeholder:SetText(tabName)
        placeholder:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
        pane.placeholder = placeholder
        panes[i] = pane

        if tabName == "Leader Board" then
            BuildLeaderboardPane(pane)
        elseif tabName == "Activity" then
            BuildActivityPane(pane)
        elseif tabName == "Bounties" then
            BuildBountiesPane(pane)
        elseif tabName == "Alerts" then
            BuildAlertsPane(pane)
        end
    end

    WB.UI.tabs  = pills
    WB.UI.panes = panes
    SelectTab(1)  -- default to Leader Board
end

-- RIGHT: War Effort Progress (top) + Active Bounties (below).
local function BuildRightColumn(col)
    -- Campaign Status (top) + War Contribution (below) share this one panel.
    local progress = CreatePanel(col)
    progress:SetPoint("TOPLEFT",  col, "TOPLEFT",  0, 0)
    progress:SetPoint("TOPRIGHT", col, "TOPRIGHT", 0, 0)
    progress:SetHeight(340)
    progress:SetClipsChildren(true)
    WB.UI.warEffortPanel = progress   -- the shard pill aligns itself to this block

    -- ── Campaign Status ───────────────────────────────────────────────────────
    local cHeader = SectionHeader(progress, "Campaign Status")
    cHeader:SetPoint("TOPLEFT", progress, "TOPLEFT", 12, -12)

    local cDiv = HLine(progress)
    cDiv:SetPoint("TOP",   cHeader,  "BOTTOM", 0, -6)
    cDiv:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    cDiv:SetPoint("RIGHT", progress, "RIGHT", -12, 0)

    local wfLabel = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    wfLabel:SetPoint("TOP",   cDiv,     "BOTTOM", 0, -8)
    wfLabel:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    wfLabel:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    wfLabel:SetJustifyH("CENTER")
    wfLabel:SetText("Active Warfront:")
    wfLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- Active-warfront title card: zone art + name (word-wrapped, centered). The
    -- hover explains the +50% reward bonus and the daily random rotation.
    local card = CreateFrame("Frame", nil, progress, "BackdropTemplate")
    card:SetPoint("TOP",   wfLabel,  "BOTTOM", 0, -6)
    card:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    card:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    card:SetHeight(58)
    card:EnableMouse(true)
    card:SetBackdrop({
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    card:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)

    -- Zone art on the ARTWORK layer (BACKGROUND textures can lose draw order to the
    -- BackdropTemplate after reloads — the objective cards hit the same gotcha).
    local cardArt = card:CreateTexture(nil, "ARTWORK")
    cardArt:SetDrawLayer("ARTWORK", -8)
    cardArt:SetPoint("TOPLEFT",     card, "TOPLEFT",      3, -3)
    cardArt:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -3,  3)
    cardArt:SetTexCoord(0.04, 0.96, 0.12, 0.78)

    local cardShade = card:CreateTexture(nil, "ARTWORK")
    cardShade:SetDrawLayer("ARTWORK", -7)
    cardShade:SetPoint("TOPLEFT",     cardArt, "TOPLEFT",     0, 0)
    cardShade:SetPoint("BOTTOMRIGHT", cardArt, "BOTTOMRIGHT", 0, 0)
    cardShade:SetColorTexture(0, 0, 0, 0.42)

    local cardName = card:CreateFontString(nil, "OVERLAY")
    cardName:SetFont("Fonts\\FRIZQT__.TTF", 17, "OUTLINE")
    cardName:SetPoint("CENTER", card, "CENTER", 0, 0)
    cardName:SetWidth(138)
    cardName:SetJustifyH("CENTER")
    cardName:SetWordWrap(true)
    cardName:SetText("--")
    cardName:SetTextColor(0.50, 0.93, 0.50)

    card:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("Active Warfront", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine((WB.Campaign and WB.Campaign.ActiveWarfront()) or "Unknown", 0.50, 0.93, 0.50)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("War Effort rewards in this zone are increased by |cff55ff5550%|r today.", 0.85, 0.85, 0.85, true)
        GameTooltip:AddLine("The active warfront changes every day at random.", 0.75, 0.75, 0.75, true)
        -- Last fully-locked campaign result, if any.
        local last = WB.Campaign and WB.Campaign.LastResult and WB.Campaign.LastResult()
        if last then
            GameTooltip:AddLine(" ")
            local txt
            if last.winner == "Horde" or last.winner == "Alliance" then
                txt = "Last campaign: " .. last.winner .. " victory"
            elseif last.winner == "Tie" then
                txt = "Last campaign: stalemate"
            else
                txt = "Last campaign: no contest"
            end
            GameTooltip:AddLine(txt, GOLD[1], GOLD[2], GOLD[3])
            GameTooltip:AddLine(("Horde %d WC  ·  Alliance %d WC"):format(last.horde or 0, last.alliance or 0), 0.7, 0.7, 0.7)
        end
        GameTooltip:Show()
    end)
    card:SetScript("OnLeave", function() GameTooltip:Hide() end)
    -- Re-assert the art/zone whenever the panel reopens (draw-order safety).
    card:SetScript("OnShow", function() if WB.UI.RefreshCampaign then WB.UI.RefreshCampaign() end end)

    -- ── War Contribution ──────────────────────────────────────────────────────
    -- Manual (non-SectionHeader) so it can sit below the campaign card rather than
    -- pinned to the panel's top-right corner.
    local pHeader = progress:CreateFontString(nil, "OVERLAY")
    pHeader:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
    pHeader:SetText("War Contribution")
    pHeader:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    pHeader:SetJustifyH("CENTER")
    pHeader:SetPoint("TOP",   card,     "BOTTOM", 0, -14)
    pHeader:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    pHeader:SetPoint("RIGHT", progress, "RIGHT", -12, 0)

    -- Tug-of-war bar: Horde (red) pushes from the left, Alliance (blue) from the
    -- right. The faction emblems sit centered on the bar endpoints, so the bar is
    -- inset enough that those emblems stay inside the War Contribution panel.
    local EMBLEM_SIZE = 58
    local BAR_END_INSET = 20
    local barBg = progress:CreateTexture(nil, "BACKGROUND")
    barBg:SetColorTexture(0.07, 0.06, 0.05, 1)
    barBg:SetHeight(20)
    barBg:SetPoint("TOPLEFT",  pHeader, "BOTTOMLEFT",   BAR_END_INSET, -24)
    barBg:SetPoint("TOPRIGHT", pHeader, "BOTTOMRIGHT", -BAR_END_INSET, -24)

    -- Faction emblems overlap the bar's ends (Horde left, Alliance right).
    local hEmblem = progress:CreateTexture(nil, "OVERLAY", nil, 2)
    hEmblem:SetAtlas("HordeEmblem")
    hEmblem:SetSize(EMBLEM_SIZE, EMBLEM_SIZE)
    hEmblem:SetPoint("CENTER", barBg, "LEFT", 0, 0)
    local aEmblem = progress:CreateTexture(nil, "OVERLAY", nil, 2)
    aEmblem:SetAtlas("AllianceEmblem")
    aEmblem:SetSize(EMBLEM_SIZE, EMBLEM_SIZE)
    aEmblem:SetPoint("CENTER", barBg, "RIGHT", 0, 0)

    local hordeFill = progress:CreateTexture(nil, "ARTWORK")
    hordeFill:SetAtlas("_pvpqueue-conquestbar-fill-red")
    hordeFill:SetPoint("TOPLEFT",    barBg, "TOPLEFT",    0, 0)
    hordeFill:SetPoint("BOTTOMLEFT", barBg, "BOTTOMLEFT", 0, 0)
    hordeFill:SetWidth(1)

    local allianceFill = progress:CreateTexture(nil, "ARTWORK")
    allianceFill:SetAtlas("_pvpqueue-conquestbar-fill-blue")
    allianceFill:SetPoint("TOPRIGHT",    barBg, "TOPRIGHT",    0, 0)
    allianceFill:SetPoint("BOTTOMRIGHT", barBg, "BOTTOMRIGHT", 0, 0)
    allianceFill:SetWidth(1)

    local divider = progress:CreateTexture(nil, "OVERLAY")
    divider:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    divider:SetWidth(2)
    divider:SetPoint("TOP",    hordeFill, "TOPRIGHT",    0,  1)
    divider:SetPoint("BOTTOM", hordeFill, "BOTTOMRIGHT", 0, -1)

    -- War credit contributions below the bar: Horde (left), Alliance (right).
    local hVal = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hVal:SetPoint("TOPLEFT", barBg, "BOTTOMLEFT", 0, -22)
    hVal:SetText("0 WC")
    hVal:SetTextColor(FACTION_NAME_COLOR.Horde[1], FACTION_NAME_COLOR.Horde[2], FACTION_NAME_COLOR.Horde[3])
    local aVal = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    aVal:SetPoint("TOPRIGHT", barBg, "BOTTOMRIGHT", 0, -22)
    aVal:SetJustifyH("RIGHT")
    aVal:SetText("0 WC")
    aVal:SetTextColor(FACTION_NAME_COLOR.Alliance[1], FACTION_NAME_COLOR.Alliance[2], FACTION_NAME_COLOR.Alliance[3])

    -- Description below the bar + contributions (full panel width).
    local pDesc = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    pDesc:SetPoint("TOP",   hVal,     "BOTTOM", 0, -8)
    pDesc:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    pDesc:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    pDesc:SetJustifyH("CENTER")
    pDesc:SetText("Earn War Credit to push the front in your faction's favor. The faction with the most War Credit at campaign's end wins.")
    pDesc:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])

    WB.UI.warEffortProgress = {
        barBg = barBg, hordeFill = hordeFill, allianceFill = allianceFill,
        divider = divider, hordeVal = hVal, allianceVal = aVal, desc = pDesc,
    }

    -- Campaign countdown beneath the description. The label/icon/value flow inside a
    -- container that's sized to fit (in RefreshCampaign) and centered as one unit, so
    -- the long label never overhangs the panel edge.
    local endsRow = CreateFrame("Frame", nil, progress)
    endsRow:SetPoint("TOP", pDesc, "BOTTOM", 0, -10)
    endsRow:SetSize(190, 18)
    local endsLabel = endsRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    endsLabel:SetPoint("LEFT", endsRow, "LEFT", 0, 0)
    endsLabel:SetText("Campaign ends in:")
    endsLabel:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])
    local endsIcon = endsRow:CreateTexture(nil, "OVERLAY")
    endsIcon:SetAtlas("unitframeicon-chromietime")
    endsIcon:SetSize(18, 18)
    endsIcon:SetPoint("LEFT", endsLabel, "RIGHT", 4, 0)
    local endsValue = endsRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    endsValue:SetPoint("LEFT", endsIcon, "RIGHT", 3, 0)
    endsValue:SetText("--")
    endsValue:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    WB.UI.campaign = { card = card, art = cardArt, name = cardName,
                       endsValue = endsValue, endsRow = endsRow, endsLabel = endsLabel }

    -- "View Campaign Results" → opens the results-history popup.
    local resultsBtn = CreateFrame("Button", nil, progress, "BackdropTemplate")
    resultsBtn:SetHeight(24)
    resultsBtn:SetPoint("TOP",   endsRow,  "BOTTOM", 0, -10)
    resultsBtn:SetPoint("LEFT",  progress, "LEFT",   34, 0)
    resultsBtn:SetPoint("RIGHT", progress, "RIGHT", -34, 0)
    resultsBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    resultsBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    resultsBtn:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
    local rbLabel = resultsBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rbLabel:SetPoint("CENTER", resultsBtn, "CENTER", 0, 0)
    rbLabel:SetText("View Campaign Results")
    rbLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    resultsBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    resultsBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95) end)
    resultsBtn:SetScript("OnClick", function()
        if WB.UI.ShowCampaignResults then WB.UI.ShowCampaignResults() end
    end)

    -- Active Bounties (bounties you've personally taken up)
    local bounties = CreatePanel(col)
    bounties:SetPoint("TOPLEFT",  progress, "BOTTOMLEFT",  0, -GAP)
    bounties:SetPoint("TOPRIGHT", progress, "BOTTOMRIGHT", 0, -GAP)
    bounties:SetPoint("BOTTOM",   col,      "BOTTOM",      0, 0)

    local bHeader = SectionHeader(bounties, "Active Bounties")
    bHeader:SetPoint("TOPLEFT", bounties, "TOPLEFT", 12, -12)

    -- Active count "X / 3" beside the header.
    local bCount = bounties:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bCount:SetPoint("TOPRIGHT", bounties, "TOPRIGHT", -14, -16)
    bCount:SetJustifyH("RIGHT")
    bCount:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local bEmpty = bounties:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bEmpty:SetPoint("TOPLEFT", bHeader, "BOTTOMLEFT", 0, -10)
    bEmpty:SetPoint("RIGHT",   bounties, "RIGHT",    -12, 0)
    bEmpty:SetJustifyH("CENTER")
    bEmpty:SetText("No bounties taken up yet.")
    bEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    -- Scrollable stack of detailed bounty cards (mouse-wheel only).
    local CARD_H = 176
    local scroll = CreateFrame("ScrollFrame", nil, bounties)
    scroll:SetPoint("TOPLEFT",  bounties, "TOPLEFT",   8, -40)
    scroll:SetPoint("TOPRIGHT", bounties, "TOPRIGHT", -8, -40)
    scroll:SetPoint("BOTTOM",   bounties, "BOTTOM",    0,  8)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local abContent = CreateFrame("Frame", nil, scroll)
    abContent:SetSize(1, 1)
    scroll:SetScrollChild(abContent)
    scroll:SetScript("OnSizeChanged", function(self, w) abContent:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxS = self:GetVerticalScrollRange() or 0
        local nv   = self:GetVerticalScroll() - delta * 40
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    local abCards = {}
    local function GetCard(i)
        if abCards[i] then return abCards[i] end
        local H = WB.UI.bountyHelpers or {}
        local r = CreateFrame("Frame", nil, abContent, "BackdropTemplate")
        r:SetHeight(CARD_H - 8)
        r:SetPoint("TOPLEFT",  abContent, "TOPLEFT",  0, -((i - 1) * CARD_H))
        r:SetPoint("TOPRIGHT", abContent, "TOPRIGHT", 0, -((i - 1) * CARD_H))
        r:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1 })
        r:SetBackdropColor(0, 0, 0, 0.22)
        r:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.55)

        -- "Warm bucket" eye (top-right): opens the nearby-allies popup for this target.
        r.eyeBtn = CreateFrame("Button", nil, r)
        r.eyeBtn:SetSize(20, 20)
        r.eyeBtn:SetPoint("TOPRIGHT", r, "TOPRIGHT", -7, -7)
        r.eyeTex = r.eyeBtn:CreateTexture(nil, "ARTWORK")
        r.eyeTex:SetAllPoints()
        r.eyeTex:SetAtlas("groupfinder-eye-frame")
        r.eyeTex:SetVertexColor(0.82, 0.82, 0.85)
        r.eyeBtn:SetScript("OnEnter", function(self)
            r.eyeTex:SetVertexColor(1, 1, 1)
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            GameTooltip:SetText("Nearby Allies", 1, 0.95, 0.8)
            GameTooltip:AddLine("View allies recently seen near this bounty target on their shard.", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("Right-click an entry to whisper or request an invite.", 0.6, 0.6, 0.6, true)
            GameTooltip:Show()
        end)
        r.eyeBtn:SetScript("OnLeave", function()
            r.eyeTex:SetVertexColor(0.82, 0.82, 0.85)
            GameTooltip:Hide()
        end)

        -- Circular race portrait.
        local CPORT = 46
        local pframe = CreateFrame("Frame", nil, r)
        pframe:SetSize(CPORT, CPORT)
        pframe:SetPoint("TOPLEFT", r, "TOPLEFT", 10, -10)
        local pbg = pframe:CreateTexture(nil, "BACKGROUND")
        pbg:SetAllPoints(pframe); pbg:SetColorTexture(0, 0, 0, 1)
        r.portrait = pframe:CreateTexture(nil, "ARTWORK")
        r.portrait:SetAllPoints(pframe)
        local pmask = pframe:CreateMaskTexture()
        pmask:SetAllPoints(pframe)
        pmask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        r.portrait:AddMaskTexture(pmask); pbg:AddMaskTexture(pmask)
        local pring = pframe:CreateTexture(nil, "OVERLAY")
        pring:SetAtlas("hud-PlayerFrame-portraitring-large", false)
        pring:SetSize(CPORT + 14, CPORT + 14)
        pring:SetPoint("CENTER", pframe, "CENTER", 0, 0)

        -- Name / level-race-class / guild / rank (right of the portrait).
        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("TOPLEFT", pframe, "TOPRIGHT", 8, -1)
        r.name:SetPoint("RIGHT",   r.eyeBtn, "LEFT", -4, 0)
        r.name:SetJustifyH("LEFT"); r.name:SetWordWrap(false)
        r.nameHit = MakeNameHover(r); r.nameHit:SetAllPoints(r.name)
        r.info = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.info:SetPoint("TOPLEFT", r.name, "BOTTOMLEFT", 0, -2)
        r.info:SetPoint("RIGHT",   r, "RIGHT", -8, 0)
        r.info:SetJustifyH("LEFT"); r.info:SetWordWrap(false)
        r.info:SetTextColor(0.82, 0.78, 0.68)
        r.guild = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.guild:SetPoint("TOPLEFT", r.info, "BOTTOMLEFT", 0, -2)
        r.guild:SetPoint("RIGHT",   r, "RIGHT", -8, 0)
        r.guild:SetJustifyH("LEFT"); r.guild:SetWordWrap(false)
        r.guild:SetTextColor(0.25, 0.85, 0.25)
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(14, 14)
        r.rankIcon:SetPoint("TOPLEFT", r.guild, "BOTTOMLEFT", 0, -3)
        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rank:SetPoint("LEFT", r.rankIcon, "RIGHT", 4, 0)
        r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Divider, then label/value rows.
        local div = HLine(r)
        div:SetPoint("TOPLEFT",  r, "TOPLEFT", 10, -74)
        div:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -74)

        local function LabelRow(y, text)
            local lab = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            lab:SetPoint("TOPLEFT", r, "TOPLEFT", 10, y)
            lab:SetText(text)
            lab:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            local val = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            val:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, y)
            val:SetJustifyH("RIGHT"); val:SetWordWrap(false)
            val:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
            return lab, val
        end
        r.locLabel, r.locVal = LabelRow(-82, "LOCATION:")
        r.locSub = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.locSub:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -95)
        r.locSub:SetJustifyH("RIGHT"); r.locSub:SetWordWrap(false)
        r.locSub:SetTextColor(0.66, 0.62, 0.54)
        r.shardLabel, r.shardVal   = LabelRow(-107, "SHARD:")
        r.statusLabel, r.statusVal = LabelRow(-122, "STATUS:")
        r.statusDot = r:CreateTexture(nil, "ARTWORK")
        r.statusDot:SetSize(11, 11)
        r.statusDot:SetPoint("RIGHT", r.statusVal, "LEFT", -5, 0)

        -- TRACK TARGET / ABANDON buttons.
        r.trackBtn = H.CreateRedButton and H.CreateRedButton(r, "TRACK")
        if r.trackBtn then
            r.trackBtn:SetHeight(22)
            r.trackBtn.label:SetFont(STANDARD_TEXT_FONT, 11, "")
            r.trackBtn:SetPoint("BOTTOMLEFT",  r, "BOTTOMLEFT", 8, 8)
            r.trackBtn:SetPoint("BOTTOMRIGHT", r, "BOTTOM",    -3, 8)
        end
        r.abandonBtn = H.CreateRedButton and H.CreateRedButton(r, "ABANDON")
        if r.abandonBtn then
            r.abandonBtn:SetHeight(22)
            r.abandonBtn.label:SetFont(STANDARD_TEXT_FONT, 11, "")
            r.abandonBtn:SetBackdropColor(0.14, 0.13, 0.12, 0.94)
            r.abandonBtn:SetBackdropBorderColor(0.45, 0.40, 0.30, 0.9)
            r.abandonBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.72, 0.62, 0.40, 1) end)
            r.abandonBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.45, 0.40, 0.30, 0.9) end)
            r.abandonBtn:SetPoint("BOTTOMLEFT",  r, "BOTTOM",       3, 8)
            r.abandonBtn:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -8, 8)
        end

        abCards[i] = r
        return r
    end

    function WB.UI.RefreshActiveBounties()
        local list = (WB.Bounties and WB.Bounties.GetActive and WB.Bounties.GetActive()) or {}
        local H = WB.UI.bountyHelpers or {}
        for _, r in ipairs(abCards) do r:Hide() end
        for i, b in ipairs(list) do
            local r  = GetCard(i)
            local nc = (H.NameColor and H.NameColor(b.targetFaction)) or FACTION_NAME_COLOR[b.targetFaction] or VALUE_COLOR
            if H.SetPortrait then H.SetPortrait(r.portrait, { raceFile = b.raceFile, sex = b.sex }) end
            r.name:SetText(b.targetName or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(b.targetName, b.targetRealm)
            r.info:SetText(("Lvl %s  %s  %s"):format(tostring(b.targetLevel or "--"), b.targetRace or "Unknown", b.targetClass or "Unknown"))
            r.guild:SetText(b.targetGuild and ("<" .. b.targetGuild .. ">") or "<No guild>")
            local rankName = (H.RankInfo and select(1, H.RankInfo(b.rank, b.targetFaction))) or "No Rank"
            if H.RankIcon then r.rankIcon:SetAtlas(H.RankIcon(b.rank, b.targetFaction)) end
            r.rank:SetText("Rank: " .. rankName)

            -- Live tracking state.
            local info  = WB.BountyTracker and WB.BountyTracker.GetTargetInfo and WB.BountyTracker.GetTargetInfo(b.targetGuid)
            local state = (WB.BountyTracker and WB.BountyTracker.GetTrackState and WB.BountyTracker.GetTrackState(b.targetGuid)) or "idle"
            if state == "failed" then
                r.locVal:SetText("Failed to obtain."); r.locSub:SetText("")
            elseif info and info.zone then
                r.locVal:SetText(info.zone)
                r.locSub:SetText((info.subZone and info.subZone ~= "") and info.subZone or "")
            elseif state == "trying" then
                r.locVal:SetText("Locating…"); r.locSub:SetText("")
            else
                r.locVal:SetText("Unknown");   r.locSub:SetText("")
            end
            -- War Mode flag to the left of the shard number (only when the target has
            -- reported a position, i.e. info.warMode is known).
            local shardTxt = (info and info.shard) and tostring(info.shard) or "--"
            if info and info.warMode ~= nil then
                local wm = info.warMode and "|cff58d65cWM On|r  " or "|cffd6585cWM Off|r  "
                r.shardVal:SetText(wm .. shardTxt)
            else
                r.shardVal:SetText(shardTxt)
            end
            if info and info.online then
                r.statusDot:SetTexture("Interface\\COMMON\\Indicator-Green")
                r.statusVal:SetText("Online");  r.statusVal:SetTextColor(0.40, 0.90, 0.45)
            else
                r.statusDot:SetTexture("Interface\\COMMON\\Indicator-Red")
                r.statusVal:SetText("Offline"); r.statusVal:SetTextColor(0.85, 0.45, 0.45)
            end

            if r.trackBtn then
                r.trackBtn.label:SetText((state == "tracking" or state == "trying") and "TRACKING" or "TRACK")
                r.trackBtn:SetScript("OnClick", function()
                    -- Tracking is persisted once the handshake actually succeeds (OnLocation).
                    if WB.BountyTracker and WB.BountyTracker.StartHunting then WB.BountyTracker.StartHunting(b) end
                    if WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
                end)
            end
            if r.abandonBtn then
                r.abandonBtn:SetScript("OnClick", function()
                    if WB.Bounties and WB.Bounties.Abandon then WB.Bounties.Abandon(b) end
                end)
            end
            if r.eyeBtn then
                r.eyeBtn:SetScript("OnClick", function()
                    if WB.UI.ShowSightings then WB.UI.ShowSightings(b) end
                end)
            end
            r:Show()
        end
        abContent:SetHeight(math.max(1, #list * CARD_H))
        bEmpty:SetShown(#list == 0)
        bCount:SetText(("%d / %d"):format(#list, (WB.Bounties and WB.Bounties.MaxActive and WB.Bounties.MaxActive()) or 3))
    end

    WB.UI.activeBountiesBody = bounties
    WB.UI.RefreshActiveBounties()
end

-- ── Nearby-enemies ("warm bucket") popup ─────────────────────────────────────
-- Opened by the eye on an Active Bounty card. Lists the enemies the target has seen on
-- their shard in the last 15 min — those enemies are YOUR faction, so you can whisper
-- them or request a group invite to converge on the target's location.
local sightingsPopup

local function SightingsFullName(e)
    if e and e.realm and e.realm ~= "" then return (e.name or "?") .. "-" .. e.realm end
    return (e and e.name) or "?"
end

-- Request a group invite from a player (JARVIS's logic: leave a normal group first).
local function RequestInviteFrom(full)
    if not full or full == "" or not (C_PartyInfo and C_PartyInfo.RequestInviteFromUnit) then return end
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        C_PartyInfo.RequestInviteFromUnit(full)                 -- can't auto-leave LFG; ask anyway
    elseif IsInGroup() then
        C_PartyInfo.LeaveParty()
        C_Timer.After(0.5, function() C_PartyInfo.RequestInviteFromUnit(full) end)
    else
        C_PartyInfo.RequestInviteFromUnit(full)
    end
end

local function SightingAgo(t)
    local d = math.max(0, time() - (t or 0))
    if d < 60   then return "just now" end
    if d < 3600 then return math.floor(d / 60) .. "m ago" end
    return math.floor(d / 3600) .. "h ago"
end

local function CreateSightingsPopup()
    local f = CreateFrame("Frame", "WarBoardSightingsPopup", UIParent, "BackdropTemplate")
    f:SetSize(304, 400)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true, tileSize = 32, edgeSize = 24,
        insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    f:SetBackdropColor(0.06, 0.06, 0.07, 0.97)

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOP", f, "TOP", 0, -14)
    f.title:SetText("Nearby Allies")
    f.title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    f.sub = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.sub:SetPoint("TOP", f.title, "BOTTOM", 0, -3)
    f.sub:SetTextColor(0.70, 0.70, 0.72)

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -3, -3)

    local ROW_H = 50
    local scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     f, "TOPLEFT",     12, -50)
    scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -30, 14)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local nv   = self:GetVerticalScroll() - (delta * ROW_H)
        local maxS = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    f.empty = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.empty:SetPoint("TOP", scroll, "TOP", -8, -20)
    f.empty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    f.empty:SetText("No nearby allies reported yet.")
    f.empty:Hide()

    -- Right-click context menu (Whisper / Request an Invite) + a click-catcher to dismiss it.
    local catcher = CreateFrame("Button", nil, UIParent)
    catcher:SetFrameStrata("FULLSCREEN_DIALOG")
    catcher:SetAllPoints(UIParent)
    catcher:Hide()
    local menu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    menu:SetFrameStrata("FULLSCREEN_DIALOG")
    menu:SetFrameLevel(catcher:GetFrameLevel() + 10)
    menu:SetSize(154, 46)
    menu:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    menu:SetBackdropColor(0.09, 0.08, 0.07, 0.98)
    menu:SetBackdropBorderColor(0.50, 0.40, 0.25, 1)
    menu:Hide()
    menu:SetScript("OnHide", function() catcher:Hide() end)
    catcher:SetScript("OnClick", function() menu:Hide() end)
    local function MenuItem(text, y, onClick)
        local b = CreateFrame("Button", nil, menu)
        b:SetPoint("TOPLEFT",  menu, "TOPLEFT",  5, y)
        b:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -5, y)
        b:SetHeight(18)
        local hl = b:CreateTexture(nil, "HIGHLIGHT")
        hl:SetAllPoints(); hl:SetColorTexture(1, 1, 1, 0.12)
        local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetPoint("LEFT", b, "LEFT", 6, 0)
        fs:SetText(text); fs:SetTextColor(0.95, 0.90, 0.78)
        b:SetScript("OnClick", function() menu:Hide(); if menu.entry then onClick(menu.entry) end end)
        return b
    end
    MenuItem("Whisper", -4, function(e)
        if ChatFrame_SendTell then ChatFrame_SendTell(SightingsFullName(e)) end
    end)
    MenuItem("Request an Invite", -23, function(e) RequestInviteFrom(SightingsFullName(e)) end)

    f.scroll, f.content, f.ROW_H, f.rows = scroll, content, ROW_H, {}
    f.menu, f.menuCatcher = menu, catcher
    return f
end

local function RenderSightings()
    local f = sightingsPopup
    if not f or not f.targetGuid then return end
    local list = (WB.Sightings and WB.Sightings.GetForTarget and WB.Sightings.GetForTarget(f.targetGuid)) or {}
    for _, row in ipairs(f.rows) do row:Hide() end
    f.sub:SetText(("%d seen in the last 15 min"):format(#list))
    if #list == 0 then
        local st = WB.BountyTracker and WB.BountyTracker.GetTrackState and WB.BountyTracker.GetTrackState(f.targetGuid)
        f.empty:SetText((st == "tracking" or st == "trying")
            and "No nearby allies reported yet."
            or  "Track this target to receive their sightings.")
        f.empty:Show(); f.content:SetHeight(1); return
    end
    f.empty:Hide()
    for i, e in ipairs(list) do
        local row = f.rows[i]
        if not row then
            row = CreateFrame("Button", nil, f.content)
            row:SetHeight(f.ROW_H - 4)
            row:SetPoint("TOPLEFT", f.content, "TOPLEFT", 2, -((i - 1) * f.ROW_H) - 2)
            row:SetPoint("RIGHT",   f.content, "RIGHT",   -2, 0)
            row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
            row.bg = row:CreateTexture(nil, "BACKGROUND")
            row.bg:SetAllPoints(); row.bg:SetColorTexture(1, 1, 1, 0.03)
            row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            row.name:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -3)
            row.name:SetPoint("RIGHT", row, "RIGHT", -6, 0)
            row.name:SetJustifyH("LEFT"); row.name:SetWordWrap(false)
            row.sub = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.sub:SetPoint("TOPLEFT", row.name, "BOTTOMLEFT", 0, -2)
            row.sub:SetTextColor(0.78, 0.74, 0.66)
            row.guild = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.guild:SetPoint("TOPLEFT", row.sub, "BOTTOMLEFT", 0, -2)
            row.guild:SetPoint("RIGHT", row, "RIGHT", -6, 0)
            row.guild:SetJustifyH("LEFT"); row.guild:SetWordWrap(false)
            row.guild:SetTextColor(0.30, 0.78, 0.30)
            row:SetScript("OnEnter", function(self)
                self.bg:SetColorTexture(1, 1, 1, 0.10)
                local en = self.entry
                if not en then return end
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(SightingsFullName(en), 1, 0.95, 0.8)
                GameTooltip:AddLine("Shard: " .. (en.shard and tostring(en.shard) or "?"), 0.60, 0.78, 0.95)
                GameTooltip:AddLine("Seen: " .. SightingAgo(en.t), 0.80, 0.80, 0.80)
                GameTooltip:Show()
            end)
            row:SetScript("OnLeave", function(self) self.bg:SetColorTexture(1, 1, 1, 0.03); GameTooltip:Hide() end)
            row:SetScript("OnClick", function(self, button)
                if button ~= "RightButton" then return end
                local m = sightingsPopup.menu
                m.entry = self.entry
                local cx, cy = GetCursorPosition()
                local s = m:GetEffectiveScale()
                m:ClearAllPoints()
                m:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx / s, cy / s)
                sightingsPopup.menuCatcher:Show()
                m:Show()
            end)
            f.rows[i] = row
        end
        row.entry = e
        local cc = e.classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[e.classToken]
        row.name:SetText(e.name or "?")
        if cc then row.name:SetTextColor(cc.r, cc.g, cc.b)
        else       row.name:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3]) end
        row.sub:SetText(("Level %s, %s"):format(tostring(e.level or "??"), e.race or "Unknown"))
        row.guild:SetText((e.guild and e.guild ~= "") and ("<" .. e.guild .. ">") or "")
        row:Show()
    end
    f.content:SetHeight(math.max(1, #list * f.ROW_H + 4))
end

function WB.UI.ShowSightings(bounty)
    if not bounty or not bounty.targetGuid then return end
    if not sightingsPopup then sightingsPopup = CreateSightingsPopup() end
    sightingsPopup.targetGuid = bounty.targetGuid
    sightingsPopup.title:SetText("Nearby Allies \226\128\148 " .. (bounty.targetName or "?"))
    RenderSightings()
    sightingsPopup:Show()
    sightingsPopup:Raise()
end

function WB.UI.RefreshSightings(targetGuid)
    if sightingsPopup and sightingsPopup:IsShown() and sightingsPopup.targetGuid == targetGuid then
        RenderSightings()
    end
end

-- ── Community invite popup ───────────────────────────────────────────────────
-- Small dialog with the War Board community invite link (highlight + Ctrl+C to
-- copy). On close it opens Blizzard's Join/Create Community flow.
local communityPopup
local function CreateCommunityPopup()
    local INVITE = (WB.Community and WB.Community.INVITE_CODE) or "LqmOb27i5aL"

    local f = CreateFrame("Frame", "WarBoardCommunityPopup", UIParent, "BackdropTemplate")
    f:SetSize(440, 210)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true, tileSize = 32, edgeSize = 28,
        insets = { left = 8, right = 8, top = 8, bottom = 8 },
    })
    f:SetBackdropColor(0.05, 0.05, 0.06, 0.97)
    f:SetBackdropBorderColor(0.85, 0.68, 0.20, 0.90)

    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 20)
    title:SetPoint("TOP", f, "TOP", 0, -16)
    title:SetText("Community Invite")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    desc:SetPoint("TOPLEFT",  f, "TOPLEFT",  22, -52)
    desc:SetPoint("TOPRIGHT", f, "TOPRIGHT", -22, -52)
    desc:SetJustifyH("CENTER")
    desc:SetText("Copy and paste this link into the \"Join Community\" field to join the War Board network.")
    desc:SetTextColor(0.85, 0.82, 0.74)

    local box = CreateFrame("EditBox", "WarBoardCommunityLinkBox", f, "InputBoxTemplate")
    box:SetSize(200, 22)
    box:SetPoint("TOP", desc, "BOTTOM", 0, -18)
    box:SetAutoFocus(false)
    box:SetJustifyH("CENTER")
    box:SetText(INVITE)
    box:SetTextColor(0.40, 0.90, 1.00)
    box:SetCursorPosition(0)
    box:SetScript("OnMouseDown", function(self) self:SetFocus(); self:HighlightText() end)
    box:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
    box:SetScript("OnTextChanged", function(self)
        -- If anything edits/clears it, repopulate the invite code.
        if self:GetText() ~= INVITE then
            self:SetText(INVITE)
            self:HighlightText()
        end
    end)
    box:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    local hint = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hint:SetPoint("TOP", box, "BOTTOM", 0, -8)
    hint:SetText("Click the link to select it, then press Ctrl+C to copy.")
    hint:SetTextColor(0.50, 0.50, 0.50)

    local xBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -4, -4)

    local closeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    closeBtn:SetSize(120, 24)
    closeBtn:SetPoint("BOTTOM", f, "BOTTOM", 0, 16)
    closeBtn:SetText("Close")
    closeBtn:SetScript("OnClick", function() f:Hide() end)

    -- Closing the popup (X, Close, or Escape) opens the Join Community flow.
    f:SetScript("OnHide", function()
        if WB.Community and WB.Community.OpenJoinCommunityDialog then
            WB.Community.OpenJoinCommunityDialog()
        end
    end)

    tinsert(UISpecialFrames, "WarBoardCommunityPopup")  -- Escape closes it
    return f
end

function WB.UI.ShowCommunityPopup()
    if not communityPopup then communityPopup = CreateCommunityPopup() end
    communityPopup:Show()
    communityPopup:Raise()
end

-- ── Campaign Results popup ───────────────────────────────────────────────────
-- A paged history of concluded campaigns: Dates (range + ended-at) | Result (faction
-- wax seal + VICTORY) | Score (stacked Horde/Alliance crest + ending WC). Themed with
-- the Horde-vs-Alliance store art top/bottom; the sides have no art, so we draw them.
local resultsPopup

local FACTION_CREST = {
    Horde    = "questlog-questtypeicon-horde",
    Alliance = "questlog-questtypeicon-alliance",
}
local FACTION_SEAL = {
    Horde    = "Quest-Horde-WaxSeal",
    Alliance = "Quest-Alliance-WaxSeal",
    Tie      = "Quest-Legionfall-WaxSeal",   -- a draw → neutral seal + "STALEMATE"
}

local function FmtRangeDates(startT, endT)
    if not startT or not endT then return "\226\128\148" end
    return date("%b %d", startT) .. " \226\128\147 " .. date("%b %d", endT)
end

local function CreateCampaignResultsPopup()
    local W             = 560
    local FRAME_H       = 706
    local ROWS_PER_PAGE = 6     -- max rows shown before paginating
    local ROW_H         = 70

    local f = CreateFrame("Frame", "WarBoardCampaignResults", UIParent, "BackdropTemplate")
    f:SetSize(W, FRAME_H)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:SetClipsChildren(false)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        tile = true, tileSize = 16,
        insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    f:SetBackdropColor(0.04, 0.045, 0.06, 0.98)

    -- Themed bars: create + size only. The top anchors to the frame top; the bottom is
    -- placed at the END of the content (below the About section) so the window can be
    -- sized to EXACTLY its content — the bars become the true top/bottom edges.
    local function ThemeBar(atlas)
        local t = f:CreateTexture(nil, "ARTWORK")
        t:SetAtlas(atlas)
        local info = C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlas)
        local h = (info and info.height) or 60
        t:SetHeight(h)
        return t, h
    end
    -- Visible height of each decorative trim — the part that actually shows, NOT the
    -- atlas's transparent padding. The FRAME is sized from these (plus the content), so
    -- the padding never inflates the panel; the atlases just overlay the top/bottom
    -- edges and may spill outside. Tune these two if the title/footer sit too close to
    -- (or too far from) the trim.
    local TOP_TRIM, BOT_TRIM = 42, 68

    local topArt = ThemeBar("perks-theme-hordevsalliance-tp-topbig")
    topArt:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 42)
    topArt:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 42)
    local botArt = ThemeBar("perks-theme-hordevsalliance-tp-bottombig")
    botArt:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  0, -50)
    botArt:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, -50)

    local xBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -24, -20)
    xBtn:SetFrameLevel(f:GetFrameLevel() + 10)

    -- ── Top-down content chain (everything has a fixed height; nothing stretches) ──
    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 28)
    title:SetHeight(34)
    title:SetPoint("TOP", f, "TOP", 0, -(TOP_TRIM + 2))
    title:SetText("CAMPAIGN RESULTS")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetHeight(18)
    desc:SetPoint("TOP", title, "BOTTOM", 0, -4)
    desc:SetText("Review the outcome of previous campaigns and how your faction performed.")
    desc:SetTextColor(0.85, 0.83, 0.76)

    -- Results table — FIXED height (header + N rows). Never stretched, so the empty
    -- state stays compact instead of ballooning to fill the window.
    local BLOCK_H = 42 + ROWS_PER_PAGE * ROW_H
    local block = CreateFrame("Frame", nil, f, "BackdropTemplate")
    block:SetHeight(BLOCK_H)
    block:SetPoint("TOP",   desc, "BOTTOM", 0, -10)
    block:SetPoint("LEFT",  f, "LEFT",   20, 0)
    block:SetPoint("RIGHT", f, "RIGHT", -20, 0)
    block:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    block:SetBackdropColor(0.02, 0.02, 0.03, 0.6)
    block:SetBackdropBorderColor(0.45, 0.36, 0.20, 0.9)

    -- Pagination row below the table.
    local pager = CreateFrame("Frame", nil, f)
    pager:SetHeight(20)
    pager:SetPoint("TOP",   block, "BOTTOM", 0, -4)
    pager:SetPoint("LEFT",  f, "LEFT",   24, 0)
    pager:SetPoint("RIGHT", f, "RIGHT", -24, 0)

    local pageText = pager:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    pageText:SetPoint("CENTER", pager, "CENTER", 0, 0)
    pageText:SetTextColor(0.85, 0.82, 0.74)

    local prevBtn = CreateFrame("Button", nil, pager)
    prevBtn:SetSize(28, 28)
    prevBtn:SetPoint("RIGHT", pageText, "LEFT", -16, 0)
    prevBtn:SetNormalTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Up")
    prevBtn:SetPushedTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Down")
    prevBtn:SetDisabledTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Disabled")

    local nextBtn = CreateFrame("Button", nil, pager)
    nextBtn:SetSize(28, 28)
    nextBtn:SetPoint("LEFT", pageText, "RIGHT", 16, 0)
    nextBtn:SetNormalTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up")
    nextBtn:SetPushedTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Down")
    nextBtn:SetDisabledTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Disabled")

    -- About section below the pager. War-mode emblem (ring + glow + swords) at the left.
    local aboutDiv = f:CreateTexture(nil, "ARTWORK")
    aboutDiv:SetColorTexture(0, 0, 0, 0)
    aboutDiv:SetHeight(1)
    aboutDiv:SetPoint("TOP",   block, "BOTTOM", 0, -8)
    aboutDiv:SetPoint("LEFT",  f, "LEFT",   24, 0)
    aboutDiv:SetPoint("RIGHT", f, "RIGHT", -24, 0)

    local aboutIcon = f:CreateTexture(nil, "ARTWORK")
    aboutIcon:SetAtlas("Talent-RingWithDot")
    aboutIcon:SetSize(46, 46)
    aboutIcon:SetPoint("TOPLEFT", aboutDiv, "BOTTOMLEFT", 4, -6)
    local aboutGlow = f:CreateTexture(nil, "ARTWORK", nil, 1)
    aboutGlow:SetAtlas("pvptalents-warmode-orb")
    aboutGlow:SetSize(34, 34)
    aboutGlow:SetPoint("CENTER", aboutIcon, "CENTER", 0, 0)
    local aboutSwords = f:CreateTexture(nil, "OVERLAY")
    aboutSwords:SetAtlas("pvptalents-warmode-swords")
    aboutSwords:SetSize(24, 24)
    aboutSwords:SetPoint("CENTER", aboutIcon, "CENTER", 0, 0)

    local aboutTitle = f:CreateFontString(nil, "OVERLAY")
    aboutTitle:SetFont("Fonts\\FRIZQT__.TTF", 14, "")
    aboutTitle:SetHeight(16)
    aboutTitle:SetPoint("TOPLEFT", aboutDiv, "BOTTOMLEFT", 58, -8)
    aboutTitle:SetText("ABOUT CAMPAIGN RESULTS")
    aboutTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local aboutDesc = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    aboutDesc:SetHeight(54)
    aboutDesc:SetPoint("TOPLEFT", aboutTitle, "BOTTOMLEFT", 0, -4)
    aboutDesc:SetPoint("RIGHT",   f, "RIGHT", -26, 0)
    aboutDesc:SetJustifyH("LEFT")
    aboutDesc:SetSpacing(4)
    aboutDesc:SetText("\226\128\162 Campaigns last 72 hours and end at 6:00 PM PST / 9:00 PM EST (one hour later during daylight saving).\n"
        .. "\226\128\162 Results are finalized after a 30-minute reconciliation period.\n"
        .. "\226\128\162 Only activities completed before the campaign end time are counted.")
    aboutDesc:SetTextColor(0.74, 0.72, 0.66)

    -- Fixed compact parent height; the table keeps its full designed size and the
    -- decorative bars attach to the frame edges.
    f:SetHeight(FRAME_H)

    -- Thin gold sides down the content area (between the two trims; the theme has none).
    local function Side(corner)
        local s = f:CreateTexture(nil, "BORDER")
        s:SetColorTexture(0.78, 0.62, 0.28, 0.7)
        s:SetWidth(2)
        local xoff = (corner == "LEFT") and 5 or -5
        s:SetPoint("TOP"..corner,    f, "TOP"..corner,    xoff, -TOP_TRIM + 6)
        s:SetPoint("BOTTOM"..corner, f, "BOTTOM"..corner, xoff,  BOT_TRIM - 6)
    end
    Side("LEFT"); Side("RIGHT")

    -- Column anchors shared by the header + rows.
    local DATE_X, SEAL_X, RESULT_X, SCORE_R = 16, 212, 260, -16

    local head = CreateFrame("Frame", nil, block)
    head:SetPoint("TOPLEFT",  block, "TOPLEFT",   8, -10)
    head:SetPoint("TOPRIGHT", block, "TOPRIGHT", -8, -10)
    head:SetHeight(18)
    local function HeadLabel(text, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY")
        fs:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.72, 0.45)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeadLabel("DATES",  "LEFT",  DATE_X)
    HeadLabel("RESULT", "LEFT",  248)
    HeadLabel("SCORE",  "RIGHT", SCORE_R)

    local hLine = block:CreateTexture(nil, "ARTWORK")
    hLine:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.25)
    hLine:SetHeight(1)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -6)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -6)

    local rowHost = CreateFrame("Frame", nil, block)
    rowHost:SetPoint("TOPLEFT",  hLine, "BOTTOMLEFT",  0, -2)
    rowHost:SetPoint("TOPRIGHT", hLine, "BOTTOMRIGHT", 0, -2)
    rowHost:SetPoint("BOTTOM",   block, "BOTTOM",      0,  6)

    local emptyFS = block:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    emptyFS:SetPoint("CENTER", rowHost, "CENTER", 0, 0)
    emptyFS:SetText("No campaigns have concluded yet.")
    emptyFS:SetTextColor(0.55, 0.55, 0.55)

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, rowHost)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  rowHost, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", rowHost, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        -- Dates: range + "72 Hour Campaign" + "Ended: …".
        r.dateRange = r:CreateFontString(nil, "OVERLAY")
        r.dateRange:SetFont("Fonts\\FRIZQT__.TTF", 15, "")
        r.dateRange:SetPoint("TOPLEFT", r, "TOPLEFT", DATE_X, -14)
        r.dateRange:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
        r.dateSub1 = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.dateSub1:SetPoint("TOPLEFT", r.dateRange, "BOTTOMLEFT", 0, -5)
        r.dateSub1:SetText("72 Hour Campaign")
        r.dateSub1:SetTextColor(0.62, 0.60, 0.55)
        r.dateSub2 = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.dateSub2:SetPoint("TOPLEFT", r.dateSub1, "BOTTOMLEFT", 0, -3)
        r.dateSub2:SetTextColor(0.62, 0.60, 0.55)

        -- Result: faction wax seal + "FACTION VICTORY" (vertically centered).
        r.seal = r:CreateTexture(nil, "ARTWORK")
        r.seal:SetSize(42, 42)
        r.seal:SetPoint("LEFT", r, "LEFT", SEAL_X, 0)
        r.result = r:CreateFontString(nil, "OVERLAY")
        r.result:SetFont("Fonts\\MORPHEUS.TTF", 16, "")
        r.result:SetPoint("LEFT", r, "LEFT", RESULT_X, 1)

        -- Score: two stacked crest + amount lines (winner on top), right-aligned.
        r.s1amount = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.s1amount:SetPoint("RIGHT", r, "RIGHT", SCORE_R, 12)
        r.s1amount:SetJustifyH("RIGHT")
        r.s1crest = r:CreateTexture(nil, "ARTWORK")
        r.s1crest:SetSize(18, 18)
        r.s1crest:SetPoint("RIGHT", r.s1amount, "LEFT", -6, 0)
        r.s2amount = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.s2amount:SetPoint("RIGHT", r, "RIGHT", SCORE_R, -12)
        r.s2amount:SetJustifyH("RIGHT")
        r.s2crest = r:CreateTexture(nil, "ARTWORK")
        r.s2crest:SetSize(18, 18)
        r.s2crest:SetPoint("RIGHT", r.s2amount, "LEFT", -6, 0)

        -- Row separator.
        r.div = r:CreateTexture(nil, "ARTWORK")
        r.div:SetColorTexture(1, 1, 1, 0.06)
        r.div:SetHeight(1)
        r.div:SetPoint("BOTTOMLEFT",  r, "BOTTOMLEFT",   6, 0)
        r.div:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -6, 0)

        rows[i] = r
        return r
    end

    local function SetScoreLine(crest, amount, fac, val)
        crest:SetAtlas(FACTION_CREST[fac]); crest:Show()
        amount:SetText(FmtNumber(val) .. " WC")
        local col = FACTION_NAME_COLOR[fac] or GOLD
        amount:SetTextColor(col[1], col[2], col[3])
    end

    f.page = 1
    f.Refresh = function()
        local hist  = (WB.Campaign and WB.Campaign.History and WB.Campaign.History()) or {}
        local total = #hist
        local pages = math.max(1, math.ceil(total / ROWS_PER_PAGE))
        f.page = math.max(1, math.min(f.page, pages))

        for _, r in ipairs(rows) do r:Hide() end
        local base = (f.page - 1) * ROWS_PER_PAGE
        for slot = 1, ROWS_PER_PAGE do
            local e = hist[base + slot]
            if e then
                local r = GetRow(slot)
                r.dateRange:SetText(FmtRangeDates(e.startT, e.endT))
                r.dateSub2:SetText("Ended: " .. date("%b %d", e.endT or 0) .. ", " .. (date("%I:%M %p", e.endT or 0):gsub("^0", "")))

                local w = e.winner
                if w == "Horde" or w == "Alliance" then
                    r.seal:SetAtlas(FACTION_SEAL[w]); r.seal:Show()
                    r.result:SetText(w:upper() .. " VICTORY")
                    local col = FACTION_NAME_COLOR[w] or GOLD
                    r.result:SetTextColor(col[1], col[2], col[3])
                elseif w == "Tie" then
                    r.seal:SetAtlas(FACTION_SEAL.Tie); r.seal:Show()
                    r.result:SetText("STALEMATE")
                    r.result:SetTextColor(0.80, 0.76, 0.64)
                else
                    r.seal:Hide()
                    r.result:SetText("NO CONTEST")
                    r.result:SetTextColor(0.70, 0.70, 0.70)
                end

                local h, a = e.horde or 0, e.alliance or 0
                if a > h then
                    SetScoreLine(r.s1crest, r.s1amount, "Alliance", a)
                    SetScoreLine(r.s2crest, r.s2amount, "Horde",    h)
                else
                    SetScoreLine(r.s1crest, r.s1amount, "Horde",    h)
                    SetScoreLine(r.s2crest, r.s2amount, "Alliance", a)
                end

                r.div:SetShown(slot < ROWS_PER_PAGE and hist[base + slot + 1] ~= nil)
                r:Show()
            end
        end

        -- Size the table to the rows actually present (collapse to a small height when
        -- empty), and the frame to that — so the panel hugs its content with no big
        -- reserved blank area. 201 = the fixed chrome around the table (title, desc,
        -- pager, footer); the table's own header/padding is the +42.
        pageText:SetText(("Page %d of %d"):format(f.page, pages))
        prevBtn:SetEnabled(f.page > 1)
        nextBtn:SetEnabled(f.page < pages)
        pager:SetShown(total > 0)
        aboutDiv:ClearAllPoints()
        if total > 0 then
            aboutDiv:SetPoint("TOP", pager, "BOTTOM", 0, -4)
        else
            aboutDiv:SetPoint("TOP", block, "BOTTOM", 0, -8)
        end
        aboutDiv:SetPoint("LEFT",  f, "LEFT",   24, 0)
        aboutDiv:SetPoint("RIGHT", f, "RIGHT", -24, 0)
        emptyFS:SetShown(total == 0)
    end

    prevBtn:SetScript("OnClick", function() f.page = f.page - 1; f.Refresh() end)
    nextBtn:SetScript("OnClick", function() f.page = f.page + 1; f.Refresh() end)

    tinsert(UISpecialFrames, "WarBoardCampaignResults")  -- Escape closes it
    return f
end

function WB.UI.ShowCampaignResults()
    if not resultsPopup then resultsPopup = CreateCampaignResultsPopup() end
    resultsPopup.page = 1
    resultsPopup.Refresh()
    resultsPopup:Show()
    resultsPopup:Raise()
end

-- Live-refresh the results window if it's open (e.g. a new campaign just locked).
function WB.UI.RefreshCampaignResults()
    if resultsPopup and resultsPopup:IsShown() and resultsPopup.Refresh then
        resultsPopup.Refresh()
    end
end

-- ── Frame ──────────────────────────────────────────────────────────────────────

local function CreateWarBoardFrame()
    -- Choose faction art on login. Same NineSlice logic; only textures + accent
    -- differ. WB.faction is stored for the (future) faction-separate data feeds.
    local art, factionName = GetFactionArt()
    ACCENT     = art.accent
    WB.faction = factionName

    local frame = CreateFrame("Frame", "WarBoardFrame", UIParent)
    frame:SetSize(FRAME_WIDTH, FRAME_HEIGHT)
    frame:SetPoint("CENTER")
    frame:SetFrameStrata("HIGH")
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
    frame:Hide()

    -- Background tile, inset 6px on all sides so it stays inside the border edges
    local bg = frame:CreateTexture(nil, "BACKGROUND", nil, -1)
    bg:SetAtlas(art.background)
    bg:SetPoint("TOPLEFT",     frame, "TOPLEFT",     6, -6)
    bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6,  6)
    bg:SetHorizTile(true)
    bg:SetVertTile(true)
    bg:SetAlpha(0.82)

    -- NineSlice border (corners + edges)
    ApplyFactionNineSlice(frame, art)

    -- Header banner: native atlas size, centered and protruding above the top edge.
    local header = frame:CreateTexture(nil, "ARTWORK")
    header:SetAtlas(art.header, true)
    header:SetPoint("TOP", frame, "TOP", 0, art.headerY)

    -- Title sits inside the frame, below the protruding header crest
    local title = frame:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 24, "OUTLINE")
    title:SetPoint("TOP", frame, "TOP", 0, -38)
    title:SetText("War Board")

    -- Close button in the top-right corner
    local closeBtn = CreateFrame("Button", "WarBoardCloseButton", frame, "UIPanelCloseButton")
    closeBtn:SetSize(26, 26)
    closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
    closeBtn:SetScript("OnClick", function() frame:Hide() end)

    -- Community button in the TOP-LEFT corner — mirror of the shard pill on the right,
    -- frame-level so it's always reachable (it used to sit under the Leader Board). A bit
    -- larger than before for prominence.
    local communityBtn = CreateFrame("Button", "WarBoardCommunityButton", frame, "BackdropTemplate")
    communityBtn:SetSize(140, 30)
    communityBtn:SetPoint("TOPLEFT", frame, "TOPLEFT", 63, -36)
    communityBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    communityBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    communityBtn:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
    local cbLabel = communityBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    cbLabel:SetPoint("CENTER")
    cbLabel:SetText("Community")
    cbLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    communityBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    communityBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95) end)
    communityBtn:SetScript("OnClick", function()
        if WB.UI.ShowCommunityPopup then WB.UI.ShowCommunityPopup() end
    end)

    -- Scale grip: drag bottom-right corner to scale the whole frame up/down.
    -- Capped at 50% – 200% of native size. Percentage popup tracks the cursor.
    local scaleHint = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    scaleHint:SetSize(64, 18)
    scaleHint:SetFrameStrata("TOOLTIP")
    scaleHint:Hide()
    scaleHint:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets   = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    scaleHint:SetBackdropColor(0, 0, 0, 0.75)
    scaleHint:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
    local scaleHintText = scaleHint:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    scaleHintText:SetPoint("CENTER")

    local scaleGrip = CreateFrame("Button", nil, frame)
    scaleGrip:SetSize(18, 18)
    scaleGrip:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
    scaleGrip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    scaleGrip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    scaleGrip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")

    scaleGrip:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" then return end
        local baseScale = frame:GetScale()
        local startX    = GetCursorPosition()
        scaleHint:Show()
        self:SetScript("OnUpdate", function()
            local curX, curY = GetCursorPosition()
            local uiScale    = UIParent:GetScale()
            local newScale   = math.max(0.5, math.min(2.0, baseScale + (curX - startX) / 200))
            frame:SetScale(newScale)
            local pct  = math.floor((newScale / baseScale - 1) * 100 + 0.5)
            local sign = pct >= 0 and "+" or ""
            scaleHintText:SetText(sign .. pct .. "%")
            scaleHint:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", curX / uiScale + 12, curY / uiScale + 12)
        end)
    end)

    scaleGrip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        scaleHint:Hide()
    end)

    -- ── Content: three-column command layout ──────────────────────────────────
    local content = CreateFrame("Frame", nil, frame)
    content:SetPoint("TOPLEFT",     frame, "TOPLEFT",     30, -66)
    content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30,  42)
    WB.UI.content = content

    local COL_W = 206

    local leftCol = CreateFrame("Frame", nil, content)
    leftCol:SetPoint("TOPLEFT",    content, "TOPLEFT",    0, 0)
    leftCol:SetPoint("BOTTOMLEFT", content, "BOTTOMLEFT", 0, 0)
    leftCol:SetWidth(COL_W)

    local rightCol = CreateFrame("Frame", nil, content)
    rightCol:SetPoint("TOPRIGHT",    content, "TOPRIGHT",    0, 0)
    rightCol:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0)
    rightCol:SetWidth(COL_W)

    local midCol = CreateFrame("Frame", nil, content)
    midCol:SetPoint("TOPLEFT",     leftCol,  "TOPRIGHT",    GAP, 0)
    midCol:SetPoint("BOTTOMRIGHT", rightCol, "BOTTOMLEFT", -GAP, 0)

    BuildLeftColumn(leftCol)
    BuildMiddleColumn(midCol)
    BuildRightColumn(rightCol)

    if WB.UI.RefreshWarEffort then WB.UI.RefreshWarEffort() end
    if WB.UI.RefreshCampaign  then WB.UI.RefreshCampaign()  end

    WB.UI.frame = frame

    -- Shard readout pinned to the frame's top-right corner.
    if WB.ShardPill and WB.ShardPill.Create then WB.ShardPill.Create(frame) end
end

-- ── War Board Guide (/wb guide; also auto-opens on first run) ─────────────────
-- A parchment "manual" explaining the basics. Faction-themed (red Horde / blue
-- Alliance), scrollable, content-driven height.
local guideFrame

local GUIDE_DESC =
    "War Board is a faction-wide World PvP campaign system that rewards players for "
 .. "fighting for their faction across Azeroth. Complete objectives, hunt enemy players, "
 .. "and contribute to your faction's war effort to earn rewards and rise through the "
 .. "ranks. To use War Board, enable War Mode and join the War Board community."

local GUIDE_SECTIONS = {
    { h = "Objectives", items = {
        { p = "Open the War Board to view active objectives. Objectives may require you to defeat enemy players, eliminate faction NPCs, or complete special campaign tasks." },
        { p = "Completing objectives rewards **Gold** and **War Credit**." },
    }},
    { h = "Gold", items = {
        { p = "Gold is War Board's **addon currency** and should not be confused with World of Warcraft gold." },
        { p = "Gold can be earned from objectives, bounties, and other War Board activities. It is primarily used to place bounties on enemy players." },
    }},
    { h = "War Credit", items = {
        { p = "War Credit (WC) represents your contribution to your faction's war effort." },
        { p = "War Credit is earned by completing objectives, claiming bounties, defeating enemy players, and participating in campaign activities." },
        { p = "War Credit serves two important purposes:" },
        { bullets = { "Advances your personal rank.", "Contributes to your faction's campaign progress." } },
        { p = "The faction with the most War Credit at the end of a campaign is declared the victor." },
    }},
    { h = "Campaigns", items = {
        { p = "War Board campaigns last **72 hours** and end at **6:00 PM PST** (7:00 PM PDT during daylight saving time). The clock is fixed to that moment so every player worldwide ends on the exact same instant." },
        { p = "Each campaign features an active warfront zone and a faction-wide contribution race. Earn War Credit to help your faction push the front line and secure victory before the campaign concludes." },
        { p = "When a campaign ends, War Board enters a **30-minute reconciliation period**. During this time, campaign results are temporarily held while the addon accepts any remaining synchronization updates from players who may have been offline. Once reconciliation is complete, the campaign winner is finalized and announced." },
    }},
    { h = "Bounties", items = {
        { p = "Players can place bounties on enemy players through the **Bounties** tab." },
        { p = "Accept a bounty, track your target, and eliminate them to claim the reward. Successful bounty claims reward both **Gold** and **War Credit**." },
        { p = "Higher-ranked players are worth more and often carry greater rewards." },
    }},
    { h = "Ranks", items = {
        { p = "As you earn War Credit, you will advance through your faction's ranks." },
        { p = "Higher ranks represent your dedication to the war effort and make you a more valuable target for enemy bounty hunters." },
    }},
    { h = "Alerts", items = {
        { p = "War Board periodically reports enemy activity occurring at active objective locations." },
        { p = "Use these reports to locate battles, defend strategic areas, and hunt enemy players across the warfront." },
    }},
    { h = "Campaign Results", items = {
        { p = "The Campaign Results window stores a history of completed campaigns." },
        { p = "Review previous victories, defeats, and stalemates, compare faction War Credit totals, and track the outcome of past war efforts." },
    }},
}

local function CreateGuideFrame()
    local faction = WB.faction or UnitFactionGroup("player") or "Horde"
    local isHorde = (faction ~= "Alliance")
    local parchment = isHorde and "UI-Frame-Horde-CardParchmentWider" or "UI-Frame-Alliance-CardParchmentWider"
    -- Horde's deep red blended into the dark reddish parchment, so it's brightened here;
    -- Alliance's dark blue read fine on its page, so it keeps the original shade.
    local accent    = isHorde and { 0.88, 0.28, 0.22 } or { 0.13, 0.25, 0.56 }
    local accentHex = isHorde and "ffe04738" or "ff21408f"   -- bold "War Credit" etc.
    local amberHex  = "ffb87a26"                              -- bold "Gold" (darker; reads on light parchment)
    local bodyCol   = { 0.20, 0.13, 0.08 }                    -- dark brown ink
    local SERIF     = "Fonts\\FRIZQT__.TTF"
    local footerTxt = ("FOR THE %s!"):format(isHorde and "HORDE" or "ALLIANCE")

    local function SetAccentFont(fs, size, outline)
        fs:SetFont(SERIF, size, outline and "OUTLINE" or "")
        fs:SetShadowColor(0, 0, 0, 0.85)
        fs:SetShadowOffset(1, -1)
    end

    local W = 1060
    local f = CreateFrame("Frame", "WarBoardGuideFrame", UIParent)
    f:SetSize(W, 740)               -- height re-set after the columns are laid out
    f:SetPoint("CENTER")
    -- Default 20% smaller; the corner grip can rescale, persisted per character.
    f:SetScale((WarBoardCharDB and WarBoardCharDB.guideScale) or 0.8)
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)

    -- The parchment atlas carries its own torn/burnt edges, so no backdrop behind it —
    -- the transparent margins then read as nothing (the paper floats) rather than a black
    -- box jutting past the paper edges.
    local bg = f:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(f)
    bg:SetAtlas(parchment, false)

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -12, -10)

    -- Two-column geometry inside the torn margins.
    local LM, TM = 56, 44
    local centerX = W / 2
    local colW    = (centerX - 30) - LM
    local topY    = -TM
    local left  = { x = LM,          y = topY }
    local right = { x = centerX + 30, y = topY }

    local function Diamond(parent, size)
        local d = parent:CreateTexture(nil, "ARTWORK")
        d:SetColorTexture(accent[1], accent[2], accent[3], 0.95)
        d:SetSize(size or 8, size or 8)
        d:SetRotation(0.785398)   -- 45° → a small diamond
        return d
    end
    local function Markup(s)
        return (s:gsub("%*%*(.-)%*%*", function(w)
            return "|c" .. ((w == "Gold") and amberHex or accentHex) .. w .. "|r"
        end))
    end
    local function AddPara(col, text, size, color, indent, gap, justify)
        local fs = f:CreateFontString(nil, "OVERLAY")
        fs:SetFont(SERIF, size, "")
        fs:SetWidth(colW - (indent or 0))
        fs:SetWordWrap(true)
        fs:SetJustifyH(justify or "LEFT")
        fs:SetTextColor(color[1], color[2], color[3])
        fs:SetText(text)
        fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x + (indent or 0), col.y)
        col.y = col.y - fs:GetStringHeight() - (gap or 5)
    end
    local function SplitGuideMarkup(text)
        local out, pos = {}, 1
        while true do
            local s, e, bold = text:find("%*%*(.-)%*%*", pos)
            if not s then break end
            if s > pos then out[#out + 1] = { text = text:sub(pos, s - 1), bold = false } end
            out[#out + 1] = { text = bold, bold = true }
            pos = e + 1
        end
        if pos <= #text then out[#out + 1] = { text = text:sub(pos), bold = false } end
        return out
    end
    local function AddRichPara(col, text, size, color, indent, gap)
        if not text:find("%*%*") then
            AddPara(col, text, size, color, indent, gap)
            return
        end

        indent = indent or 0
        local maxW = colW - indent
        local x, y, lineH = 0, col.y, 0
        local spaceW = size * 0.33
        for _, seg in ipairs(SplitGuideMarkup(text)) do
            for word in tostring(seg.text or ""):gmatch("%S+") do
                local fs = f:CreateFontString(nil, "OVERLAY")
                if seg.bold then
                    fs:SetFont(SERIF, size, "")
                    fs:SetShadowColor(0, 0, 0, 0.75)
                    fs:SetShadowOffset(1, -1)
                    local c = (word == "Gold") and { tonumber(amberHex:sub(3, 4), 16) / 255, tonumber(amberHex:sub(5, 6), 16) / 255, tonumber(amberHex:sub(7, 8), 16) / 255 } or accent
                    fs:SetTextColor(c[1], c[2], c[3])
                else
                    fs:SetFont(SERIF, size, "")
                    fs:SetTextColor(color[1], color[2], color[3])
                end
                fs:SetText(word)
                local w = fs:GetStringWidth()
                local addSpace = (x > 0) and spaceW or 0
                if x > 0 and (x + addSpace + w) > maxW then
                    y = y - lineH
                    x, lineH, addSpace = 0, 0, 0
                end
                fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x + indent + x + addSpace, y)
                x = x + addSpace + w
                lineH = math.max(lineH, fs:GetStringHeight())
            end
        end
        col.y = y - math.max(lineH, size) - (gap or 5)
    end
    -- Section header: small-caps serif + a faction rule running to the column's edge.
    local function AddHeader(col, text)
        local fs = f:CreateFontString(nil, "OVERLAY")
        SetAccentFont(fs, 18)
        fs:SetText(text:upper())
        fs:SetTextColor(accent[1], accent[2], accent[3])
        fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x, col.y)
        local rule = f:CreateTexture(nil, "ARTWORK")
        rule:SetColorTexture(accent[1], accent[2], accent[3], 0.5)
        rule:SetHeight(1.5)
        rule:SetPoint("LEFT", fs, "RIGHT", 10, 1)
        local rw = colW - fs:GetStringWidth() - 10
        if rw > 6 then rule:SetWidth(rw) else rule:Hide() end
        col.y = col.y - fs:GetStringHeight() - 7
    end
    local function AddSection(col, sec)
        AddHeader(col, sec.h)
        for _, item in ipairs(sec.items) do
            if item.p then
                AddRichPara(col, item.p, 14, bodyCol, 0, 6)
            elseif item.bullets then
                for _, b in ipairs(item.bullets) do
                    AddRichPara(col, "\226\128\162  " .. b, 14, bodyCol, 16, 3)
                end
                col.y = col.y - 3
            end
        end
        col.y = col.y - 9   -- breathing room before the next section
    end

    -- LEFT column: big title, ornamented divider, description, then sections 1-3.
    local title = f:CreateFontString(nil, "OVERLAY")
    SetAccentFont(title, 30, true)
    title:SetText("WAR BOARD GUIDE")
    title:SetTextColor(accent[1], accent[2], accent[3])
    title:SetPoint("TOPLEFT", f, "TOPLEFT", left.x, left.y)
    left.y = left.y - title:GetStringHeight() - 7
    local td = f:CreateTexture(nil, "ARTWORK")
    td:SetColorTexture(accent[1], accent[2], accent[3], 0.5)
    td:SetHeight(1.5)
    td:SetPoint("TOPLEFT", f, "TOPLEFT", left.x, left.y)
    td:SetWidth(colW)
    Diamond(f, 8):SetPoint("CENTER", td, "CENTER", 0, 0)
    left.y = left.y - 14
    AddPara(left, GUIDE_DESC, 14, bodyCol, 0, 12)
    for i = 1, 3 do AddSection(left, GUIDE_SECTIONS[i]) end

    -- RIGHT column: sections 4-8.
    for i = 4, #GUIDE_SECTIONS do AddSection(right, GUIDE_SECTIONS[i]) end

    -- Size the frame to the taller column, then place the footer + center divider.
    local FOOTER_GAP, FOOTER_H, BM = 16, 22, 26
    f:SetHeight(-math.min(left.y, right.y) + FOOTER_GAP + FOOTER_H + BM)

    local footer = f:CreateFontString(nil, "OVERLAY")
    SetAccentFont(footer, 14)
    footer:SetText(footerTxt)
    footer:SetTextColor(accent[1], accent[2], accent[3])
    footer:SetPoint("BOTTOM", f, "BOTTOM", 0, BM)
    Diamond(f, 8):SetPoint("RIGHT", footer, "LEFT",  -14, 0)
    Diamond(f, 8):SetPoint("LEFT",  footer, "RIGHT",  14, 0)

    local vdiv = f:CreateTexture(nil, "ARTWORK")
    vdiv:SetColorTexture(accent[1], accent[2], accent[3], 0.28)
    vdiv:SetWidth(1)
    vdiv:SetPoint("TOP",    f, "TOP", 0, -TM)
    vdiv:SetPoint("BOTTOM", footer, "TOP", 0, 10)

    -- Corner grip: drag to scale (zoom) the whole guide; the scale persists per character.
    local grip = CreateFrame("Button", nil, f)
    grip:SetSize(16, 16)
    grip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -5, 5)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(self)
        self.scaling = true
        self.sx, self.sy = GetCursorPosition()
        self.startScale  = f:GetScale()
    end)
    grip:SetScript("OnMouseUp", function(self)
        self.scaling = false
        WarBoardCharDB = WarBoardCharDB or {}
        WarBoardCharDB.guideScale = f:GetScale()
    end)
    grip:SetScript("OnUpdate", function(self)
        if not self.scaling then return end
        local cx, cy = GetCursorPosition()
        local delta = (cx - self.sx) + (self.sy - cy)   -- drag right/down → larger
        f:SetScale(math.max(0.5, math.min(1.5, self.startScale + delta / 800)))
    end)

    return f
end

function WB.UI.ShowGuide()
    if not guideFrame then guideFrame = CreateGuideFrame() end
    guideFrame:Show()
    guideFrame:Raise()
end

-- Show/hide the War Board (shared by /wb and the minimap button), refreshing the panes
-- when it opens.
function WB.UI.Toggle()
    local f = WB.UI.frame
    if not f then return end
    if f:IsShown() then
        f:Hide()
        return
    end
    f:Show()
    if WB.UI.RefreshObjectiveBackgrounds then WB.UI.RefreshObjectiveBackgrounds() end
    if WB.UI.RefreshActivity          then WB.UI.RefreshActivity()          end
    if WB.UI.RefreshBounties          then WB.UI.RefreshBounties()          end
    if WB.UI.RefreshActiveBounties    then WB.UI.RefreshActiveBounties()    end
    if WB.UI.RefreshWarEffort         then WB.UI.RefreshWarEffort()         end
    if WB.UI.RefreshAlerts            then WB.UI.RefreshAlerts()            end
    if WB.UI.RefreshCurrency          then WB.UI.RefreshCurrency()          end  -- needs real bar width once shown
end

-- Initialize after the addon is loaded
local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_LOGIN")
initFrame:SetScript("OnEvent", function(self)
    -- PLAYER_LOGIN (not ADDON_LOADED): player faction is reliably available here.
    CreateWarBoardFrame()
    -- First run on this character: open the guide once.
    WarBoardCharDB = WarBoardCharDB or {}
    if not WarBoardCharDB.guideSeen then
        WarBoardCharDB.guideSeen = true
        C_Timer.After(2, function() if WB.UI.ShowGuide then WB.UI.ShowGuide() end end)
    end
    self:UnregisterEvent("PLAYER_LOGIN")
end)

-- Slash commands to toggle the frame
SLASH_WARBOARD1 = "/wb"
SLASH_WARBOARD2 = "/warboard"
SlashCmdList["WARBOARD"] = function(msg)
    local raw = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")   -- preserves case (for player names)
    msg = raw:lower()

    -- /wb guide — open the War Board guide (the parchment manual).
    if msg == "guide" then
        if WB.UI.ShowGuide then WB.UI.ShowGuide() end
        return
    end

    -- /wb help — list the available commands.
    if msg == "help" or msg == "?" then
        print(WB.TAG.." commands:")
        print("  |cffffd100/wb|r \226\128\148 open or close the War Board")
        print("  |cffffd100/wb guide|r \226\128\148 open the War Board guide")
        print("  |cffffd100/wb sync|r \226\128\148 force a sync now (clears the cooldown); |cffffd100/wb sync Name-Realm|r pulls from one player")
        print("  |cffffd100/wb peers|r \226\128\148 list War Board users currently online in your community (who you can sync with)")
        print("  |cffffd100/wb diag|r \226\128\148 sync diagnostics (ledger size, quarantined records, local clock)")
        print("  |cffffd100/wb resync|r \226\128\148 clear the local quarantine and re-pull from peers")
        print("  |cffffd100/wb help|r \226\128\148 show this command list")
        return
    end

    -- /wb sync [name-realm] — force a sync. Bare: re-announce to everyone, clearing the
    -- per-peer cooldown so it always re-pulls immediately. With a target: ask that one player
    -- to dump their ledger to us right now (direct, immediate catch-up).
    if msg == "sync" or msg:find("^sync%s") then
        local target = raw:match("^%S+%s+(.+)$")   -- everything after "sync " (original case)
        if target and target ~= "" then
            if WB.Comm and WB.Comm.RequestSyncFrom then
                WB.Comm.RequestSyncFrom(target)
                print((WB.TAG.." requested an immediate sync from |cffffd100%s|r."):format(target))
            end
        elseif WB.Comm and WB.Comm.Announce then
            if WB.Comm.ResetSyncCooldown then WB.Comm.ResetSyncCooldown() end
            local n = WB.Comm.Announce()
            print((WB.TAG.." announced to %d peer(s) + channels (cooldown cleared)."):format(n or 0))
        end
        return
    end

    -- /wb peers — list the online War Board community members we can currently reach.
    if msg == "peers" then
        if WB.Comm and WB.Comm.GetOnlinePeers then
            local peers = WB.Comm.GetOnlinePeers()
            print((WB.TAG.." %d online peer(s):"):format(#peers))
            for _, p in ipairs(peers) do print("  - " .. p) end
        end
        return
    end

    -- /wb diag — sync diagnostics. The most common cause of "I sync but get no current data"
    -- is a wrong PC clock: peers' fresh records look like they're from the future and get
    -- quarantined (hidden from the feed + score nothing). This shows the ledger size, what's
    -- been quarantined and why, and your local clock to compare against real time.
    if msg == "diag" then
        local Lg = WB.Ledger
        print(WB.TAG.." diagnostics:")
        print(("  ledger: %d record(s) total, %d in the sync window (only these transfer)"):format(
            (Lg and Lg.Count and Lg.Count()) or 0,
            (Lg and Lg.SyncCount and Lg.SyncCount()) or 0))
        if WB.Verifier and WB.Verifier.GetQuarantine then
            local q = WB.Verifier.GetQuarantine()
            print(("  quarantined: %d record(s)"):format(#q))
            local shown = 0
            for i = #q, 1, -1 do   -- newest first
                local e = q[i]
                print(("    - [%s] %s"):format(tostring(e.atype), tostring(e.reason)))
                shown = shown + 1
                if shown >= 8 then break end
            end
        end
        local now = time()
        print(("  local clock: %s  (epoch %d)"):format(date("%Y-%m-%d %H:%M:%S", now), now))
        print("  If that time is off vs real time, fix your PC clock, then run |cffffd100/wb resync|r.")
        return
    end

    -- /wb resync — clear the local quarantine and re-pull from peers. Use after fixing a clock
    -- (already-quarantined records are remembered and won't re-admit on their own); genuinely
    -- bad records simply re-quarantine on the next dump.
    if msg == "resync" then
        if WB.Verifier and WB.Verifier.WipeQuarantine then WB.Verifier.WipeQuarantine() end
        if WB.Comm and WB.Comm.ResetSyncCooldown then WB.Comm.ResetSyncCooldown() end
        local n = (WB.Comm and WB.Comm.Announce and WB.Comm.Announce()) or 0
        print((WB.TAG.." cleared quarantine and re-announced to %d peer(s) — records re-pull on the next dump."):format(n or 0))
        return
    end

    if WB.UI.Toggle then WB.UI.Toggle() end
end
