local addonName, RF = ...

-------------------------------------------------------------------------------
-- AnalyticsEngine: Guild health metrics, retention risk, activity trends
-- All computations derived from existing TrackingEngine + RosterCache data.
-------------------------------------------------------------------------------

local DAY = 86400
local WEEK = 604800

local function GetActiveThreshold()
    return (RF.charDB and RF.charDB.dashboardActiveDays or 7) * DAY
end

local function GetEngagedThreshold()
    local days = RF.charDB and RF.charDB.dashboardActiveDays or 7
    return math.max(1, math.floor(days * 3 / 7)) * DAY
end

-- Cache to avoid recomputing on every UI interaction
RF._analyticsCache = nil
RF._analyticsCacheTime = 0

-------------------------------------------------------------------------------
-- Retention Tiers: Bucket each member by last-online time
-------------------------------------------------------------------------------
function RF:ComputeRetentionTiers()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local tiers = {}
    for _, tier in ipairs(RF.RETENTION_TIERS) do
        tiers[tier.key] = { count = 0, members = {} }
    end

    for _, entry in ipairs(RF.rosterData) do
        local offlineSec = entry.lastOnlineSeconds or 0
        local offlineDays = offlineSec / DAY
        local placed = false

        for _, tier in ipairs(RF.RETENTION_TIERS) do
            if tier.maxDays and offlineDays < tier.maxDays then
                local t = tiers[tier.key]
                t.count = t.count + 1
                tinsert(t.members, {
                    name = entry.name,
                    classFile = entry.classFile,
                    lastOnlineText = entry.lastOnlineText,
                })
                placed = true
                break
            end
        end

        -- Falls through to "gone" (maxDays == nil)
        if not placed then
            local t = tiers["gone"]
            t.count = t.count + 1
            tinsert(t.members, {
                name = entry.name,
                classFile = entry.classFile,
                lastOnlineText = entry.lastOnlineText,
            })
        end
    end

    return tiers
end

-------------------------------------------------------------------------------
-- Vitality Score: Weighted 0-100 composite health metric
-------------------------------------------------------------------------------
function RF:ComputeVitalityScore()
    if not RF.rosterData or #RF.rosterData == 0 then
        return 0, { activeRatio = 0, retentionRate = 0.5, growthTrend = 0.5, engagementDepth = 0 }
    end

    local total = #RF.rosterData
    local activeCount = 0
    local engagedCount = 0
    local activeThresh = GetActiveThreshold()
    local engagedThresh = GetEngagedThreshold()

    for _, entry in ipairs(RF.rosterData) do
        local offlineSec = entry.lastOnlineSeconds or 0
        if offlineSec < activeThresh then
            activeCount = activeCount + 1
        end
        if offlineSec < engagedThresh then
            engagedCount = engagedCount + 1
        end
    end

    -- Sub-metric 1: Active ratio (weight 35)
    local activeRatio = activeCount / total

    -- Sub-metric 2: Retention rate from log (weight 30)
    local retentionRate = 0.5  -- default neutral
    local growthTrend = 0.5    -- default neutral
    local guildDB = RF:GetGuildDB()
    if guildDB then
        local log = guildDB.tracking.log
        local cutoff = time() - 30 * DAY
        local leaves = 0
        local joins = 0
        for _, entry in ipairs(log) do
            if entry.time >= cutoff then
                if entry.type == "LEAVE" then
                    leaves = leaves + 1
                elseif entry.type == "JOIN" then
                    joins = joins + 1
                end
            end
        end
        if total > 0 then
            retentionRate = math.max(0, 1 - (leaves / total))
        end

        -- Sub-metric 3: Growth trend (weight 20)
        local netGrowth = (joins - leaves) / math.max(total, 1)
        -- Clamp to -1..1, then map to 0..1
        netGrowth = math.max(-1, math.min(1, netGrowth))
        growthTrend = (netGrowth + 1) / 2
    end

    -- Sub-metric 4: Engagement depth (weight 15)
    local engagementDepth = 0
    if activeCount > 0 then
        engagementDepth = engagedCount / activeCount
    end

    local score = activeRatio * 35 + retentionRate * 30 + growthTrend * 20 + engagementDepth * 15
    score = math.max(0, math.min(100, score))

    return math.floor(score + 0.5), {
        activeRatio = activeRatio,
        retentionRate = retentionRate,
        growthTrend = growthTrend,
        engagementDepth = engagementDepth,
    }
