local addonName, RF = ...
local T

-------------------------------------------------------------------------------
-- Dashboard Tab: Guild Health Analytics
-- Two-column card layout with 13 cards: vitality, retention, rank/tenure,
-- level dist, applicants, bans, action items | composition, M+, professions,
-- accounts/alts, achievements, activity trends
-------------------------------------------------------------------------------
function RF:InitDashboardTab(parent)
    T = RF.Theme

    ---------------------------------------------------------------------------
    -- Scroll frame wrapper (same pattern as SettingsPanel)
    ---------------------------------------------------------------------------
    local scrollFrame = CreateFrame("ScrollFrame", nil, parent, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", 0, 0)
    scrollFrame:SetPoint("BOTTOMRIGHT", -24, 0)

    local scrollContent = CreateFrame("Frame", nil, scrollFrame)
    scrollContent:SetWidth(1060)
    scrollContent:SetHeight(1)
    scrollFrame:SetScrollChild(scrollContent)

    scrollFrame:SetScript("OnSizeChanged", function(self, w)
        scrollContent:SetWidth(w)
    end)

    ---------------------------------------------------------------------------
    -- Layout Constants
    ---------------------------------------------------------------------------
    local SIDE_PAD = 10
    local GAP = 8
    local INNER_PAD = 10
    local parentWidth = scrollFrame:GetWidth() or 1060
    local COL_WIDTH = math.floor((parentWidth - SIDE_PAD * 2 - GAP) / 2)
    local INNER_WIDTH = COL_WIDTH - INNER_PAD * 2

    local CARD_BACKDROP = {
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    }

    ---------------------------------------------------------------------------
    -- Helper: CreateCard (matches SettingsPanel pattern)
    ---------------------------------------------------------------------------
    local function CreateCard(title, x, y, width, height)
        local card = CreateFrame("Frame", nil, scrollContent, "BackdropTemplate")
        card:SetSize(width, height)
        card:SetPoint("TOPLEFT", x, y)
        card:SetBackdrop(CARD_BACKDROP)
        card:SetBackdropColor(unpack(T.bgSecondary))
        card:SetBackdropBorderColor(unpack(T.border))

        local header = RF:CreateLabel(card, title, RF.FONT_SIZES.medium, "accent")
        header:SetPoint("TOPLEFT", INNER_PAD, -INNER_PAD)

        local underline = card:CreateTexture(nil, "ARTWORK")
        underline:SetHeight(1)
        underline:SetPoint("TOPLEFT", INNER_PAD, -(INNER_PAD + 18))
        underline:SetPoint("TOPRIGHT", -INNER_PAD, -(INNER_PAD + 18))
        underline:SetColorTexture(unpack(T.accentDim))

        return card, -(INNER_PAD + 18 + 8)
    end

    ---------------------------------------------------------------------------
    -- Helper: Create a horizontal metric bar
    ---------------------------------------------------------------------------
    local function CreateMetricBar(parentCard, y, label, width)
        local frame = CreateFrame("Frame", nil, parentCard)
        frame:SetSize(width, 20)
        frame:SetPoint("TOPLEFT", INNER_PAD, y)

        local nameLabel = RF:CreateLabel(frame, label, RF.FONT_SIZES.small, "textSecondary")
        nameLabel:SetPoint("LEFT", 0, 0)
        nameLabel:SetWidth(110)

        -- Bar background
        local barBg = frame:CreateTexture(nil, "BACKGROUND")
        barBg:SetHeight(8)
        barBg:SetPoint("LEFT", 115, 0)
        barBg:SetPoint("RIGHT", -45, 0)
        barBg:SetColorTexture(unpack(T.bgTertiary))

        -- Bar fill
        local barFill = frame:CreateTexture(nil, "ARTWORK")
        barFill:SetHeight(8)
        barFill:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barFill:SetWidth(1) -- will be updated
        barFill:SetColorTexture(unpack(T.accent))

        -- Value label
        local valueLabel = RF:CreateLabel(frame, "0%", RF.FONT_SIZES.small, "textPrimary")
        valueLabel:SetPoint("RIGHT", frame, "RIGHT", 0, 0)
        valueLabel:SetWidth(40)
        valueLabel:SetJustifyH("RIGHT")

        return {
            frame = frame,
            barBg = barBg,
            barFill = barFill,
            valueLabel = valueLabel,
            Update = function(self, value, color)
                local pct = math.max(0, math.min(1, value))
                local barWidth = math.max(1, (self.barBg:GetWidth() or 200) * pct)
                self.barFill:SetWidth(barWidth)
                self.valueLabel:SetText(math.floor(pct * 100 + 0.5) .. "%")
                if color then
                    self.barFill:SetColorTexture(unpack(color))
                end
            end,
        }
    end

    ---------------------------------------------------------------------------
    -- Helper: Simple labeled bar (label + bar + count, no percentage)
    ---------------------------------------------------------------------------
    local function CreateSimpleBar(parentCard, y, labelWidth)
        local frame = CreateFrame("Frame", nil, parentCard)
        frame:SetSize(INNER_WIDTH, 18)
        frame:SetPoint("TOPLEFT", INNER_PAD, y)

        local nameLabel = RF:CreateLabel(frame, "", RF.FONT_SIZES.small, "textSecondary")
        nameLabel:SetPoint("LEFT", 0, 0)
        nameLabel:SetWidth(labelWidth or 90)
        nameLabel:SetWordWrap(false)

        local barBg = frame:CreateTexture(nil, "BACKGROUND")
        barBg:SetHeight(8)
        barBg:SetPoint("LEFT", (labelWidth or 90) + 5, 0)
        barBg:SetPoint("RIGHT", -45, 0)
        barBg:SetColorTexture(unpack(T.bgTertiary))

        local barFill = frame:CreateTexture(nil, "ARTWORK")
        barFill:SetHeight(8)
        barFill:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barFill:SetWidth(1)
        barFill:SetColorTexture(unpack(T.accent))

        local valueLabel = RF:CreateLabel(frame, "", RF.FONT_SIZES.small, "textPrimary")
        valueLabel:SetPoint("RIGHT", frame, "RIGHT", 0, 0)
        valueLabel:SetWidth(40)
        valueLabel:SetJustifyH("RIGHT")

        return {
            frame = frame,
            nameLabel = nameLabel,
            barBg = barBg,
            barFill = barFill,
            valueLabel = valueLabel,
        }
    end

    ---------------------------------------------------------------------------
    -- Helper: Stat line (label: value)
    ---------------------------------------------------------------------------
    local function CreateStatLine(parentCard, y, label)
        local lbl = RF:CreateLabel(parentCard, label, RF.FONT_SIZES.small, "textSecondary")
        lbl:SetPoint("TOPLEFT", INNER_PAD, y)
        lbl:SetWidth(160)

        local val = RF:CreateLabel(parentCard, "—", RF.FONT_SIZES.small, "textPrimary")
        val:SetPoint("TOPLEFT", INNER_PAD + 165, y)
        val:SetWidth(INNER_WIDTH - 165)

        return val
    end

    ---------------------------------------------------------------------------
    -- Score color by range
    ---------------------------------------------------------------------------
    local function GetScoreColor(score)
        if score >= 80 then return T.success
        elseif score >= 60 then return T.accent
        elseif score >= 40 then return T.warning
        else return T.danger end
    end

    ---------------------------------------------------------------------------
    -- Column positions
    ---------------------------------------------------------------------------
    local LEFT_X = SIDE_PAD
    local RIGHT_X = SIDE_PAD + COL_WIDTH + GAP

    -- Card heights
    local CARD_HEIGHTS = {
        vitality    = 230,
        retention   = 290,
        rankTenure  = 310,
        levelDist   = 200,
        applicants  = 120,
        banList     = 110,
        actionItems = 290,
        composition = 360,
        mplus       = 300,  -- recalculated below based on topPerformers
        professions = 290,
        alts        = 180,
        achievements= 165,
        activityTrends = 280,
    }

    -- Adjust M+ card height for configurable top performers count.
    -- MAX_TOP_PERFORMERS matches the "Top M+ performers" stepper's max (15)
    -- in Settings -- the row pool below is always built at this max so that
    -- RefreshDashboard can honor a live setting change without needing the
    -- tab (which only initializes once per session) to be rebuilt.
    local MAX_TOP_PERFORMERS = 15
    local topCount = RF.charDB and RF.charDB.dashboardTopPerformers or 5
    CARD_HEIGHTS.mplus = 300 + (topCount - 5) * 16

    ---------------------------------------------------------------------------
    -- Card 1: Guild Vitality Score (left)
    ---------------------------------------------------------------------------
    local vitalCard, vy = CreateCard("Guild Vitality", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.vitality)

    -- Large score number (right side, below underline)
    local scoreLabel = RF:CreateLabel(vitalCard, "—", RF.FONT_SIZES.header + 8, "textPrimary")
    scoreLabel:SetPoint("TOPRIGHT", -INNER_PAD - 10, vy - 10)
    scoreLabel:SetJustifyH("RIGHT")

    local scoreSubLabel = RF:CreateLabel(vitalCard, "/ 100", RF.FONT_SIZES.small, "textMuted")
    scoreSubLabel:SetPoint("TOP", scoreLabel, "BOTTOM", 0, -2)
    scoreSubLabel:SetJustifyH("RIGHT")

    -- 4 metric bars (start below header underline)
    local barActiveRatio = CreateMetricBar(vitalCard, vy, "Active (7d)", INNER_WIDTH - 80)
    vy = vy - 24
    local barRetention = CreateMetricBar(vitalCard, vy, "Retention (30d)", INNER_WIDTH - 80)
    vy = vy - 24
    local barGrowth = CreateMetricBar(vitalCard, vy, "Growth Trend", INNER_WIDTH - 80)
    vy = vy - 24
    local barEngagement = CreateMetricBar(vitalCard, vy, "Engagement (3d)", INNER_WIDTH - 80)

    -- Limited data note
    local limitedNote = RF:CreateLabel(vitalCard, "", RF.FONT_SIZES.small, "textMuted")
    limitedNote:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)
    limitedNote:SetWidth(INNER_WIDTH - 90)

    -- Export button
    local exportBtn = RF:CreateButton(vitalCard, "Export", 60, 22)
    exportBtn:SetPoint("BOTTOMRIGHT", -INNER_PAD, INNER_PAD)

    -- Refresh button
    local refreshBtn = RF:CreateButton(vitalCard, "Refresh", 70, 22)
    refreshBtn:SetPoint("RIGHT", exportBtn, "LEFT", -6, 0)

    ---------------------------------------------------------------------------
    -- Card 2: Retention Risk (left)
    ---------------------------------------------------------------------------
    local retCard, ry = CreateCard("Retention Risk", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.retention)

    local retentionRows = {}
    for _, tier in ipairs(RF.RETENTION_TIERS) do
        local row = CreateFrame("Frame", nil, retCard)
        row:SetSize(INNER_WIDTH, 22)
        row:SetPoint("TOPLEFT", INNER_PAD, ry)

        -- Colored dot
        local dot = row:CreateTexture(nil, "ARTWORK")
        dot:SetSize(10, 10)
        dot:SetPoint("LEFT", 0, 0)
        dot:SetColorTexture(unpack(tier.color))

        -- Tier label
        local tierLabel = RF:CreateLabel(row, tier.label, RF.FONT_SIZES.normal, "textSecondary")
        tierLabel:SetPoint("LEFT", dot, "RIGHT", 8, 0)
        tierLabel:SetWidth(80)

        -- Day range
        local rangeText = ""
        if tier.key == "active" then rangeText = "< 7 days"
        elseif tier.key == "cooling" then rangeText = "7-14 days"
        elseif tier.key == "fading" then rangeText = "14-30 days"
        elseif tier.key == "atRisk" then rangeText = "30-60 days"
        elseif tier.key == "gone" then rangeText = "60+ days"
        end
        local rangeLabel = RF:CreateLabel(row, rangeText, RF.FONT_SIZES.small, "textMuted")
        rangeLabel:SetPoint("LEFT", tierLabel, "RIGHT", 4, 0)
        rangeLabel:SetWidth(80)

        -- Count (right-aligned)
        local countLabel = RF:CreateLabel(row, "0", RF.FONT_SIZES.normal, "textPrimary")
        countLabel:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        countLabel:SetJustifyH("RIGHT")
        countLabel:SetWidth(40)

        -- Bar fill showing proportion
        local propBg = row:CreateTexture(nil, "BACKGROUND")
        propBg:SetHeight(4)
        propBg:SetPoint("BOTTOMLEFT", dot, "BOTTOMLEFT", 0, -4)
        propBg:SetWidth(INNER_WIDTH - 50)
        propBg:SetColorTexture(unpack(T.bgTertiary))

        local propFill = row:CreateTexture(nil, "ARTWORK")
        propFill:SetHeight(4)
        propFill:SetPoint("LEFT", propBg, "LEFT", 0, 0)
        propFill:SetWidth(1)
        propFill:SetColorTexture(tier.color[1], tier.color[2], tier.color[3], 0.5)

        -- Hover tooltip for member names
        row:EnableMouse(true)
        row.tierKey = tier.key
        row:SetScript("OnEnter", function(self)
            local data = RF:GetAnalyticsData()
            if not data or not data.retentionTiers then return end
            local t = data.retentionTiers[self.tierKey]
            if not t or #t.members == 0 then return end

            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine(tier.label .. " Members", tier.color[1], tier.color[2], tier.color[3])
            local shown = 0
            for _, m in ipairs(t.members) do
                if shown >= 15 then
                    GameTooltip:AddLine(format("... and %d more", #t.members - shown), 0.5, 0.5, 0.5)
                    break
                end
                local cc = RF:GetClassColor(m.classFile)
                GameTooltip:AddDoubleLine(m.name, m.lastOnlineText or "", cc.r, cc.g, cc.b, 0.6, 0.6, 0.6)
                shown = shown + 1
            end
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function() GameTooltip:Hide() end)

        retentionRows[tier.key] = {
            countLabel = countLabel,
            propFill = propFill,
            propBg = propBg,
        }
        ry = ry - 28
    end

    -- Summary text at bottom
    local retSummary = RF:CreateLabel(retCard, "", RF.FONT_SIZES.small, "textMuted")
    retSummary:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)
    retSummary:SetWidth(INNER_WIDTH)

    ---------------------------------------------------------------------------
    -- Card 3: Rank & Tenure (left)
    ---------------------------------------------------------------------------
    local rankCard, rky = CreateCard("Rank & Tenure", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.rankTenure)

    -- Rank bars (up to 10 ranks, dynamically populated)
    local MAX_RANK_ROWS = 10
    local rankBarRows = {}
    for i = 1, MAX_RANK_ROWS do
        local bar = CreateSimpleBar(rankCard, rky, 90)
        bar.frame:Hide()
        rankBarRows[i] = bar
        rky = rky - 20
    end

    -- Tenure dots row
    local tenureFrame = CreateFrame("Frame", nil, rankCard)
    tenureFrame:SetSize(INNER_WIDTH, 20)
    tenureFrame:SetPoint("TOPLEFT", INNER_PAD, rky - 4)
    local tenureLabels = {}
    local tenureXOff = 0
    for _, bucket in ipairs(RF.TENURE_BUCKETS) do
        local dot = tenureFrame:CreateTexture(nil, "ARTWORK")
        dot:SetSize(8, 8)
        dot:SetPoint("LEFT", tenureXOff, 0)
        dot:SetColorTexture(unpack(bucket.color))

        local lbl = RF:CreateLabel(tenureFrame, bucket.label .. ": 0", RF.FONT_SIZES.small, "textSecondary")
        lbl:SetPoint("LEFT", tenureXOff + 12, 0)
        lbl:SetWidth(110)

        tenureLabels[bucket.key] = lbl
        tenureXOff = tenureXOff + 130
    end

    -- Promo/demo + avg tenure
    local rankPromoLabel = RF:CreateLabel(rankCard, "", RF.FONT_SIZES.small, "textMuted")
    rankPromoLabel:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)
    rankPromoLabel:SetWidth(INNER_WIDTH)

    ---------------------------------------------------------------------------
    -- Card 4: Level Distribution (left)
    ---------------------------------------------------------------------------
    local lvlCard, lvy = CreateCard("Level Distribution", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.levelDist)

    local lvlMaxLabel = CreateStatLine(lvlCard, lvy, "Max Level (" .. RF.MAX_PLAYER_LEVEL .. ")")
    lvy = lvy - 18
    local lvlLevelingLabel = CreateStatLine(lvlCard, lvy, "Leveling")
    lvy = lvy - 22

    local lvlBracketBars = {}
    local MAX_LVL_BRACKETS = 6
    for i = 1, MAX_LVL_BRACKETS do
        local bar = CreateSimpleBar(lvlCard, lvy, 60)
        bar.frame:Hide()
        lvlBracketBars[i] = bar
        lvy = lvy - 18
    end

    ---------------------------------------------------------------------------
    -- Card 5: Applicant History (left)
    ---------------------------------------------------------------------------
    local appCard, apy = CreateCard("Applicant History", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.applicants)

    local appTotalLabel = CreateStatLine(appCard, apy, "Total Applicants")
    apy = apy - 18
    local appAcceptedLabel = CreateStatLine(appCard, apy, "Accepted")
    apy = apy - 18
    local appDeclinedLabel = CreateStatLine(appCard, apy, "Declined")
    apy = apy - 18
    local appRateLabel = CreateStatLine(appCard, apy, "Conversion Rate")

    ---------------------------------------------------------------------------
    -- Card 6: Ban List (left)
    ---------------------------------------------------------------------------
    local banCard, bny = CreateCard("Ban List", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.banList)

    local banTotalLabel = CreateStatLine(banCard, bny, "Total Bans")
    bny = bny - 18
    local banRejoinsLabel = CreateStatLine(banCard, bny, "Recent Rejoins (30d)")

    ---------------------------------------------------------------------------
    -- Card 7: Officer Action Items (left)
    ---------------------------------------------------------------------------
    local actCard, ay = CreateCard("Action Items", LEFT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.actionItems)

    local actionLabels = {}
    for i = 1, 11 do
        local row = CreateFrame("Button", nil, actCard)
        row:SetSize(INNER_WIDTH, 20)
        row:SetPoint("TOPLEFT", INNER_PAD, ay)
        row:RegisterForClicks("RightButtonUp")

        local icon = RF:CreateLabel(row, "", RF.FONT_SIZES.normal, "textMuted")
        icon:SetPoint("LEFT", 0, 0)
        icon:SetWidth(20)

        local text = RF:CreateLabel(row, "", RF.FONT_SIZES.small, "textSecondary")
        text:SetPoint("LEFT", 20, 0)
        text:SetPoint("RIGHT", 0, 0)
        text:SetWordWrap(false)

        row:SetScript("OnClick", function(self, button)
            if button == "RightButton" and self.itemText and self.itemSeverity ~= "none" then
                local DAY_SEC = 86400
                local items = {
                    { text = "Snooze 24 hours", func = function()
                        RF:DismissActionItem(self.itemText, DAY_SEC)
                        RF:RefreshDashboard()
                    end },
                    { text = "Snooze 7 days", func = function()
                        RF:DismissActionItem(self.itemText, 7 * DAY_SEC)
                        RF:RefreshDashboard()
                    end },
                    { text = "Snooze 14 days", func = function()
                        RF:DismissActionItem(self.itemText, 14 * DAY_SEC)
                        RF:RefreshDashboard()
                    end },
                    { text = "Snooze 30 days", func = function()
                        RF:DismissActionItem(self.itemText, 30 * DAY_SEC)
                        RF:RefreshDashboard()
                    end },
                    { separator = true },
                    { text = "Ignore", func = function()
                        RF:DismissActionItem(self.itemText, 365 * DAY_SEC)
                        RF:RefreshDashboard()
                    end },
                }
                RF:ShowContextMenu(self, items)
            end
        end)

        row:SetScript("OnEnter", function(self)
            if self.itemText and self.itemSeverity ~= "none" then
                GameTooltip:SetOwner(self, "ANCHOR_TOP")
                GameTooltip:AddLine("Right-click to snooze", 0.5, 0.5, 0.5)
                GameTooltip:Show()
            end
        end)
        row:SetScript("OnLeave", function() GameTooltip:Hide() end)

        row:Hide()
        tinsert(actionLabels, { row = row, icon = icon, text = text })
        ay = ay - 22
    end

    ---------------------------------------------------------------------------
    -- Card 8: Roster Composition (right)
    ---------------------------------------------------------------------------
    local compCard, ccy = CreateCard("Roster Composition", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.composition)

    local classRows = {}
    for _, class in ipairs(RF.ALL_CLASSES) do
        local row = CreateFrame("Frame", nil, compCard)
        row:SetSize(INNER_WIDTH, 20)
        row:SetPoint("TOPLEFT", INNER_PAD, ccy)

        -- Class name (short)
        local cc = RF:GetClassColor(class)
        local displayName = class:sub(1, 1) .. class:sub(2):lower()
        displayName = displayName:gsub("deathknight", "Death Knight")
            :gsub("demonhunter", "Demon Hunter")
        -- Capitalize first letter after space
        displayName = displayName:gsub("(%a)([%w_']*)", function(a, b) return a:upper() .. b end)
        -- Fix combined words
        if class == "DEATHKNIGHT" then displayName = "Death Knight"
        elseif class == "DEMONHUNTER" then displayName = "Demon Hunter"
        end

        local nameLabel = RF:CreateLabel(row, displayName, RF.FONT_SIZES.small, "textPrimary")
        nameLabel:SetPoint("LEFT", 0, 0)
        nameLabel:SetWidth(90)
        nameLabel:SetTextColor(cc.r, cc.g, cc.b)

        -- Bar background
        local barBg = row:CreateTexture(nil, "BACKGROUND")
        barBg:SetHeight(10)
        barBg:SetPoint("LEFT", 95, 0)
        barBg:SetPoint("RIGHT", -60, 0)
        barBg:SetColorTexture(unpack(T.bgTertiary))

        -- Active bar (solid)
        local barActive = row:CreateTexture(nil, "ARTWORK")
        barActive:SetHeight(10)
        barActive:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barActive:SetWidth(1)
        barActive:SetColorTexture(cc.r, cc.g, cc.b, 0.9)

        -- Total bar (dim overlay, behind active)
        local barTotal = row:CreateTexture(nil, "BORDER")
        barTotal:SetHeight(10)
        barTotal:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barTotal:SetWidth(1)
        barTotal:SetColorTexture(cc.r, cc.g, cc.b, 0.25)

        -- Count text
        local countLabel = RF:CreateLabel(row, "0/0", RF.FONT_SIZES.small, "textSecondary")
        countLabel:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        countLabel:SetWidth(55)
        countLabel:SetJustifyH("RIGHT")

        classRows[class] = {
            barBg = barBg,
            barActive = barActive,
            barTotal = barTotal,
            countLabel = countLabel,
        }
        ccy = ccy - 22
    end

    -- Gaps label
    local gapsLabel = RF:CreateLabel(compCard, "", RF.FONT_SIZES.small, "textMuted")
    gapsLabel:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)
    gapsLabel:SetPoint("BOTTOMRIGHT", -INNER_PAD, INNER_PAD)
    gapsLabel:SetWordWrap(true)

    ---------------------------------------------------------------------------
    -- Card 9: M+ Ratings (right)
    ---------------------------------------------------------------------------
    local mplusCard, mpy = CreateCard("M+ Ratings", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.mplus)

    local mplusAvgLabel = CreateStatLine(mplusCard, mpy, "Average Rating")
    mpy = mpy - 18
    local mplusRatedLabel = CreateStatLine(mplusCard, mpy, "Rated Players")
    mpy = mpy - 22

    -- Tier bars
    local mplusTierBars = {}
    for _, tier in ipairs(RF.MPLUS_TIERS) do
        local bar = CreateSimpleBar(mplusCard, mpy, 90)
        bar.nameLabel:SetText(tier.label)
        bar.barFill:SetColorTexture(unpack(tier.color))
        mplusTierBars[tier.key] = bar
        mpy = mpy - 20
    end

    -- Top performers header
    mpy = mpy - 4
    local mplusTopHeader = RF:CreateLabel(mplusCard, "Top Performers", RF.FONT_SIZES.small, "textMuted")
    mplusTopHeader:SetPoint("TOPLEFT", INNER_PAD, mpy)
    mpy = mpy - 16

    local mplusTopLabels = {}
    for i = 1, MAX_TOP_PERFORMERS do
        local nameL = RF:CreateLabel(mplusCard, "", RF.FONT_SIZES.small, "textPrimary")
        nameL:SetPoint("TOPLEFT", INNER_PAD, mpy)
        nameL:SetWidth(200)
        nameL:SetWordWrap(false)

        local ratingL = RF:CreateLabel(mplusCard, "", RF.FONT_SIZES.small, "textSecondary")
        ratingL:SetPoint("TOPLEFT", INNER_PAD + 210, mpy)
        ratingL:SetWidth(80)

        nameL:Hide()
        ratingL:Hide()
        mplusTopLabels[i] = { nameLabel = nameL, ratingLabel = ratingL }
        mpy = mpy - 16
    end

    ---------------------------------------------------------------------------
    -- Card 10: Profession Coverage (right)
    ---------------------------------------------------------------------------
    local profCard, pfy = CreateCard("Profession Coverage", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.professions)

    local profCoverageLabel = CreateStatLine(profCard, pfy, "Covered / Total")
    pfy = pfy - 18
    local profMissingLabel = CreateStatLine(profCard, pfy, "Missing")
    pfy = pfy - 22

    local profBars = {}
    for _, profName in ipairs(RF.ALL_PROFESSIONS) do
        local bar = CreateSimpleBar(profCard, pfy, 100)
        bar.nameLabel:SetText(profName)
        bar.frame:Hide()
        profBars[profName] = bar
        pfy = pfy - 18
    end

    ---------------------------------------------------------------------------
    -- Card 11: Accounts & Alts (right)
    ---------------------------------------------------------------------------
    local altCard, aly = CreateCard("Accounts & Alts", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.alts)

    local altMainsLabel = CreateStatLine(altCard, aly, "Unique Mains")
    aly = aly - 18
    local altTotalLabel = CreateStatLine(altCard, aly, "Total Characters")
    aly = aly - 18
    local altAvgLabel = CreateStatLine(altCard, aly, "Avg Alts / Main")
    aly = aly - 18
    local altActivityLabel = CreateStatLine(altCard, aly, "Main Activity Rate")

    -- Note at bottom
    local altNote = RF:CreateLabel(altCard, "Based on alt/main links set in the roster", RF.FONT_SIZES.small, "textMuted")
    altNote:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)
    altNote:SetWidth(INNER_WIDTH)

    ---------------------------------------------------------------------------
    -- Card 12: Achievement Points (right)
    ---------------------------------------------------------------------------
    local achCard, achy = CreateCard("Achievement Points", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.achievements)

    local achAvgLabel = CreateStatLine(achCard, achy, "Average")
    achy = achy - 18
    local achRangeLabel = CreateStatLine(achCard, achy, "Range")
    achy = achy - 22

    local achBracketBars = {}
    local MAX_ACH_BRACKETS = 4
    for i = 1, MAX_ACH_BRACKETS do
        local bar = CreateSimpleBar(achCard, achy, 110)
        bar.frame:Hide()
        achBracketBars[i] = bar
        achy = achy - 18
    end

    ---------------------------------------------------------------------------
    -- Card 13: Activity Trends (right)
    ---------------------------------------------------------------------------
    local trendCard, ty = CreateCard("Activity Trends (8 Weeks)", RIGHT_X, -SIDE_PAD, COL_WIDTH, CARD_HEIGHTS.activityTrends)

    local trendRows = {}
    for i = 1, 8 do
        local row = CreateFrame("Frame", nil, trendCard)
        row:SetSize(INNER_WIDTH, 24)
        row:SetPoint("TOPLEFT", INNER_PAD, ty)

        -- Week label
        local weekLabel = RF:CreateLabel(row, "", RF.FONT_SIZES.small, "textMuted")
        weekLabel:SetPoint("LEFT", 0, 0)
        weekLabel:SetWidth(70)

        -- Active bar
        local barBg = row:CreateTexture(nil, "BACKGROUND")
        barBg:SetHeight(10)
        barBg:SetPoint("LEFT", 75, 0)
        barBg:SetPoint("RIGHT", -120, 0)
        barBg:SetColorTexture(unpack(T.bgTertiary))

        local barFill = row:CreateTexture(nil, "ARTWORK")
        barFill:SetHeight(10)
        barFill:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barFill:SetWidth(1)
        barFill:SetColorTexture(unpack(T.accent))

        -- Active count
        local activeLabel = RF:CreateLabel(row, "", RF.FONT_SIZES.small, "textPrimary")
        activeLabel:SetPoint("LEFT", barBg, "RIGHT", 6, 0)
        activeLabel:SetWidth(30)

        -- Join/Leave text
        local jlLabel = RF:CreateLabel(row, "", RF.FONT_SIZES.small, "textSecondary")
        jlLabel:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        jlLabel:SetWidth(75)
        jlLabel:SetJustifyH("RIGHT")

        trendRows[i] = {
            weekLabel = weekLabel,
            barBg = barBg,
            barFill = barFill,
            activeLabel = activeLabel,
            jlLabel = jlLabel,
        }
        ty = ty - 26
    end

    -- Legend
    local legendLabel = RF:CreateLabel(trendCard, "Bar = active members  |  +joins / -leaves", RF.FONT_SIZES.small, "textMuted")
    legendLabel:SetPoint("BOTTOMLEFT", INNER_PAD, INNER_PAD)

    ---------------------------------------------------------------------------
    -- Empty state overlay
    ---------------------------------------------------------------------------
    local emptyLabel = RF:CreateLabel(scrollContent, "Waiting for guild data...", RF.FONT_SIZES.large, "textMuted")
    emptyLabel:SetPoint("CENTER", scrollContent, "TOP", 0, -200)
    emptyLabel:Hide()

    ---------------------------------------------------------------------------
    -- Refresh function: Populate all cards from analytics data
    ---------------------------------------------------------------------------
    -- Card map: key -> { card, height, side }
    local cardMap = {
        { key = "vitality",       card = vitalCard,  h = CARD_HEIGHTS.vitality,       side = "left"  },
        { key = "retention",      card = retCard,    h = CARD_HEIGHTS.retention,      side = "left"  },
        { key = "rankTenure",     card = rankCard,   h = CARD_HEIGHTS.rankTenure,     side = "left"  },
        { key = "levelDist",      card = lvlCard,    h = CARD_HEIGHTS.levelDist,      side = "left"  },
        { key = "applicants",     card = appCard,    h = CARD_HEIGHTS.applicants,     side = "left"  },
        { key = "banList",        card = banCard,    h = CARD_HEIGHTS.banList,        side = "left"  },
        { key = "actionItems",    card = actCard,    h = CARD_HEIGHTS.actionItems,    side = "left"  },
        { key = "composition",    card = compCard,   h = CARD_HEIGHTS.composition,    side = "right" },
        { key = "mplus",          card = mplusCard,  h = CARD_HEIGHTS.mplus,          side = "right" },
        { key = "professions",    card = profCard,   h = CARD_HEIGHTS.professions,    side = "right" },
        { key = "alts",           card = altCard,    h = CARD_HEIGHTS.alts,           side = "right" },
        { key = "achievements",   card = achCard,    h = CARD_HEIGHTS.achievements,   side = "right" },
        { key = "activityTrends", card = trendCard,  h = CARD_HEIGHTS.activityTrends, side = "right" },
    }

    local allCards = {}
    local mplusCardMapEntry
    for _, entry in ipairs(cardMap) do
        tinsert(allCards, entry.card)
        if entry.key == "mplus" then mplusCardMapEntry = entry end
    end

    local function LayoutCards()
        local curHidden = RF.charDB and RF.charDB.dashboardHiddenCards or {}
        local leftY = -SIDE_PAD
        local rightY = -SIDE_PAD

        for _, entry in ipairs(cardMap) do
            if curHidden[entry.key] then
                entry.card:Hide()
            else
                entry.card:Show()
                local x = entry.side == "left" and LEFT_X or RIGHT_X
                local y = entry.side == "left" and leftY or rightY
                entry.card:ClearAllPoints()
                entry.card:SetPoint("TOPLEFT", x, y)
                if entry.side == "left" then
                    leftY = y - entry.h - GAP
                else
                    rightY = y - entry.h - GAP
                end
            end
        end

        local totalHeight = math.abs(math.min(leftY, rightY)) + SIDE_PAD
        scrollContent:SetHeight(totalHeight)
    end

    local function RefreshDashboard()
        if not RF.rosterData or #RF.rosterData == 0 then
            for _, card in ipairs(allCards) do card:Hide() end
            emptyLabel:SetText(IsInGuild() and "Waiting for guild data..." or "Not in a guild.")
            emptyLabel:Show()
            return
        end

        emptyLabel:Hide()

        -- Re-read the "Top M+ performers" setting live every refresh (rather
        -- than the value captured once at tab-init time) and resize the M+
        -- card + reflow the rest of the dashboard to match, so changing the
        -- Settings stepper takes effect immediately instead of needing a
        -- /reload (Dashboard, like other tabs, only initializes once per
        -- session).
        local liveTopCount = RF.charDB and RF.charDB.dashboardTopPerformers or 5
        liveTopCount = math.max(3, math.min(MAX_TOP_PERFORMERS, liveTopCount))
        local mplusHeight = 300 + (liveTopCount - 5) * 16
        mplusCard:SetHeight(mplusHeight)
        if mplusCardMapEntry then mplusCardMapEntry.h = mplusHeight end

        LayoutCards()

        -- Invalidate cache so we get fresh data
        RF._analyticsCache = nil
        local data = RF:GetAnalyticsData()
        if not data then return end

        -----------------------------------------------------------------------
        -- Vitality Score
        -----------------------------------------------------------------------
        local score = data.vitalityScore or 0
        local bd = data.vitalityBreakdown
        local scoreColor = GetScoreColor(score)
        scoreLabel:SetText(tostring(score))
        scoreLabel:SetTextColor(unpack(scoreColor))

        barActiveRatio:Update(bd.activeRatio, scoreColor)
        barRetention:Update(bd.retentionRate, scoreColor)
        barGrowth:Update(bd.growthTrend, scoreColor)
        barEngagement:Update(bd.engagementDepth, scoreColor)

        -- Limited data note
        local guildDB = RF:GetGuildDB()
        local hasHistory = guildDB and #guildDB.analytics.snapshots > 1
        if not hasHistory then
            limitedNote:SetText("Limited data - accuracy improves over time")
        else
            limitedNote:SetText("")
        end

        -----------------------------------------------------------------------
        -- Retention Tiers
        -----------------------------------------------------------------------
        local tiers = data.retentionTiers
        if tiers then
            local total = #RF.rosterData
            for _, tier in ipairs(RF.RETENTION_TIERS) do
                local t = tiers[tier.key]
                local row = retentionRows[tier.key]
                if t and row then
                    row.countLabel:SetText(tostring(t.count))
                    local pct = total > 0 and (t.count / total) or 0
                    local barMaxWidth = row.propBg:GetWidth() or (INNER_WIDTH - 50)
                    row.propFill:SetWidth(math.max(1, barMaxWidth * pct))
                end
            end
            local activeCount = tiers["active"] and tiers["active"].count or 0
            retSummary:SetText(format("%d of %d members active in last 7 days (%.0f%%)",
                activeCount, total, total > 0 and (activeCount / total * 100) or 0))
        end

        -----------------------------------------------------------------------
        -- Rank & Tenure
        -----------------------------------------------------------------------
        local rt = data.rankTenure
        if rt then
            -- Rank bars
            local maxRankCount = 1
            for _, r in ipairs(rt.rankDist) do
                maxRankCount = math.max(maxRankCount, r.count)
            end
            for i = 1, MAX_RANK_ROWS do
                local bar = rankBarRows[i]
                local r = rt.rankDist[i]
                if r then
                    bar.frame:Show()
                    bar.nameLabel:SetText(r.rankName)
                    local rc = RF.RANK_COLORS[r.rankIndex] or RF.RANK_COLORS[5]
                    bar.nameLabel:SetTextColor(rc.r, rc.g, rc.b)
                    bar.barFill:SetColorTexture(rc.r, rc.g, rc.b, 0.7)
                    bar.valueLabel:SetText(tostring(r.count))
                    local barW = bar.barBg:GetWidth() or 200
                    bar.barFill:SetWidth(math.max(1, barW * (r.count / maxRankCount)))
                else
                    bar.frame:Hide()
                end
            end

            -- Tenure dots
            for _, bucket in ipairs(RF.TENURE_BUCKETS) do
                local lbl = tenureLabels[bucket.key]
                if lbl then
                    lbl:SetText(bucket.label .. ": " .. (rt.tenureBuckets[bucket.key] or 0))
                end
            end

            -- Promo/demo summary
            local parts = {}
            if rt.recentChanges.promotions > 0 then
                tinsert(parts, format("|cFF40CC66%d promoted|r", rt.recentChanges.promotions))
            end
            if rt.recentChanges.demotions > 0 then
                tinsert(parts, format("|cFFE64040%d demoted|r", rt.recentChanges.demotions))
            end
            if rt.avgTenureDays > 0 then
                tinsert(parts, format("Avg tenure: %dd", rt.avgTenureDays))
            end
            rankPromoLabel:SetText(table.concat(parts, "  |  "))
        end

        -----------------------------------------------------------------------
        -- Level Distribution
        -----------------------------------------------------------------------
        local ld = data.levelDist
        if ld then
            lvlMaxLabel:SetText(tostring(ld.maxLevelCount))
            lvlLevelingLabel:SetText(tostring(ld.levelingCount))

            local maxBracket = 1
            for _, b in ipairs(ld.brackets) do
                maxBracket = math.max(maxBracket, b.count)
            end
            for i, b in ipairs(ld.brackets) do
                local bar = lvlBracketBars[i]
                if bar then
                    bar.frame:Show()
                    bar.nameLabel:SetText(b.label)
                    bar.valueLabel:SetText(tostring(b.count))
                    local barW = bar.barBg:GetWidth() or 200
                    bar.barFill:SetWidth(math.max(1, barW * (b.count / maxBracket)))
                end
            end
        end

        -----------------------------------------------------------------------
        -- Applicant History
        -----------------------------------------------------------------------
        local ap = data.applicantStats
        if ap then
            appTotalLabel:SetText(tostring(ap.total))
            appAcceptedLabel:SetText(tostring(ap.accepted))
            appDeclinedLabel:SetText(tostring(ap.declined))
            appRateLabel:SetText(format("%.0f%%", ap.rate * 100))
        else
            appTotalLabel:SetText("0")
            appAcceptedLabel:SetText("0")
            appDeclinedLabel:SetText("0")
            appRateLabel:SetText("—")
        end

        -----------------------------------------------------------------------
        -- Ban List
        -----------------------------------------------------------------------
        local bl = data.banListStats
        if bl then
            banTotalLabel:SetText(tostring(bl.totalBans))
            banRejoinsLabel:SetText(tostring(bl.recentRejoins))
        else
            banTotalLabel:SetText("0")
            banRejoinsLabel:SetText("0")
        end

        -----------------------------------------------------------------------
        -- Action Items
        -----------------------------------------------------------------------
        local items = data.actionItems
        for i, al in ipairs(actionLabels) do
            if items and items[i] then
                local item = items[i]
                al.row:Show()
                al.row.itemText = item.text
                al.row.itemSeverity = item.severity
                al.text:SetText(item.text)
                if item.severity == "danger" then
                    al.icon:SetText("!!")
                    al.icon:SetTextColor(unpack(T.danger))
                    al.text:SetTextColor(unpack(T.textPrimary))
                elseif item.severity == "warning" then
                    al.icon:SetText("!")
                    al.icon:SetTextColor(unpack(T.warning))
                    al.text:SetTextColor(unpack(T.textPrimary))
                elseif item.severity == "success" then
                    al.icon:SetText("+")
                    al.icon:SetTextColor(unpack(T.success))
                    al.text:SetTextColor(unpack(T.textPrimary))
                elseif item.severity == "none" then
                    al.icon:SetText("-")
                    al.icon:SetTextColor(unpack(T.textMuted))
                    al.text:SetTextColor(unpack(T.textMuted))
                else
                    al.icon:SetText("i")
                    al.icon:SetTextColor(unpack(T.accent))
                    al.text:SetTextColor(unpack(T.textPrimary))
                end
            else
                al.row:Hide()
            end
        end

        -----------------------------------------------------------------------
        -- Class Distribution
        -----------------------------------------------------------------------
        local activeCounts, totalCounts, gaps = data.classDist[1], data.classDist[2], data.classDist[3]
        if activeCounts and totalCounts then
            -- Find max total for bar scaling
            local maxTotal = 1
            for _, class in ipairs(RF.ALL_CLASSES) do
                maxTotal = math.max(maxTotal, totalCounts[class] or 0)
            end

            for _, class in ipairs(RF.ALL_CLASSES) do
                local row = classRows[class]
                if row then
                    local ac = activeCounts[class] or 0
                    local tc = totalCounts[class] or 0
                    row.countLabel:SetText(ac .. "/" .. tc)

                    local barWidth = row.barBg:GetWidth() or 200
                    row.barTotal:SetWidth(math.max(1, barWidth * (tc / maxTotal)))
                    row.barActive:SetWidth(math.max(1, barWidth * (ac / maxTotal)))
                end
            end

            if gaps and #gaps > 0 and #gaps <= 5 then
                local names = {}
                for _, class in ipairs(gaps) do
                    local cc = RF:GetClassColor(class)
                    local name = class:sub(1,1) .. class:sub(2):lower()
                    if class == "DEATHKNIGHT" then name = "DK"
                    elseif class == "DEMONHUNTER" then name = "DH"
                    end
                    tinsert(names, format("|cFF%02x%02x%02x%s|r", cc.r*255, cc.g*255, cc.b*255, name))
                end
                gapsLabel:SetText("No active: " .. table.concat(names, ", "))
            elseif gaps and #gaps > 5 then
                gapsLabel:SetText(#gaps .. " classes with no active members")
            else
                gapsLabel:SetText("")
            end
        end

        -----------------------------------------------------------------------
        -- M+ Ratings
        -----------------------------------------------------------------------
        local mp = data.mplusStats
        if mp then
            mplusAvgLabel:SetText(tostring(mp.avgRating))
            mplusRatedLabel:SetText(tostring(mp.ratedCount))

            -- Tier bars
            local maxTierCount = 1
            for _, tier in ipairs(RF.MPLUS_TIERS) do
                maxTierCount = math.max(maxTierCount, mp.tierBreakdown[tier.key] or 0)
            end
            for _, tier in ipairs(RF.MPLUS_TIERS) do
                local bar = mplusTierBars[tier.key]
                if bar then
                    local cnt = mp.tierBreakdown[tier.key] or 0
                    bar.valueLabel:SetText(tostring(cnt))
                    local barW = bar.barBg:GetWidth() or 200
                    bar.barFill:SetWidth(math.max(1, barW * (cnt / maxTierCount)))
                end
            end

            -- Top performers (always iterate the full pool so rows beyond
            -- the live setting are hidden even if stale data lingers there)
            for i = 1, MAX_TOP_PERFORMERS do
                local row = mplusTopLabels[i]
                local perf = (i <= liveTopCount) and mp.topPerformers[i] or nil
                if perf then
                    row.nameLabel:Show()
                    row.ratingLabel:Show()
                    local cc = RF:GetClassColor(perf.classFile)
                    row.nameLabel:SetText(perf.name)
                    row.nameLabel:SetTextColor(cc.r, cc.g, cc.b)
                    row.ratingLabel:SetText(tostring(perf.rating))
                else
                    row.nameLabel:Hide()
                    row.ratingLabel:Hide()
                end
            end
        else
            mplusAvgLabel:SetText("0")
            mplusRatedLabel:SetText("0")
        end

        -----------------------------------------------------------------------
        -- Profession Coverage
        -----------------------------------------------------------------------
        local pc = data.profCoverage
        if pc then
            profCoverageLabel:SetText(format("%d / %d", #pc.covered, #RF.ALL_PROFESSIONS))
            if #pc.missing > 0 then
                profMissingLabel:SetText(table.concat(pc.missing, ", "))
                profMissingLabel:SetTextColor(unpack(T.warning))
            else
                profMissingLabel:SetText("None")
                profMissingLabel:SetTextColor(unpack(T.success))
            end

            -- Profession bars
            local maxProfCount = 1
            for _, prof in ipairs(RF.ALL_PROFESSIONS) do
                maxProfCount = math.max(maxProfCount, pc.profCounts[prof] or 0)
            end
            for _, prof in ipairs(RF.ALL_PROFESSIONS) do
                local bar = profBars[prof]
                if bar then
                    local cnt = pc.profCounts[prof] or 0
                    if cnt > 0 then
                        bar.frame:Show()
                        bar.valueLabel:SetText(tostring(cnt))
                        local barW = bar.barBg:GetWidth() or 200
                        bar.barFill:SetWidth(math.max(1, barW * (cnt / maxProfCount)))
                    else
                        bar.frame:Hide()
                    end
                end
            end
        else
            profCoverageLabel:SetText("—")
            profMissingLabel:SetText("—")
        end

        -----------------------------------------------------------------------
        -- Accounts & Alts
        -----------------------------------------------------------------------
        local am = data.altMainStats
        if am then
            altMainsLabel:SetText(tostring(am.uniqueMains))
            altTotalLabel:SetText(tostring(am.totalChars))
            altAvgLabel:SetText(tostring(am.avgAltsPerMain))
            altActivityLabel:SetText(format("%.0f%%", am.mainActivityRate * 100))
        else
            altMainsLabel:SetText("0")
            altTotalLabel:SetText("0")
            altAvgLabel:SetText("—")
            altActivityLabel:SetText("—")
        end

        -----------------------------------------------------------------------
        -- Achievement Points
        -----------------------------------------------------------------------
        local ach = data.achieveStats
        if ach then
            achAvgLabel:SetText(format("%s", FormatLargeNumber and FormatLargeNumber(ach.avgPoints) or tostring(ach.avgPoints)))
            if ach.maxPoints > 0 then
                local minStr = FormatLargeNumber and FormatLargeNumber(ach.minPoints) or tostring(ach.minPoints)
                local maxStr = FormatLargeNumber and FormatLargeNumber(ach.maxPoints) or tostring(ach.maxPoints)
                achRangeLabel:SetText(minStr .. " - " .. maxStr)
            else
                achRangeLabel:SetText("—")
            end

            local maxBracket = 1
            for _, b in ipairs(ach.brackets) do
                maxBracket = math.max(maxBracket, b.count)
            end
            for i, b in ipairs(ach.brackets) do
                local bar = achBracketBars[i]
                if bar then
                    bar.frame:Show()
                    bar.nameLabel:SetText(b.label)
                    bar.valueLabel:SetText(tostring(b.count))
                    local barW = bar.barBg:GetWidth() or 200
                    bar.barFill:SetWidth(math.max(1, barW * (b.count / maxBracket)))
                end
            end
        else
            achAvgLabel:SetText("—")
            achRangeLabel:SetText("—")
        end

        -----------------------------------------------------------------------
        -- Activity Trends
        -----------------------------------------------------------------------
        local trends = data.activityTrends
        if trends then
            -- Find max active for bar scaling
            local maxActive = 1
            for _, bucket in ipairs(trends) do
                maxActive = math.max(maxActive, bucket.activeCount or 0)
            end

            for i, bucket in ipairs(trends) do
                local row = trendRows[i]
                if row then
                    row.weekLabel:SetText(bucket.weekLabel)
                    row.activeLabel:SetText(bucket.activeCount > 0 and tostring(bucket.activeCount) or "-")

                    local barWidth = row.barBg:GetWidth() or 200
                    local pct = maxActive > 0 and (bucket.activeCount / maxActive) or 0
                    row.barFill:SetWidth(math.max(1, barWidth * pct))

                    -- Join/leave text
                    local parts = {}
                    if bucket.joins > 0 then
                        tinsert(parts, format("|cFF40CC66+%d|r", bucket.joins))
                    end
                    if bucket.leaves > 0 then
                        tinsert(parts, format("|cFFE64040-%d|r", bucket.leaves))
                    end
                    row.jlLabel:SetText(#parts > 0 and table.concat(parts, " ") or "")
                end
            end
        end
    end

    -- Expose for external callers (e.g. action item snooze)
    function RF:RefreshDashboard()
        RF._analyticsCache = nil
        RefreshDashboard()
    end

    -- Refresh button handler
    refreshBtn:SetScript("OnClick", function()
        RF:RefreshDashboard()
    end)

    -- Export button handler
    exportBtn:SetScript("OnClick", function()
        local data = RF:GetAnalyticsData()
        if not data then
            RF:Print("No analytics data to export.")
            return
        end

        local lines = {}
        local total = RF.rosterData and #RF.rosterData or 0
        tinsert(lines, format("RosterForge Guild Health Report - %s", RF:GetGuildKey() or "Unknown Guild"))
        tinsert(lines, format("Generated: %s  |  Total Members: %d", date("%Y-%m-%d %H:%M"), total))
        tinsert(lines, string.rep("=", 60))

        -- Vitality
        tinsert(lines, "")
        tinsert(lines, "GUILD VITALITY")
        tinsert(lines, string.rep("-", 40))
        tinsert(lines, format("Score: %d / 100", data.vitalityScore or 0))
        local bd = data.vitalityBreakdown
        if bd then
            tinsert(lines, format("  Active (7d):    %d%%", math.floor(bd.activeRatio * 100 + 0.5)))
            tinsert(lines, format("  Retention (30d): %d%%", math.floor(bd.retentionRate * 100 + 0.5)))
            tinsert(lines, format("  Growth Trend:   %d%%", math.floor(bd.growthTrend * 100 + 0.5)))
            tinsert(lines, format("  Engagement (3d): %d%%", math.floor(bd.engagementDepth * 100 + 0.5)))
        end

        -- Retention
        if data.retentionTiers then
            tinsert(lines, "")
            tinsert(lines, "RETENTION RISK")
            tinsert(lines, string.rep("-", 40))
            for _, tier in ipairs(RF.RETENTION_TIERS) do
                local t = data.retentionTiers[tier.key]
                if t then
                    tinsert(lines, format("  %-10s %d members", tier.label, t.count))
                end
            end
        end

        -- Rank & Tenure
        local rt = data.rankTenure
        if rt then
            tinsert(lines, "")
            tinsert(lines, "RANK & TENURE")
            tinsert(lines, string.rep("-", 40))
            for _, r in ipairs(rt.rankDist) do
                tinsert(lines, format("  %-20s %d", r.rankName, r.count))
            end
            tinsert(lines, format("  Avg Tenure: %d days", rt.avgTenureDays))
            tinsert(lines, format("  Promotions (30d): %d  |  Demotions: %d",
                rt.recentChanges.promotions, rt.recentChanges.demotions))
        end

        -- Class Distribution
        local ac, tc = data.classDist[1], data.classDist[2]
        if ac and tc then
            tinsert(lines, "")
            tinsert(lines, "CLASS DISTRIBUTION (Active / Total)")
            tinsert(lines, string.rep("-", 40))
            for _, class in ipairs(RF.ALL_CLASSES) do
                local name = class:sub(1,1) .. class:sub(2):lower()
                if class == "DEATHKNIGHT" then name = "Death Knight"
                elseif class == "DEMONHUNTER" then name = "Demon Hunter"
                end
                tinsert(lines, format("  %-14s %d / %d", name, ac[class] or 0, tc[class] or 0))
            end
        end

        -- M+ Ratings
        local mp = data.mplusStats
        if mp then
            tinsert(lines, "")
            tinsert(lines, "M+ RATINGS")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Average Rating: %d  |  Rated Players: %d", mp.avgRating, mp.ratedCount))
            for _, tier in ipairs(RF.MPLUS_TIERS) do
                tinsert(lines, format("  %-14s %d", tier.label, mp.tierBreakdown[tier.key] or 0))
            end
            if #mp.topPerformers > 0 then
                tinsert(lines, "  Top Performers:")
                for _, p in ipairs(mp.topPerformers) do
                    tinsert(lines, format("    %-20s %d", p.name, p.rating))
                end
            end
        end

        -- Professions
        local pc = data.profCoverage
        if pc then
            tinsert(lines, "")
            tinsert(lines, "PROFESSION COVERAGE")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Covered: %d / %d", #pc.covered, #RF.ALL_PROFESSIONS))
            if #pc.missing > 0 then
                tinsert(lines, "  Missing: " .. table.concat(pc.missing, ", "))
            end
            for _, prof in ipairs(RF.ALL_PROFESSIONS) do
                local cnt = pc.profCounts[prof] or 0
                if cnt > 0 then
                    tinsert(lines, format("  %-18s %d active", prof, cnt))
                end
            end
        end

        -- Level Distribution
        local ld = data.levelDist
        if ld then
            tinsert(lines, "")
            tinsert(lines, "LEVEL DISTRIBUTION")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Max Level: %d  |  Leveling: %d", ld.maxLevelCount, ld.levelingCount))
            for _, b in ipairs(ld.brackets) do
                tinsert(lines, format("  %-12s %d", b.label, b.count))
            end
        end

        -- Accounts & Alts
        local am = data.altMainStats
        if am then
            tinsert(lines, "")
            tinsert(lines, "ACCOUNTS & ALTS")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Unique Mains: %d  |  Total Chars: %d", am.uniqueMains, am.totalChars))
            tinsert(lines, format("  Avg Alts/Main: %s  |  Main Activity: %d%%",
                tostring(am.avgAltsPerMain), math.floor(am.mainActivityRate * 100 + 0.5)))
        end

        -- Achievement Points
        local ach = data.achieveStats
        if ach then
            tinsert(lines, "")
            tinsert(lines, "ACHIEVEMENT POINTS")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Average: %d  |  Range: %d - %d", ach.avgPoints, ach.minPoints, ach.maxPoints))
            for _, b in ipairs(ach.brackets) do
                tinsert(lines, format("  %-18s %d", b.label, b.count))
            end
        end

        -- Applicants
        local ap = data.applicantStats
        if ap then
            tinsert(lines, "")
            tinsert(lines, "APPLICANT HISTORY")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Total: %d  |  Accepted: %d  |  Declined: %d  |  Rate: %d%%",
                ap.total, ap.accepted, ap.declined, math.floor(ap.rate * 100 + 0.5)))
        end

        -- Ban List
        local bl = data.banListStats
        if bl then
            tinsert(lines, "")
            tinsert(lines, "BAN LIST")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  Total Bans: %d  |  Recent Rejoins (30d): %d", bl.totalBans, bl.recentRejoins))
        end

        -- Activity Trends
        local trends = data.activityTrends
        if trends then
            tinsert(lines, "")
            tinsert(lines, "ACTIVITY TRENDS (8 Weeks)")
            tinsert(lines, string.rep("-", 40))
            tinsert(lines, format("  %-12s %8s %8s %8s", "Week", "Active", "Joins", "Leaves"))
            for _, bucket in ipairs(trends) do
                tinsert(lines, format("  %-12s %8d %8d %8d",
                    bucket.weekLabel, bucket.activeCount, bucket.joins, bucket.leaves))
            end
        end

        -- Action Items
        local items = data.actionItems
        if items and #items > 0 then
            tinsert(lines, "")
            tinsert(lines, "ACTION ITEMS")
            tinsert(lines, string.rep("-", 40))
            for _, item in ipairs(items) do
                local prefix = item.severity == "danger" and "!!" or
                    item.severity == "warning" and "!" or
                    item.severity == "success" and "+" or "-"
                tinsert(lines, format("  [%s] %s", prefix, item.text))
            end
        end

        tinsert(lines, "")
        tinsert(lines, string.rep("=", 60))
        tinsert(lines, "Generated by RosterForge v" .. RF.VERSION)

        RF:ShowExportPopup(table.concat(lines, "\n"))
    end)

    -- Refresh on show
    parent:SetScript("OnShow", function()
        RefreshDashboard()
    end)

    -- Initial refresh (LayoutCards sets scroll height)
    RefreshDashboard()
end