end

-------------------------------------------------------------------------------
-- Class Distribution: Active vs total counts per class
-------------------------------------------------------------------------------
function RF:ComputeClassDistribution()
    local activeCounts = {}
    local totalCounts = {}
    for _, class in ipairs(RF.ALL_CLASSES) do
        activeCounts[class] = 0
        totalCounts[class] = 0
    end

    if not RF.rosterData then
        return activeCounts, totalCounts, RF.ALL_CLASSES
    end

    local activeThresh = GetActiveThreshold()
    for _, entry in ipairs(RF.rosterData) do
        local cf = entry.classFile
        if cf then
            totalCounts[cf] = (totalCounts[cf] or 0) + 1
            local offlineSec = entry.lastOnlineSeconds or 0
            if offlineSec < activeThresh then
                activeCounts[cf] = (activeCounts[cf] or 0) + 1
            end
        end
    end

    -- Find gaps (0 active members)
    local gaps = {}
    for _, class in ipairs(RF.ALL_CLASSES) do
        if activeCounts[class] == 0 then
            tinsert(gaps, class)
        end
    end

    return activeCounts, totalCounts, gaps
end

-------------------------------------------------------------------------------
-- Activity Trends: 8-week trailing join/leave + active counts
-------------------------------------------------------------------------------
function RF:ComputeActivityTrends()
    local guildDB = RF:GetGuildDB()
    local now = time()
    local buckets = {}

    for i = 0, 7 do
        buckets[i + 1] = {
            weekIndex = i,
            weekLabel = i == 0 and "This Week" or (i .. "w ago"),
            joins = 0,
            leaves = 0,
            activeCount = 0,
        }
    end

    -- Count joins/leaves from log
    if guildDB then
        local log = guildDB.tracking.log
        for _, entry in ipairs(log) do
            local age = now - entry.time
            local weekIdx = math.floor(age / WEEK)
            if weekIdx >= 0 and weekIdx <= 7 then
                local bucket = buckets[weekIdx + 1]
                if entry.type == "JOIN" then
                    bucket.joins = bucket.joins + 1
                elseif entry.type == "LEAVE" then
                    bucket.leaves = bucket.leaves + 1
                end
            end
        end

        -- Fill active counts from snapshots for past weeks
        local snapshots = guildDB.analytics.snapshots
        for _, snap in ipairs(snapshots) do
            local age = now - snap.time
            local weekIdx = math.floor(age / WEEK)
            if weekIdx >= 0 and weekIdx <= 7 then
                buckets[weekIdx + 1].activeCount = snap.activeMembers or 0
            end
        end
    end

    -- Current week active count is live
    if RF.rosterData then
        local active = 0
        local activeThresh = GetActiveThreshold()
        for _, entry in ipairs(RF.rosterData) do
            local offlineSec = entry.lastOnlineSeconds or 0
            if offlineSec < activeThresh then
                active = active + 1
            end
        end
        buckets[1].activeCount = active
    end

    return buckets
end

-------------------------------------------------------------------------------
-- Action Items: Auto-generated officer checklist
-------------------------------------------------------------------------------
function RF:ComputeActionItems()
    local items = {}

    -- 1. Members inactive 30+ days
    local tiers = RF:ComputeRetentionTiers()
    if tiers then
        local atRiskCount = tiers["atRisk"].count + tiers["gone"].count
        if atRiskCount > 0 then
            tinsert(items, {
                text = atRiskCount .. " member" .. (atRiskCount > 1 and "s" or "") .. " inactive 30+ days",
                severity = "warning",
            })
        end
    end

    -- 2. Missing class coverage
    local _, _, gaps = RF:ComputeClassDistribution()
    if gaps and #gaps > 0 then
        local names = {}
        for _, class in ipairs(gaps) do
            -- Convert "DEATHKNIGHT" -> "Death Knight" etc.
            local display = class:sub(1, 1) .. class:sub(2):lower()
            display = display:gsub("knight", " Knight"):gsub("hunter", "Hunter"):gsub("demon", "Demon ")
            tinsert(names, display)
        end
        if #names <= 3 then
            tinsert(items, {
                text = "No active " .. table.concat(names, ", "),
                severity = "info",
            })
        else
            tinsert(items, {
                text = #names .. " classes with no active members",
                severity = "info",
            })
        end
    end

    -- 3. Retention change vs previous snapshot
    local guildDB = RF:GetGuildDB()
    if guildDB then
        local snapshots = guildDB.analytics.snapshots
        if #snapshots >= 2 then
            local prev = snapshots[#snapshots - 1]
            local curr = snapshots[#snapshots]
            if prev.vitalityScore and curr.vitalityScore then
                local diff = curr.vitalityScore - prev.vitalityScore
                if diff <= -10 then
                    tinsert(items, {
                        text = format("Vitality dropped %d points since last week", math.abs(diff)),
                        severity = "danger",
                    })
                elseif diff >= 10 then
                    tinsert(items, {
                        text = format("Vitality up %d points since last week", diff),
                        severity = "success",
                    })
                end
            end
        end

        -- 4. New members who haven't come back
        local now = time()
        local ghostCount = 0
        if guildDB.tracking and guildDB.tracking.members then
            for name, data in pairs(guildDB.tracking.members) do
                if data.joinDate and not data.leftDate then
                    local joinAge = now - data.joinDate
                    if joinAge < 14 * DAY and joinAge > 3 * DAY then
                        -- Joined in last 14 days but more than 3 days ago
                        local member = RF.rosterByName and RF.rosterByName[name]
                        if member then
                            local offlineSec = member.lastOnlineSeconds or 0
                            if offlineSec > 7 * DAY then
                                ghostCount = ghostCount + 1
                            end
                        end
                    end
                end
            end
        end
        if ghostCount > 0 then
            tinsert(items, {
                text = ghostCount .. " new member" .. (ghostCount > 1 and "s" or "") .. " haven't returned since joining",
                severity = "warning",
            })
        end

        -- 5. Net member loss (leaves > joins in last 30 days)
        local cutoff30 = now - 30 * DAY
        local joins30, leaves30 = 0, 0
        for _, entry in ipairs(guildDB.tracking.log) do
            if entry.time >= cutoff30 then
                if entry.type == "JOIN" then
                    joins30 = joins30 + 1
                elseif entry.type == "LEAVE" then
                    leaves30 = leaves30 + 1
                end
            end
        end
        if leaves30 > joins30 and (leaves30 - joins30) >= 3 then
            tinsert(items, {
                text = format("Net loss of %d members in the last 30 days (%d left, %d joined)", leaves30 - joins30, leaves30, joins30),
                severity = "warning",
            })
        end

        -- 6. Missing profession coverage
        local profData = RF:ComputeProfessionCoverage()
        if profData and profData.missing and #profData.missing > 0 then
            local missing = profData.missing
            if #missing <= 3 then
                tinsert(items, {
                    text = "No active " .. table.concat(missing, ", ") .. " crafter" .. (#missing > 1 and "s" or ""),
                    severity = "info",
                })
            else
                tinsert(items, {
                    text = #missing .. " professions with no active crafters",
                    severity = "info",
                })
            end
        end

        -- 7. Rank-heavy roster (members at lowest rank for 30+ days without promotion)
        local rankData = RF:ComputeRankTenure()
        if rankData and rankData.rankDist then
            local numRanks = #rankData.rankDist
            if numRanks >= 2 then
                -- Find the lowest rank (highest rankIndex)
                local lowestRank = rankData.rankDist[numRanks]
                local total = 0
                for _, rd in ipairs(rankData.rankDist) do total = total + rd.count end
                if total > 0 and lowestRank.count / total >= 0.5 then
                    tinsert(items, {
                        text = format("%d%% of members are at the lowest rank (%s) - consider reviewing promotions", math.floor(lowestRank.count / total * 100), lowestRank.rankName),
                        severity = "info",
                    })
                end
            end
        end

        -- 8. Roster cap approaching (WoW guild cap is 1000)
        if RF.rosterData then
            local memberCount = #RF.rosterData
            if memberCount >= 950 then
                tinsert(items, {
                    text = format("Guild roster at %d/1000 - approaching cap", memberCount),
                    severity = "danger",
                })
            elseif memberCount >= 900 then
                tinsert(items, {
                    text = format("Guild roster at %d/1000 - nearing cap", memberCount),
                    severity = "warning",
                })
            end
        end

        -- 9. Stale analytics snapshots (no snapshot in 14+ days)
        local lastSnap = guildDB.analytics.lastSnapshotTime or 0
        if lastSnap > 0 and (now - lastSnap) > 14 * DAY then
            local staleDays = math.floor((now - lastSnap) / DAY)
            tinsert(items, {
                text = format("Analytics snapshot is %d days old - open Health tab to refresh", staleDays),
                severity = "info",
            })
        end

        -- 10. Stale MOTD (hasn't changed in 30+ days)
        if guildDB.lastMotdTime then
            local motdAge = now - guildDB.lastMotdTime
            if motdAge > 30 * DAY then
                local motdDays = math.floor(motdAge / DAY)
                tinsert(items, {
                    text = format("Guild MOTD unchanged for %d days", motdDays),
                    severity = "info",
                })
            end
        end

        -- 11. Untagged alts (members at alt rank not linked in alt system)
        local altRankIdx = RF.charDB and RF.charDB.altAutoRankIndex
        if altRankIdx and RF.rosterData then
            local untaggedCount = 0
            local alts = guildDB.alts
            for _, member in ipairs(RF.rosterData) do
                if member.rankIndex == altRankIdx then
                    local name = member.name
                    if not alts.links[name] then
                        untaggedCount = untaggedCount + 1
                    end
                end
            end
            if untaggedCount > 0 then
                tinsert(items, {
                    text = untaggedCount .. " member" .. (untaggedCount > 1 and "s" or "") .. " at alt rank but not linked to a main",
                    severity = "warning",
                })
            end
        end
    end

    -- Filter out dismissed (snoozed) items
    local guildDB = guildDB or RF:GetGuildDB()
    if guildDB and guildDB.dismissedActionItems then
        local now = time()
        -- Purge expired dismissals
        for key, expiry in pairs(guildDB.dismissedActionItems) do
            if now >= expiry then
                guildDB.dismissedActionItems[key] = nil
            end
        end
        -- Filter active dismissals
        local filtered = {}
        for _, item in ipairs(items) do
            if not guildDB.dismissedActionItems[item.text] then
                tinsert(filtered, item)
            end
        end
        items = filtered
    end

    if #items == 0 then
        tinsert(items, {
            text = "No action items right now",
            severity = "none",
        })
    end

    return items
end

-------------------------------------------------------------------------------
-- Dismiss (snooze) an action item for a duration
-------------------------------------------------------------------------------
function RF:DismissActionItem(text, durationSeconds)
    local guildDB = RF:GetGuildDB()
    if not guildDB then return end
    if not guildDB.dismissedActionItems then guildDB.dismissedActionItems = {} end
    guildDB.dismissedActionItems[text] = time() + durationSeconds
    RF._analyticsCache = nil
end

-------------------------------------------------------------------------------
-- M+ Rating Stats
-------------------------------------------------------------------------------
function RF:ComputeMPlusStats()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local totalRating = 0
    local ratedCount = 0
    local classRatingSum = {}
    local classRatedCount = {}
    local performers = {}
    local tierCounts = {}
    for _, tier in ipairs(RF.MPLUS_TIERS) do
        tierCounts[tier.key] = 0
    end

    for _, entry in ipairs(RF.rosterData) do
        local rating = entry.mplusRating or 0
        local cf = entry.classFile

        -- Tier bucketing
        if rating <= 0 then
            tierCounts["none"] = tierCounts["none"] + 1
        else
            local placed = false
            for _, tier in ipairs(RF.MPLUS_TIERS) do
                if tier.key ~= "none" and rating <= tier.max then
                    tierCounts[tier.key] = tierCounts[tier.key] + 1
                    placed = true
                    break
                end
            end
            if not placed then
                tierCounts["high"] = tierCounts["high"] + 1
            end
        end

        if rating > 0 then
            totalRating = totalRating + rating
            ratedCount = ratedCount + 1
            tinsert(performers, {
                name = entry.name,
                classFile = cf,
                rating = rating,
                ratingColor = entry.mplusRatingColor,
            })
            if cf then
                classRatingSum[cf] = (classRatingSum[cf] or 0) + rating
                classRatedCount[cf] = (classRatedCount[cf] or 0) + 1
            end
        end
    end

    -- Top performers (configurable count)
    table.sort(performers, function(a, b) return a.rating > b.rating end)
    local topCount = RF.charDB and RF.charDB.dashboardTopPerformers or 5
    local topPerformers = {}
    for i = 1, math.min(topCount, #performers) do
        topPerformers[i] = performers[i]
    end

    -- Per-class averages
    local classMPlusAvg = {}
    for cf, sum in pairs(classRatingSum) do
        classMPlusAvg[cf] = math.floor(sum / classRatedCount[cf] + 0.5)
    end

    return {
        avgRating = ratedCount > 0 and math.floor(totalRating / ratedCount + 0.5) or 0,
        ratedCount = ratedCount,
        classMPlusAvg = classMPlusAvg,
        topPerformers = topPerformers,
        tierBreakdown = tierCounts,
    }
end

-------------------------------------------------------------------------------
-- Rank & Tenure
-------------------------------------------------------------------------------
function RF:ComputeRankTenure()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local guildDB = RF:GetGuildDB()
    local now = time()

    -- Rank distribution
    local rankMap = {}
    for _, entry in ipairs(RF.rosterData) do
        local ri = entry.rankIndex or 0
        local rn = entry.rankName or ("Rank " .. ri)
        if not rankMap[ri] then
            rankMap[ri] = { rankName = rn, rankIndex = ri, count = 0 }
        end
        rankMap[ri].count = rankMap[ri].count + 1
    end
    local rankDist = {}
    for _, v in pairs(rankMap) do tinsert(rankDist, v) end
    table.sort(rankDist, function(a, b) return a.rankIndex < b.rankIndex end)

    -- Tenure buckets
    local tenureBuckets = {}
    for _, bucket in ipairs(RF.TENURE_BUCKETS) do
        tenureBuckets[bucket.key] = 0
    end
    local totalTenure = 0
    local tenureCount = 0

    if guildDB and guildDB.tracking and guildDB.tracking.members then
        for name, data in pairs(guildDB.tracking.members) do
            if data.joinDate and not data.leftDate then
                local ageDays = (now - data.joinDate) / DAY
                totalTenure = totalTenure + ageDays
                tenureCount = tenureCount + 1
                local placed = false
                for _, bucket in ipairs(RF.TENURE_BUCKETS) do
                    if bucket.maxDays and ageDays < bucket.maxDays then
                        tenureBuckets[bucket.key] = tenureBuckets[bucket.key] + 1
                        placed = true
                        break
                    end
                end
                if not placed then
                    tenureBuckets["veteran"] = tenureBuckets["veteran"] + 1
                end
            end
        end
    end

    -- Recent rank changes from log
    local promotions = 0
    local demotions = 0
    if guildDB then
        local cutoff = now - 30 * DAY
        for _, entry in ipairs(guildDB.tracking.log) do
            if entry.time >= cutoff and entry.type == "RANK" then
                local msg = entry.msg or ""
                if msg:lower():find("promot") then
                    promotions = promotions + 1
                elseif msg:lower():find("demot") then
                    demotions = demotions + 1
                end
            end
        end
    end

    return {
        rankDist = rankDist,
        tenureBuckets = tenureBuckets,
        recentChanges = { promotions = promotions, demotions = demotions },
        avgTenureDays = tenureCount > 0 and math.floor(totalTenure / tenureCount + 0.5) or 0,
    }
end

-------------------------------------------------------------------------------
-- Profession Coverage
-------------------------------------------------------------------------------
function RF:ComputeProfessionCoverage()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local profCounts = {}
    for _, prof in ipairs(RF.ALL_PROFESSIONS) do
        profCounts[prof] = 0
    end

    local totalWithProfs = 0
    local activeThresh = GetActiveThreshold()
    for _, entry in ipairs(RF.rosterData) do
        local offlineSec = entry.lastOnlineSeconds or 0
        if offlineSec < activeThresh then
            local hasProf = false
            for _, pKey in ipairs({ "profession1Name", "profession2Name" }) do
                local pName = entry[pKey]
                if pName and pName ~= "" then
                    profCounts[pName] = (profCounts[pName] or 0) + 1
                    hasProf = true
                end
            end
            if hasProf then totalWithProfs = totalWithProfs + 1 end
        end
    end

    local covered = {}
    local missing = {}
    for _, prof in ipairs(RF.ALL_PROFESSIONS) do
        if (profCounts[prof] or 0) > 0 then
            tinsert(covered, prof)
        else
            tinsert(missing, prof)
        end
    end

    return {
        profCounts = profCounts,
        covered = covered,
        missing = missing,
        totalWithProfs = totalWithProfs,
    }
end

-------------------------------------------------------------------------------
-- Alt/Main Stats
-------------------------------------------------------------------------------
function RF:ComputeAltMainStats()
    local guildDB = RF:GetGuildDB()
    if not guildDB or not guildDB.alts then return nil end

    local mains = guildDB.alts.mains or {}
    local links = guildDB.alts.links or {}

    local uniqueMains = 0
    for _ in pairs(mains) do uniqueMains = uniqueMains + 1 end

    local totalChars = uniqueMains
    for _ in pairs(links) do totalChars = totalChars + 1 end

    local avgAlts = uniqueMains > 0 and ((totalChars - uniqueMains) / uniqueMains) or 0

    -- Main activity rate
    local activeMains = 0
    local activeThresh = GetActiveThreshold()
    if RF.rosterData then
        for mainName in pairs(mains) do
            local member = RF.rosterByName and RF.rosterByName[mainName]
            if member and (member.lastOnlineSeconds or 0) < activeThresh then
                activeMains = activeMains + 1
            end
        end
    end

    return {
        uniqueMains = uniqueMains,
        totalChars = totalChars,
        avgAltsPerMain = math.floor(avgAlts * 10 + 0.5) / 10,
        mainActivityRate = uniqueMains > 0 and (activeMains / uniqueMains) or 0,
    }
end

-------------------------------------------------------------------------------
-- Level Distribution
-------------------------------------------------------------------------------
function RF:ComputeLevelDistribution()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local maxLevel = RF.MAX_PLAYER_LEVEL
    local maxLevelCount = 0
    local levelingCount = 0

    local brackets = {
        { label = "1 - 20",  min = 1,  max = 20,  count = 0 },
        { label = "21 - 40", min = 21, max = 40,  count = 0 },
        { label = "41 - 60", min = 41, max = 60,  count = 0 },
        { label = "61 - 80", min = 61, max = 80,  count = 0 },
        { label = "81 - 89", min = 81, max = 89,  count = 0 },
        { label = tostring(maxLevel), min = maxLevel, max = maxLevel, count = 0 },
    }

    for _, entry in ipairs(RF.rosterData) do
        local lvl = entry.level or 0
        if lvl >= maxLevel then
            maxLevelCount = maxLevelCount + 1
        elseif lvl > 0 then
            levelingCount = levelingCount + 1
        end
        for _, bracket in ipairs(brackets) do
            if lvl >= bracket.min and lvl <= bracket.max then
                bracket.count = bracket.count + 1
                break
            end
        end
    end

    return {
        maxLevelCount = maxLevelCount,
        levelingCount = levelingCount,
        brackets = brackets,
    }
end

-------------------------------------------------------------------------------
-- Achievement Points Stats
-------------------------------------------------------------------------------
function RF:ComputeAchievementStats()
    if not RF.rosterData or #RF.rosterData == 0 then return nil end

    local total = 0
    local count = 0
    local minPts = math.huge
    local maxPts = 0

    local brackets = {
        { label = "0 - 5,000",      min = 0,     max = 5000,  count = 0 },
        { label = "5,001 - 15,000", min = 5001,  max = 15000, count = 0 },
        { label = "15,001 - 30,000",min = 15001, max = 30000, count = 0 },
        { label = "30,000+",        min = 30001, max = 999999,count = 0 },
    }

    for _, entry in ipairs(RF.rosterData) do
        local pts = entry.achievementPoints or 0
        if pts > 0 then
            total = total + pts
            count = count + 1
            if pts < minPts then minPts = pts end
            if pts > maxPts then maxPts = pts end
        end
        for _, bracket in ipairs(brackets) do
            if pts >= bracket.min and pts <= bracket.max then
                bracket.count = bracket.count + 1
                break
            end
        end
    end

    if count == 0 then minPts = 0 end

    return {
        avgPoints = count > 0 and math.floor(total / count + 0.5) or 0,
        minPoints = minPts,
        maxPoints = maxPts,
        brackets = brackets,
    }
end

-------------------------------------------------------------------------------
-- Applicant Conversion Stats
-------------------------------------------------------------------------------
function RF:ComputeApplicantStats()
    local guildDB = RF:GetGuildDB()
    if not guildDB or not guildDB.applicantHistory then return nil end

    local hist = guildDB.applicantHistory
    local total = #hist
    local accepted = 0
    local declined = 0

    for _, app in ipairs(hist) do
        local status = app.status or ""
        if status == "accepted" then
            accepted = accepted + 1
        elseif status == "declined" then
            declined = declined + 1
        end
    end

    return {
        total = total,
        accepted = accepted,
        declined = declined,
        rate = total > 0 and (accepted / total) or 0,
    }
end

-------------------------------------------------------------------------------
-- Ban List Stats
-------------------------------------------------------------------------------
function RF:ComputeBanListStats()
    local guildDB = RF:GetGuildDB()
    if not guildDB then return nil end

    local totalBans = 0
    if guildDB.tracking and guildDB.tracking.banList then
        for _ in pairs(guildDB.tracking.banList) do
            totalBans = totalBans + 1
        end
    end

    local recentRejoins = 0
    if guildDB.tracking and guildDB.tracking.log then
        local cutoff = time() - 30 * DAY
        for _, entry in ipairs(guildDB.tracking.log) do
            if entry.time >= cutoff and entry.type == "BAN_REJOIN" then
                recentRejoins = recentRejoins + 1
            end
        end
    end

    return {
        totalBans = totalBans,
        recentRejoins = recentRejoins,
    }
end

-------------------------------------------------------------------------------
-- Snapshot: Save weekly analytics snapshot
-------------------------------------------------------------------------------
function RF:TakeAnalyticsSnapshot()
    local guildDB = RF:GetGuildDB()
    if not guildDB then return end
    if not RF.rosterData or #RF.rosterData == 0 then return end

    local now = time()
    if now - guildDB.analytics.lastSnapshotTime < RF.ANALYTICS_SNAPSHOT_INTERVAL then
        return -- too soon
    end

    local score = RF:ComputeVitalityScore()

    local activeCount = 0
    local activeThresh = GetActiveThreshold()
    for _, entry in ipairs(RF.rosterData) do
        if (entry.lastOnlineSeconds or 0) < activeThresh then
            activeCount = activeCount + 1
        end
    end

    -- Count joins/leaves this week
    local log = guildDB.tracking.log
    local weekAgo = now - WEEK
    local joins, leaves = 0, 0
    for _, entry in ipairs(log) do
        if entry.time >= weekAgo then
            if entry.type == "JOIN" then joins = joins + 1 end
            if entry.type == "LEAVE" then leaves = leaves + 1 end
        end
    end

    tinsert(guildDB.analytics.snapshots, {
        time = now,
        totalMembers = #RF.rosterData,
        activeMembers = activeCount,
        joins = joins,
        leaves = leaves,
        vitalityScore = score,
    })

    -- Cap snapshots
    while #guildDB.analytics.snapshots > RF.MAX_ANALYTICS_SNAPSHOTS do
        tremove(guildDB.analytics.snapshots, 1)
    end

    guildDB.analytics.lastSnapshotTime = now
end

-------------------------------------------------------------------------------
-- Master Data Getter (cached)
-------------------------------------------------------------------------------
function RF:GetAnalyticsData()
    local now = GetTime()
    if RF._analyticsCache and (now - RF._analyticsCacheTime) < 60 then
        return RF._analyticsCache
    end

    -- Try snapshot first
    RF:TakeAnalyticsSnapshot()

    local score, breakdown = RF:ComputeVitalityScore()
    local data = {
        vitalityScore = score,
        vitalityBreakdown = breakdown,
        retentionTiers = RF:ComputeRetentionTiers(),
        classDist = { RF:ComputeClassDistribution() },
        activityTrends = RF:ComputeActivityTrends(),
        actionItems = RF:ComputeActionItems(),
        mplusStats = RF:ComputeMPlusStats(),
        rankTenure = RF:ComputeRankTenure(),
        profCoverage = RF:ComputeProfessionCoverage(),
        altMainStats = RF:ComputeAltMainStats(),
        levelDist = RF:ComputeLevelDistribution(),
        achieveStats = RF:ComputeAchievementStats(),
        applicantStats = RF:ComputeApplicantStats(),
        banListStats = RF:ComputeBanListStats(),
    }

    RF._analyticsCache = data
    RF._analyticsCacheTime = now
    return data
end

-------------------------------------------------------------------------------
-- Invalidate cache on roster refresh
-------------------------------------------------------------------------------
RF:RegisterCallback("ROSTER_REFRESHED", function()
    RF._analyticsCache = nil
end)
