-- ============================================================
-- FILE: MidnightPI.lua
-- PURPOSE: MidnightUI Player Intelligence (MPI) — a Mythic+
--   performance tracking and player-scoring system. Records
--   run outcomes per dungeon, computes per-player performance
--   scores (awareness, survival, output, role efficiency),
--   manages a community database of player profiles (loaded
--   from db_mpi_*.lua), and renders a dungeon dashboard panel
--   integrated into GroupFinder or a standalone overlay.
-- HOW IT WORKS:
--   1. SCORE_TIERS, ROLE_WEIGHTS, KEY_BRACKETS, MASTERY_LEVELS,
--      BADGE_DEFS: constants driving scoring and display.
--   2. MPI.EnsureDB() — initializes _G.MidnightUI_MPI SavedVar
--      (per-player DB: profiles, runs, scores, sync metadata).
--   3. Scoring pipeline: per-run data captured via COMBAT_LOG
--      or encounter events. Score functions (ScoreAwareness,
--      ScoreSurvival, ScoreDpsOutput, etc.) return 0-100 ints.
--      Per-run composite = weighted sum by ROLE_WEIGHTS, then
--      multiplied by Blizzard's par-time linear scaling
--      (ComputeParTimeMultiplier). Overall MPI is a Blizzard-
--      equivalent base × execution-quality multiplier
--      (ComputeOverallFromRuns; v8 model 2026-05-02).
--   4. SnapshotRoster() — reads current group (party/raid)
--      at run start; stores unit GUIDs + names + roles.
--   5. Event pipeline: CHALLENGE_MODE_START → SnapshotRoster
--      + begin tracking; CHALLENGE_MODE_COMPLETED →
--      finalize run data, compute scores, update profiles.
--   6. MPI.BuildDungeonDashboard(parentFrame) — creates the
--      MPI UI panel with per-dungeon mastery grid, recent
--      runs list, player score cards, and badge strips.
--   7. MPI.LoadCommunityDB() — reads MidnightUI_MPI_DB
--      (binary-encoded per-realm data from db_mpi_*.lua) +
--      MidnightUI_MPI_DBMeta; MPI.CommunityLookup(name,realm)
--      decodes and returns community profile for a player.
--   8. MPI.BuildExportData() — serializes local MPI data for
--      export to the MPI Companion App (saved on logout).
--   9. mpi_sync.lua provides sync metadata (lastSync,
--      companionVersion) for the companion app handshake.
-- DEPENDS ON:
--   db_mpi_us/eu/kr/tw.lua (community profile data)
--   db_mpi_metadata.lua (region counts + version)
--   mpi_sync.lua (sync state SavedVar)
--   MidnightUISettings (per-player SavedVar)
-- EXPORTS: _G.MidnightUI_MPI (per-player DB SavedVar)
--   MPI table is module-local; accessed via SavedVar.
-- OWNED SAVEDVARIABLES: _G.MidnightUI_MPI (profiles, runs)
-- EVENTS REGISTERED:
--   ADDON_LOADED, PLAYER_LOGOUT, PLAYER_ENTERING_WORLD
--   CHALLENGE_MODE_START, CHALLENGE_MODE_COMPLETED, CHALLENGE_MODE_RESET
--   ENCOUNTER_START, ENCOUNTER_END
--   GROUP_ROSTER_UPDATE
--   PLAYER_ENTERING_WORLD drives the /combatlog auto-toggle: enable on
--   entering a "party" instance (5-man dungeon) so WoW writes the
--   CHALLENGE_MODE_START line to the log; disable on exit so log files
--   don't accumulate during open-world / city play.
-- FRAMES CREATED:
--   MPI dashboard container (parented to GroupFinder panel)
--   Per-dungeon: mastery card frames, score row frames
-- GOTCHAS:
--   Community DB data is binary-encoded (packed bytes) per
--   realm for size efficiency — use MPI.CommunityLookup to
--   decode; never read MidnightUI_MPI_DB["us"].data directly.
--   mpi_sync.lua (MidnightUI_MPI_Sync global) is written by
--   the external MPI Companion App, not by this Lua file.
--   AUG_EVOKER_SPEC_ID = 1473 requires updating if Blizzard
--   changes the Augmentation Evoker spec ID.
-- ============================================================

local ADDON_NAME = "MidnightUI"
local W8 = "Interface\\Buttons\\WHITE8X8"
local TITLE_FONT = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\FRIZQT__.TTF")) or "Fonts\\FRIZQT__.TTF"
local BODY_FONT  = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\FRIZQT__.TTF")) or "Fonts\\FRIZQT__.TTF"

local pcall, type, pairs, ipairs, math, string, table, select =
      pcall, type, pairs, ipairs, math, string, table, select
local tostring, tonumber, time, date, floor, ceil, min, max, abs, sqrt =
      tostring, tonumber, time, date, math.floor, math.ceil, math.min, math.max, math.abs, math.sqrt
local CreateFrame, UIParent, GameTooltip = CreateFrame, UIParent, GameTooltip
local GetTime, InCombatLockdown = GetTime, InCombatLockdown
local UnitGUID, UnitName, UnitClass, UnitLevel = UnitGUID, UnitName, UnitClass, UnitLevel
local UnitGroupRolesAssigned, GetNumGroupMembers = UnitGroupRolesAssigned, GetNumGroupMembers
local IsInGroup, IsInRaid, GetInstanceInfo = IsInGroup, IsInRaid, GetInstanceInfo
local GetSpecialization, GetSpecializationInfo = GetSpecialization, GetSpecializationInfo
local RAID_CLASS_COLORS = RAID_CLASS_COLORS
local strsplit = strsplit

local MPI_VERSION   = 1
local MAX_RUNS      = 100
local MAX_PROFILES  = 500
local TREND_WINDOW  = 5
local SCORE_WINDOW  = 10

-- Module table. Declared here (before any function MPI.X defs below) so that
-- later `function MPI.X()` sugar can index it. An earlier refactor left the
-- declaration further down the file, which caused the whole chunk to abort.
local MPI = {}

-- Tier table aligned with mpi.atyzi.com (js/mpi.js TIERS at line ~10).
-- Cutoffs and colors must match the website so the in-game pill, the
-- run sheet header, and any shared screenshot agree on letter + hue.
-- Format: { minScore, letter, r, g, b } where rgb is 0-1 (Blizzard's
-- :SetTextColor takes that range, not 0-255).
--
-- Website uses 5 tiers (no F). The convention is "D = Emerging" rather
-- than a failing grade — we're tracking M+ progress, not grading exams.
local SCORE_TIERS = {
    { 90, "S", 1.00, 0.84, 0.00 },  -- #ffd700  Elite      gold
    { 75, "A", 0.65, 0.55, 0.98 },  -- #a78bfa  Great      purple
    { 60, "B", 0.40, 0.70, 0.95 },  -- #66b3f2  Solid      blue
    { 40, "C", 0.35, 0.88, 0.45 },  -- #59e073  Developing green
    {  0, "D", 1.00, 0.35, 0.35 },  -- #ff5959  Emerging   red
}

-- Word labels paired with tier letters — same as the website's tier table
-- so a player who screenshots one and reads the other doesn't see two
-- different tier names. Surfaced in the MPI pill tooltip alongside the letter.
local TIER_LABELS = {
    S = "Elite",
    A = "Great",
    B = "Solid",
    C = "Developing",
    D = "Emerging",
}

-- v10 (2026-05-02): WCL-derived. Order: { awareness, survival, output, utility, consistency }.
-- See server scoring.php for methodology. Util up across all roles, Awareness down,
-- Healer Survival up, Tank Output down. 510 player observations, +20 vs +12 tiers.
local ROLE_WEIGHTS = {
    TANK   = { 0.10, 0.25, 0.15, 0.35, 0.15 },
    HEALER = { 0.05, 0.35, 0.20, 0.25, 0.15 },
    DPS    = { 0.05, 0.25, 0.25, 0.30, 0.15 },
    AUG    = { 0.10, 0.20, 0.15, 0.35, 0.20 },
}

local AUG_EVOKER_ICONS   = { [5198701] = true }
local AUG_EVOKER_SPEC_ID = 1473

local KEY_BRACKETS = {
    { 2,  5,  "+2-5"   },
    { 6,  9,  "+6-9"   },
    { 10, 13, "+10-13" },
    { 14, 17, "+14-17" },
    { 18, 99, "+18+"   },
}

local MASTERY_LEVELS = {
    MASTERED    = { minTimedRuns = 5, minAvgScore = 70, label = "Mastered",    icon = "star_filled"  },
    COMFORTABLE = { minTimedRuns = 2, minAvgScore = 55, label = "Comfortable", icon = "star_half"    },
    LEARNING    = { minTimedRuns = 0, minAvgScore = 0,  label = "Learning",    icon = "star_empty"   },
}

local BADGE_DEFS = {
    -- Standard badges (earnable on a good run)
    ACTIVE_KICKER  = { id = "AK", label = "Active Kicker",  desc = "15+ interrupts with 30%+ group share and 80%+ availability." },
    CLEAN_RUN      = { id = "CR", label = "Clean Run",      desc = "Zero deaths, zero avoidable damage, zero group wipes." },
    TIMED_PLUS     = { id = "TP", label = "Timed+",         desc = "Timed with 2+ stars on a key at or above your Proven Level." },
    IMPROVER       = { id = "IM", label = "Improver",        desc = "Recent scores up 10+ points. Growing as a player." },
    IRON_WILL      = { id = "IW", label = "Iron Will",      desc = "Finishes 95%+ of runs. This player sees it through." },
    DUNGEON_EXPERT = { id = "DE", label = "Dungeon Expert", desc = "Mastered 4 or more unique dungeons this season." },
    -- Prestige badges (hard to earn)
    FLAWLESS        = { id = "FL", label = "Flawless",         desc = "Zero group deaths and zero avoidable damage. Timed." },
    NO_WIPE         = { id = "NW", label = "No Wipe",          desc = "Zero encounter wipes the entire key." },
    PACESETTER      = { id = "PS", label = "Pacesetter",       desc = "Beat community median for your role/dungeon/key by 15+." },
    MVP             = { id = "MV", label = "MVP",              desc = "Highest MPI in your group for the run." },
    WALL            = { id = "WL", label = "Wall",             desc = "Tank: under 800k avoidable and 80%+ mitigation uptime on a +10+." },
    TRIAGE_MASTER   = { id = "TM", label = "Triage Master",    desc = "Healer: zero group deaths from avoidable damage." },
    REAPER          = { id = "RP", label = "Reaper",           desc = "DPS: top damage, top kicks, zero deaths. Same run." },
    CHAIN_BREAKER   = { id = "CB", label = "Chain Breaker",    desc = "5+ dispels in a single encounter." },
    UNDER_PRESSURE  = { id = "UP", label = "Under Pressure",   desc = "Timed a +15 or higher." },
    PRECISION_KICKER= { id = "PK", label = "Precision Kicker", desc = "100% kick availability — never missed a kick while ready." },
    TITLE_CHASER    = { id = "TC", label = "Title Chaser",     desc = "Proven Level of +15 or higher." },
    -- Elite badges (top-tier Mythic+ players only)
    ASCENDANT       = { id = "AS", label = "Ascendant",        desc = "S-tier MPI (95+) on a timed +15 or higher." },
    DRAGONSLAYER    = { id = "DS", label = "Dragonslayer",     desc = "Timed a +20 or higher. Title-range territory." },
    UNTOUCHABLE     = { id = "UT", label = "Untouchable",      desc = "Timed a +15+ with zero group deaths. Flawless team run." },
    SPEED_DEMON     = { id = "SD", label = "Speed Demon",      desc = "3-star timed a +15+ (40%+ timer remaining)." },
    GODSTREAK       = { id = "GS", label = "Godstreak",        desc = "Ten consecutive timed keys at +10 or higher." },
    -- Mythic tier (literally the best players in the world)
    TRANSCENDENT    = { id = "TR", label = "Transcendent",     desc = "3-star timed +20+ with 95+ MPI, zero group deaths, and zero avoidable damage. The impossible run." },
}

-- Forward-declare reverse lookup; populated at the bottom after BADGE_TIERS.
local BADGE_ID_TO_KEY = {}

-- Badge visual metadata: which rarity tier and what symbol prefix to use in-game.
-- Tiers drive color. Symbols are Unicode chars that render in WoW fonts.
local BADGE_TIERS = {
    -- Standard tier
    AK = { rarity = "standard", symbol = "\xE2\x9A\xA1" },  -- lightning
    CR = { rarity = "standard", symbol = "\xE2\x9C\xA8" },  -- sparkles
    TP = { rarity = "standard", symbol = "\xE2\x98\x85" },  -- star
    IM = { rarity = "standard", symbol = "\xE2\x96\xB2" },  -- triangle up
    IW = { rarity = "standard", symbol = "\xE2\x98\xA3" },  -- club
    DE = { rarity = "standard", symbol = "\xE2\x98\x86" },  -- outlined star
    -- Prestige tier
    FL = { rarity = "prestige", symbol = "\xE2\x9D\x96" },  -- diamond
    NW = { rarity = "prestige", symbol = "\xE2\x9A\x94" },  -- crossed swords
    PS = { rarity = "prestige", symbol = "\xE2\x9C\xA6" },  -- 4-pointed star
    MV = { rarity = "prestige", symbol = "\xE2\x98\xAF" },  -- yin yang
    WL = { rarity = "prestige", symbol = "\xE2\x9A\x94" },  -- crossed swords
    TM = { rarity = "prestige", symbol = "\xE2\x9C\xA4" },  -- teardrop
    RP = { rarity = "prestige", symbol = "\xE2\x98\xA0" },  -- skull
    CB = { rarity = "prestige", symbol = "\xE2\x9B\x93" },  -- chain link
    UP = { rarity = "prestige", symbol = "\xE2\x9A\xAB" },  -- black circle
    PK = { rarity = "prestige", symbol = "\xE2\x97\x89" },  -- target
    TC = { rarity = "prestige", symbol = "\xE2\x99\x9A" },  -- chess king
    -- Elite tier
    AS = { rarity = "elite",    symbol = "\xE2\x98\xBD" },  -- circled star
    DS = { rarity = "elite",    symbol = "\xE2\x9A\x94" },  -- crossed swords
    UT = { rarity = "elite",    symbol = "\xE2\x98\xAF" },  -- yin yang
    SD = { rarity = "elite",    symbol = "\xE2\x9A\xA1" },  -- lightning
    GS = { rarity = "elite",    symbol = "\xE2\x98\xAC" },  -- comet
    -- Mythic tier (the impossible)
    TR = { rarity = "mythic",   symbol = "\xE2\x98\xBE" },  -- circled dot
}

local BADGE_RARITY_COLORS = {
    standard = "ffc5c8dd",  -- soft white-purple
    prestige = "ffffd700",  -- gold
    elite    = "ffff2d92",  -- hot pink
    mythic   = "ffff3b30",  -- red
}

local BADGE_RARITY_LABELS = {
    standard = "Standard",
    prestige = "Prestige",
    elite    = "Elite",
    mythic   = "Mythic",
}

-- Render a single badge as colored Unicode symbol + short label for in-game display.
-- Returns formatted string: |cffXXXXXX◆ LABEL|r
function MPI.FormatBadge(badgeID, withLabel)
    local key = BADGE_ID_TO_KEY[badgeID]
    if not key or not BADGE_DEFS[key] then return "" end
    local info = BADGE_DEFS[key]
    local visual = BADGE_TIERS[badgeID] or { rarity = "standard", symbol = "?" }
    local color = BADGE_RARITY_COLORS[visual.rarity] or BADGE_RARITY_COLORS.standard
    if withLabel then
        return "|c" .. color .. visual.symbol .. " " .. info.label .. "|r"
    end
    return "|c" .. color .. visual.symbol .. "|r"
end

-- Group a list of badge IDs by rarity tier, return {standard={}, prestige={}, elite={}, mythic={}}.
function MPI.GroupBadgesByRarity(badgeIDs)
    local groups = { standard = {}, prestige = {}, elite = {}, mythic = {} }
    if not badgeIDs then return groups end
    for _, bid in ipairs(badgeIDs) do
        local v = BADGE_TIERS[bid]
        local rarity = v and v.rarity or "standard"
        groups[rarity][#groups[rarity] + 1] = bid
    end
    return groups
end

-- Celebration toast shown when the player earns a new badge. Stacks if multiple
-- badges drop at once (staggered via C_Timer.After).
local badgeToastFrame
function MPI.ShowBadgeEarnedToast(badgeID)
    local key = BADGE_ID_TO_KEY[badgeID]
    if not key or not BADGE_DEFS[key] then return end
    local info = BADGE_DEFS[key]
    local visual = BADGE_TIERS[badgeID] or { rarity = "standard", symbol = "?" }
    local rarity = visual.rarity
    local hexColor = BADGE_RARITY_COLORS[rarity] or BADGE_RARITY_COLORS.standard
    -- hex format is "ffXXXXXX"; strip alpha for hex→RGB math
    local r = tonumber(hexColor:sub(3, 4), 16) / 255
    local g = tonumber(hexColor:sub(5, 6), 16) / 255
    local b = tonumber(hexColor:sub(7, 8), 16) / 255
    local rarityLabel = BADGE_RARITY_LABELS[rarity] or "Badge"

    if not badgeToastFrame then
        badgeToastFrame = CreateFrame("Frame", "MidnightUI_MPI_BadgeToast", UIParent, "BackdropTemplate")
        badgeToastFrame:SetSize(380, 90)
        badgeToastFrame:SetPoint("TOP", UIParent, "TOP", 0, -160)
        badgeToastFrame:SetFrameStrata("FULLSCREEN_DIALOG")
        badgeToastFrame:SetBackdrop({
            bgFile = W8, edgeFile = W8, edgeSize = 2,
            insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        badgeToastFrame:SetBackdropColor(0.04, 0.04, 0.07, 0.95)
        badgeToastFrame:Hide()

        badgeToastFrame._symbol = badgeToastFrame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgeToastFrame._symbol, TITLE_FONT, 36, "OUTLINE")
        badgeToastFrame._symbol:SetPoint("LEFT", badgeToastFrame, "LEFT", 20, 0)

        badgeToastFrame._rarity = badgeToastFrame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgeToastFrame._rarity, BODY_FONT, 10, "")
        badgeToastFrame._rarity:SetPoint("TOPLEFT", badgeToastFrame._symbol, "TOPRIGHT", 16, -2)

        badgeToastFrame._title = badgeToastFrame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgeToastFrame._title, TITLE_FONT, 15, "OUTLINE")
        badgeToastFrame._title:SetPoint("TOPLEFT", badgeToastFrame._rarity, "BOTTOMLEFT", 0, -4)

        badgeToastFrame._badgeName = badgeToastFrame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgeToastFrame._badgeName, TITLE_FONT, 18, "OUTLINE")
        badgeToastFrame._badgeName:SetPoint("TOPLEFT", badgeToastFrame._title, "BOTTOMLEFT", 0, -2)

        badgeToastFrame._desc = badgeToastFrame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgeToastFrame._desc, BODY_FONT, 10, "")
        badgeToastFrame._desc:SetPoint("TOPLEFT", badgeToastFrame._badgeName, "BOTTOMLEFT", 0, -3)
        badgeToastFrame._desc:SetPoint("RIGHT", badgeToastFrame, "RIGHT", -14, 0)
        badgeToastFrame._desc:SetJustifyH("LEFT")
        badgeToastFrame._desc:SetTextColor(0.65, 0.67, 0.78)

        badgeToastFrame._accent = badgeToastFrame:CreateTexture(nil, "OVERLAY")
        badgeToastFrame._accent:SetHeight(3)
        badgeToastFrame._accent:SetPoint("TOPLEFT")
        badgeToastFrame._accent:SetPoint("TOPRIGHT")

        -- Single reusable animation group. Earlier each call created a
        -- fresh group + a fresh C_Timer.After fadeout, with no way to
        -- cancel prior ones — when two badges fired in sequence (the
        -- 0.8s stagger in OnChallengeModeCompleted), the first badge's
        -- fadeout could land mid-display of the second, leaving the
        -- frame visible but blank.
        badgeToastFrame._fadeIn = badgeToastFrame:CreateAnimationGroup()
        local aIn = badgeToastFrame._fadeIn:CreateAnimation("Alpha")
        aIn:SetFromAlpha(0); aIn:SetToAlpha(1); aIn:SetDuration(0.3); aIn:SetSmoothing("OUT")
        badgeToastFrame._fadeIn:SetScript("OnFinished", function()
            badgeToastFrame:SetAlpha(1)
        end)

        badgeToastFrame._fadeOut = badgeToastFrame:CreateAnimationGroup()
        local aOut = badgeToastFrame._fadeOut:CreateAnimation("Alpha")
        aOut:SetFromAlpha(1); aOut:SetToAlpha(0); aOut:SetDuration(0.6); aOut:SetSmoothing("IN")
        badgeToastFrame._fadeOut:SetScript("OnFinished", function()
            badgeToastFrame:Hide()
        end)
    end

    -- Cancel anything from a prior toast call before populating: stop
    -- both animation groups and cancel the pending hold timer. Without
    -- this, a stale fadeOut Hide() can fire mid-display and clobber a
    -- brand-new toast.
    if badgeToastFrame._fadeIn:IsPlaying()  then badgeToastFrame._fadeIn:Stop()  end
    if badgeToastFrame._fadeOut:IsPlaying() then badgeToastFrame._fadeOut:Stop() end
    if badgeToastFrame._holdTimer then
        badgeToastFrame._holdTimer:Cancel()
        badgeToastFrame._holdTimer = nil
    end

    badgeToastFrame._symbol:SetText(visual.symbol)
    badgeToastFrame._symbol:SetTextColor(r, g, b)
    badgeToastFrame._rarity:SetText(string.upper(rarityLabel) .. " BADGE EARNED")
    badgeToastFrame._rarity:SetTextColor(r, g, b)
    badgeToastFrame._title:SetText("|cffffffffUnlocked|r")
    badgeToastFrame._badgeName:SetText(info.label)
    badgeToastFrame._badgeName:SetTextColor(1, 1, 1)
    badgeToastFrame._desc:SetText(info.desc)
    badgeToastFrame._accent:SetColorTexture(r, g, b, 1)
    badgeToastFrame:SetBackdropBorderColor(r, g, b, 0.6)

    -- Animate in: fade up, hold, fade down.
    badgeToastFrame:SetAlpha(0)
    badgeToastFrame:Show()
    badgeToastFrame._fadeIn:Play()

    local holdSec = (rarity == "mythic") and 8 or (rarity == "elite") and 6 or 4.5
    badgeToastFrame._holdTimer = C_Timer.NewTimer(holdSec, function()
        if not badgeToastFrame or not badgeToastFrame:IsShown() then return end
        badgeToastFrame._fadeOut:Play()
    end)

    -- Mythic/elite extra: play a subtle sound cue if available.
    if PlaySound then
        if rarity == "mythic" then
            pcall(PlaySound, 73277)  -- achievement win sound
        elseif rarity == "elite" then
            pcall(PlaySound, 12891)  -- levelup sound
        end
    end
end

for k, v in pairs(BADGE_DEFS) do BADGE_ID_TO_KEY[v.id] = k end

-- Scoring version. Bumped when formulas change to trigger rescore on addon load.
local SCORING_VERSION = 6

-- Gear tier names are stable across seasons.
-- Ilvl ranges are for Midnight Season 1 — server-configurable for future seasons
-- via MidnightUI_MPI_SeasonConfig (fetched by companion; falls back to this).
local DEFAULT_GEAR_TIERS = {
    { name = "Myth",       min = 272, max = 999, expectedKey = 17 },
    { name = "Hero",       min = 259, max = 276, expectedKey = 13 },
    { name = "Champion",   min = 246, max = 263, expectedKey = 10 },
    { name = "Veteran",    min = 233, max = 250, expectedKey = 7  },
    { name = "Adventurer", min = 220, max = 237, expectedKey = 4  },
    { name = "Unranked",   min = 0,   max = 219, expectedKey = 2  },
}

-- Avoidable-damage-per-minute benchmarks by role (used when cohort data absent).
-- 100 at or below min; 0 at or above max; linear in between.
-- Tightened for honest scoring on low keys where mob output is low — a tank
-- eating 200k/min on a +2 is real avoidable damage, not "perfect play."
local AVOIDABLE_BENCHMARKS = {
    TANK   = { min =  50000, max = 800000 },
    HEALER = { min =  15000, max = 200000 },
    DPS    = { min =  30000, max = 300000 },
    AUG    = { min =  25000, max = 250000 },
}

-- Main active mitigation ability per tank spec (for uptime tracking).
local TANK_MITIGATION = {
    DRUID       = { spellID = 192081, trackAs = "aura"        }, -- Ironfur
    WARRIOR     = { spellID = 2565,   trackAs = "aura"        }, -- Shield Block
    PALADIN     = { spellID = 132403, trackAs = "aura"        }, -- Shield of the Righteous buff
    DEATHKNIGHT = { spellID = 195181, trackAs = "stackedBuff" }, -- Bone Shield stacks
    MONK        = { spellID = 215479, trackAs = "aura"        }, -- Shuffle
    DEMONHUNTER = { spellID = 203819, trackAs = "aura"        }, -- Demon Spikes buff
}

-- Major defensive cooldowns per class, filtered by role.
-- Used to check "was it off cooldown at time of death?".
local DEFENSIVE_CDS = {
    DRUID = {
        TANK   = { 22812, 61336, 22842, 200851, 102558 }, -- Barkskin, Survival Inst, Frenzied Regen, Rage of Sleeper, Incarnation
        HEALER = { 22812, 61336, 108238, 102342 },        -- Barkskin, Survival Inst, Renewal, Ironbark
        DPS    = { 22812, 61336 },                         -- Barkskin, Survival Inst
    },
    WARRIOR = {
        TANK = { 871, 12975, 23920, 97462, 107574, 184364 }, -- Shield Wall, Last Stand, Spell Reflect, Rallying Cry, Avatar, Enraged Regen
        DPS  = { 118038, 97462, 184364, 23920 },             -- Die by the Sword, Rallying Cry, Enraged Regen, Spell Reflect
    },
    PALADIN = {
        TANK   = { 86659, 31850, 642, 633, 498 },   -- GoAK, Ardent Defender, Divine Shield, Lay on Hands, Divine Protection
        HEALER = { 498, 642, 633, 1022 },            -- Divine Protection, Divine Shield, Lay on Hands, Blessing of Protection
        DPS    = { 642, 184662, 633, 498 },          -- Divine Shield, Shield of Vengeance, Lay on Hands, Divine Protection
    },
    DEATHKNIGHT = {
        TANK = { 55233, 48792, 48707, 49028, 327574 }, -- Vampiric Blood, Icebound Fort, AMS, DRW, Sacrificial Pact
        DPS  = { 48792, 48707, 327574 },                -- Icebound Fort, AMS, Sacrificial Pact
    },
    MONK = {
        TANK   = { 115203, 115176, 132578, 122278, 122783 },
        HEALER = { 115203, 122278, 122783 },
        DPS    = { 122470, 122783, 115203, 122278 },
    },
    DEMONHUNTER = {
        TANK = { 187827, 204021, 263648, 212084 },
        DPS  = { 198589, 196718, 196555 },
    },
    MAGE     = { DPS = { 45438, 342245, 110959, 66 } },
    WARLOCK  = { DPS = { 104773, 108416, 20707 } },
    HUNTER   = { DPS = { 109304, 186265, 5384 } },
    SHAMAN   = {
        HEALER = { 108271, 108270, 207399 },
        DPS    = { 108271, 108270 },
    },
    ROGUE    = { DPS = { 31224, 5277, 1966 } },
    PRIEST   = {
        HEALER = { 586, 19236, 33206 },
        DPS    = { 47585, 586 },
    },
    EVOKER   = {
        HEALER = { 363916, 374348, 374227 },
        DPS    = { 363916, 374348 },
    },
}

-- Dispel capability per class+spec. Values: "magic", "curse", "poison", "disease", "bleed".
local DISPEL_TYPES = {
    PRIEST = {
        HOLY   = { magic = true, disease = true },
        DISC   = { magic = true, disease = true },
        SHADOW = { disease = true },
    },
    PALADIN = {
        HOLY = { magic = true, disease = true, poison = true },
        PROT = { disease = true, poison = true },
        RET  = { disease = true, poison = true },
    },
    SHAMAN = {
        RESTO = { magic = true, curse = true },
        ELE   = { curse = true },
        ENH   = { curse = true },
    },
    DRUID = {
        RESTO    = { magic = true, curse = true, poison = true },
        FERAL    = { curse = true, poison = true },
        BALANCE  = { curse = true, poison = true },
        GUARDIAN = { curse = true, poison = true },
    },
    MONK = {
        MW = { magic = true, disease = true, poison = true },
        BM = { disease = true, poison = true },
        WW = { disease = true, poison = true },
    },
    EVOKER = {
        PRES = { magic = true, poison = true, disease = true, curse = true, bleed = true },
        DEV  = { poison = true, disease = true, curse = true, bleed = true },
        AUG  = { poison = true, disease = true, curse = true, bleed = true },
    },
    MAGE    = { ALL = { curse = true } },
    WARLOCK = { ALL = { magic = true } }, -- Felhunter pet: Singe Magic
    -- Hunter's Tranquilizing Shot purges enemies, not allies — no ally cleanse.
    -- Warrior, Rogue, Death Knight, Demon Hunter have no ally dispel either.
}

-- Interrupt spell IDs + cooldown seconds per class/spec.
local INTERRUPTS = {
    DRUID = {
        FERAL    = { spellID = 106839, cooldown = 15 },
        GUARDIAN = { spellID = 106839, cooldown = 15 },
        BALANCE  = { spellID = 78675,  cooldown = 60 },
    },
    WARRIOR     = { ALL = { spellID = 6552,   cooldown = 15 } },
    ROGUE       = { ALL = { spellID = 1766,   cooldown = 15 } },
    PRIEST      = { SHADOW = { spellID = 15487, cooldown = 45 } },
    SHAMAN      = { ALL = { spellID = 57994,  cooldown = 12 } },
    WARLOCK     = { ALL = { spellID = 19647,  cooldown = 24 } },
    HUNTER      = { ALL = { spellID = 147362, cooldown = 24 } },
    MAGE        = { ALL = { spellID = 2139,   cooldown = 24 } },
    MONK        = { ALL = { spellID = 116705, cooldown = 15 } },
    DEATHKNIGHT = { ALL = { spellID = 47528,  cooldown = 15 } },
    DEMONHUNTER = { ALL = { spellID = 183752, cooldown = 15 } },
    PALADIN = {
        HOLY = { spellID = 96231, cooldown = 15 },
        PROT = { spellID = 31935, cooldown = 15 },
        RET  = { spellID = 96231, cooldown = 15 },
    },
    EVOKER      = { ALL = { spellID = 351338, cooldown = 40 } },
}

-- Capability lookup helper: returns a table for a given class+spec or class+"ALL".
local function LookupClassCapability(tbl, class, spec)
    if not tbl or not class then return nil end
    local classEntry = tbl[class]
    if not classEntry then return nil end
    if spec and classEntry[spec] then return classEntry[spec] end
    if classEntry.ALL then return classEntry.ALL end
    return nil
end

-- Percentile scoring: compare a value to a cohort's p10/p25/p50/p75/p90/p99 array.
-- Returns 0-100. lowerIsBetter=true for metrics like deaths and avoidable damage.
-- Score anchors (higher-is-better): p10→30, p25→50, p50→70, p75→85, p90→95, p99→100.
-- This makes the MEDIAN of the community score 70 — "pulling your weight", not great/bad.
function MPI.PercentileScore(value, percentiles, lowerIsBetter)
    if not percentiles or #percentiles < 6 then return 50 end
    local p10, p25, p50, p75, p90, p99 =
        percentiles[1], percentiles[2], percentiles[3],
        percentiles[4], percentiles[5], percentiles[6]
    value = value or 0

    local function lerp(from, to, span, diff)
        if span <= 0 then return to end
        return from + (diff / span) * (to - from)
    end

    if not lowerIsBetter then
        if value >= p99 then return 100 end
        if value >= p90 then return floor(lerp(95, 100, p99 - p90, value - p90)) end
        if value >= p75 then return floor(lerp(85, 95,  p90 - p75, value - p75)) end
        if value >= p50 then return floor(lerp(70, 85,  p75 - p50, value - p50)) end
        if value >= p25 then return floor(lerp(50, 70,  p50 - p25, value - p25)) end
        if value >= p10 then return floor(lerp(30, 50,  p25 - p10, value - p10)) end
        if p10 > 0 then return max(0, floor(30 * (value / p10))) end
        return 0
    end
    -- Lower is better: smaller = better score.
    if value <= p10 then return 95 end
    if value <= p25 then return floor(lerp(85, 95, p25 - p10, p25 - value)) end
    if value <= p50 then return floor(lerp(70, 85, p50 - p25, p50 - value)) end
    if value <= p75 then return floor(lerp(50, 70, p75 - p50, p75 - value)) end
    if value <= p90 then return floor(lerp(30, 50, p90 - p75, p90 - value)) end
    if value <= p99 then return floor(lerp(10, 30, p99 - p90, p99 - value)) end
    return 0
end

-- Cohort lookup. Cohort keys: "CLASS/ROLE/MAP_ID/KEY_BRACKET".
-- Percentile data is delivered by the companion app into _G.MidnightUI_MPI_Cohorts.
local function KeyBracket(keyLevel)
    if (keyLevel or 0) >= 18 then return "18+" end
    if (keyLevel or 0) >= 14 then return "14-17" end
    if (keyLevel or 0) >= 10 then return "10-13" end
    if (keyLevel or 0) >= 6  then return "6-9" end
    return "2-5"
end

function MPI.GetCohort(class, role, mapId, keyLevel)
    if not _G.MidnightUI_MPI_Cohorts or not class or not role or not mapId then return nil end
    if role == "DAMAGER" then role = "DPS" end
    local key = string.upper(class) .. "/" .. role .. "/" .. tostring(mapId) .. "/" .. KeyBracket(keyLevel)
    local cohortRoot = _G.MidnightUI_MPI_Cohorts.cohorts or _G.MidnightUI_MPI_Cohorts
    return cohortRoot[key]
end

-- ============================================================
-- Active spec → role detection. Returns the SAME role tokens the
-- web overlay uses ("TANK" / "HEALER" / "DPS" / "AUG"), so the
-- result can be compared directly against MidnightUI_MPI_Web.roles[].
-- Aug Evoker is treated as its own role rather than rolling into DPS,
-- matching the dashboard split.
-- Returns nil when no spec is selected (low-level chars, dual-spec
-- swap mid-zone, etc.) — callers should fall back gracefully.
-- ============================================================
function MPI.GetActiveRole()
    if not GetSpecialization or not GetSpecializationInfo then return nil end
    local specIdx = GetSpecialization()
    if not specIdx then return nil end
    local ok, specID, _, _, _, specRole = pcall(GetSpecializationInfo, specIdx)
    if not ok or not specRole then return nil end
    if specRole == "TANK"   then return "TANK" end
    if specRole == "HEALER" then return "HEALER" end
    if specID and specID == AUG_EVOKER_SPEC_ID then return "AUG" end
    return "DPS"
end

-- Number of seasonal dungeons in the current pool. Mirrors PHP's
-- MPI_SEASON_DUNGEON_COUNT so local-computed coverage matches the website.
local SEASON_DUNGEON_COUNT = 8

local function CoverageFactor(timedDungeons)
    if SEASON_DUNGEON_COUNT <= 0 then return 1.0 end
    local ratio = math.max(0, math.min(1, timedDungeons / SEASON_DUNGEON_COUNT))
    return 0.5 + 0.5 * ratio
end

-- Resolve the local player's data block inside a run record. db.runs[i]
-- has a `players` map keyed by GUID; the local player is identified by
-- run.ownerGUID first, then by current UnitGUID, then by isLocalPlayer
-- flag, then by name match. Older runs may not have ownerGUID stamped,
-- which is why the cascade exists.
local function _findLocalInRun(run)
    if type(run) ~= "table" or type(run.players) ~= "table" then return nil end
    local ownerGUID = run.ownerGUID
    if ownerGUID and run.players[ownerGUID] then
        return run.players[ownerGUID]
    end
    local myGUID = UnitGUID and UnitGUID("player")
    if myGUID and run.players[myGUID] then
        return run.players[myGUID]
    end
    local myName = UnitName and UnitName("player")
    for _, p in pairs(run.players) do
        if p.isLocalPlayer then return p end
        local pn = p.name or ""
        if pn == myName then return p end
        local head = pn:match("^([^%-]+)")
        if head and head == myName then return p end
    end
    return nil
end

-- Normalise a single run record (live or imported) into the minimal
-- shape ComputeLocalRoleScore needs. Returns nil when we can't extract
-- a local-player block.
--
-- Two source shapes:
--   * Live (db.runs[i]): player data lives in run.players[ownerGUID]
--     with fields weightRole, mpiScore, <dim>Score.
--   * Imported (MidnightUI_MPI_Imported.runs[i]): per-player my* fields
--     are flattened onto the run itself (matches /php/export_runs.php
--     and /php/upload_runs.php contract).
local function _extractRunSelfData(run)
    if type(run) ~= "table" then return nil end

    -- Imported (upload-shape) detection — myScore is the canonical marker.
    if run.myScore ~= nil or run.myRole ~= nil then
        local role = run.myRole or "DPS"
        if role == "DAMAGER" then role = "DPS" end
        return {
            timestamp    = run.timestamp or 0,
            dungeonMapID = run.dungeonMapID or 0,
            dungeonName  = run.dungeonName or "",
            keyLevel     = run.keyLevel or 0,
            result       = run.result or "depleted",
            durationSec  = run.durationSec or 0,
            timeLimitSec = run.timeLimitSec or 0,
            role         = role,
            mpiScore     = run.myScore or 0,
            awa          = run.myAwareness or 0,
            sur          = run.mySurvival or 0,
            out          = run.myOutput or 0,
            uti          = run.myUtility or 0,
            con          = run.myConsistency or 0,
        }
    end

    -- Live (db.runs) shape — find the local-player block.
    local me = _findLocalInRun(run)
    if not me then return nil end
    local role = me.weightRole or me.role or "DPS"
    if role == "DAMAGER" then role = "DPS" end
    return {
        timestamp    = run.timestamp or 0,
        dungeonMapID = run.dungeonMapID or 0,
        dungeonName  = run.dungeonName or "",
        keyLevel     = run.keyLevel or 0,
        result       = run.result or "depleted",
        durationSec  = run.durationSec or 0,
        timeLimitSec = run.timeLimitSec or 0,
        role         = role,
        mpiScore     = me.mpiScore or 0,
        awa          = me.awarenessScore or 0,
        sur          = me.survivalScore or 0,
        out          = me.outputScore or 0,
        uti          = me.utilityScore or 0,
        con          = me.consistencyScore or 0,
    }
end

-- Compute a per-role score payload from local SavedVar data — both LIVE
-- (db.runs, captured by the addon while playing) and IMPORTED (server
-- history pulled by the Companion / sync tool, stored in
-- MidnightUI_MPI_Imported). Same role-payload shape the website returns.
--
-- Imported runs that match a live run by (timestamp, dungeonMapID) are
-- skipped — live data is richer (full per-player roster, GUIDs, etc.).
function MPI.ComputeLocalRoleScore(role)
    if not role then return nil end
    local db = _G.MidnightUI_MPI
    if type(db) ~= "table" then return nil end

    -- Collect everything that matches the requested role from BOTH sources.
    -- Live runs win on (timestamp, dungeonMapID) collision.
    local self_runs = {}
    local seen = {}

    if type(db.runs) == "table" then
        for _, r in ipairs(db.runs) do
            local x = _extractRunSelfData(r)
            if x and x.role == role then
                self_runs[#self_runs + 1] = x
                seen[x.timestamp .. ":" .. x.dungeonMapID] = true
            end
        end
    end

    local imp = _G.MidnightUI_MPI_Imported
    if type(imp) == "table" and type(imp.runs) == "table" then
        for _, r in ipairs(imp.runs) do
            local x = _extractRunSelfData(r)
            if x and x.role == role then
                local key = x.timestamp .. ":" .. x.dungeonMapID
                if not seen[key] then
                    self_runs[#self_runs + 1] = x
                    seen[key] = true
                end
            end
        end
    end

    if #self_runs == 0 then return nil end

    local n = #self_runs
    local sumMpi, sumAwa, sumSur, sumOut, sumUti, sumCon = 0, 0, 0, 0, 0, 0
    local bestMPI, highestKey, lowestKey = 0, 0, math.huge
    local timedCount = 0
    local timedDungeonSet = {}
    for _, x in ipairs(self_runs) do
        local sc = x.mpiScore
        sumMpi = sumMpi + sc
        sumAwa = sumAwa + x.awa
        sumSur = sumSur + x.sur
        sumOut = sumOut + x.out
        sumUti = sumUti + x.uti
        sumCon = sumCon + x.con
        if sc > bestMPI then bestMPI = sc end
        local kl = x.keyLevel
        if kl > 0 then
            if kl > highestKey then highestKey = kl end
            if kl < lowestKey  then lowestKey  = kl end
        end
        if x.result == "timed" then
            timedCount = timedCount + 1
            if x.dungeonName ~= "" then timedDungeonSet[x.dungeonName] = true end
        end
    end
    if lowestKey == math.huge then lowestKey = 0 end

    -- v8: Blizzard-equivalent base × execution-quality multiplier. Coverage
    -- is implicit in the per-dungeon Blizzard sum (a player who only ran 2
    -- dungeons contributes only 2 dungeons' worth of points).
    local _, _, info = MPI.ComputeOverallFromRuns(self_runs)
    info = info or {}
    local overall      = info.overall or 0
    local verifiedPeak = info.verifiedPeak or 0

    local coverageDungeons = 0
    for _ in pairs(timedDungeonSet) do coverageDungeons = coverageDungeons + 1 end
    -- Legacy coverage factor retained for the dashboard's coverage strip
    -- visual only — no longer multiplies the score.
    local coverageF = CoverageFactor(coverageDungeons)

    return {
        role               = role,
        runs               = n,
        timed              = timedCount,
        rawAvgMPI          = sumMpi / n,
        preCoverageMPI     = overall,
        overallMPI         = overall,
        -- v8 fields:
        mpiBase            = info.mpiBase or 0,
        qualityMultiplier  = info.qualityMultiplier or 1.0,
        blizzardEquivalent = info.blizzardEquivalent or 0,
        -- Retained signals:
        verifiedPeak       = verifiedPeak,
        peakIsVerified     = info.verified or false,
        progressionFactor  = info.progFactor or 0,
        coverageDungeons   = coverageDungeons,
        coverageFactor     = coverageF,
        highestKey         = highestKey,
        lowestKey          = lowestKey,
        bestMPI            = bestMPI,
        avgAwareness       = sumAwa / n,
        avgSurvival        = sumSur / n,
        avgOutput          = sumOut / n,
        avgUtility         = sumUti / n,
        avgConsistency     = sumCon / n,
        _source            = "local",
    }
end

-- Tooltip-facing extras for a role: the best individual run (with
-- dungeon name + key level) and per-dungeon best-key breakdown. Walks
-- the same union of db.runs ∪ MidnightUI_MPI_Imported.runs that
-- ComputeLocalRoleScore uses, with the same dedup. Returned shape:
--
--   {
--     bestRun = { dungeonName, keyLevel, mpiScore, timestamp } | nil,
--     dungeonBests = { ["Skyreach"] = { bestKey, bestScore, timed }, ... },
--   }
function MPI.GetRoleTooltipExtras(role)
    local out = { bestRun = nil, dungeonBests = {} }
    if not role then return out end
    local db = _G.MidnightUI_MPI

    local function eat(x)
        if x.role ~= role then return end
        -- Track best run by mpiScore, ties broken by higher keyLevel.
        if not out.bestRun
           or x.mpiScore > out.bestRun.mpiScore
           or (x.mpiScore == out.bestRun.mpiScore and x.keyLevel > out.bestRun.keyLevel)
        then
            out.bestRun = {
                dungeonName = x.dungeonName,
                keyLevel    = x.keyLevel,
                mpiScore    = x.mpiScore,
                timestamp   = x.timestamp,
            }
        end
        -- Per-dungeon best-timed-key tracker. Untimed attempts don't
        -- bump the dungeon's bestKey but DO set the entry so the row
        -- shows up as "attempted, not yet timed".
        local dn = x.dungeonName
        if dn ~= "" then
            local cur = out.dungeonBests[dn]
            if not cur then
                cur = { bestKey = 0, bestScore = 0, timed = false }
                out.dungeonBests[dn] = cur
            end
            if x.result == "timed" then
                if x.keyLevel > cur.bestKey
                   or (x.keyLevel == cur.bestKey and x.mpiScore > cur.bestScore)
                then
                    cur.bestKey = x.keyLevel
                    cur.bestScore = x.mpiScore
                end
                cur.timed = true
            end
        end
    end

    local seen = {}
    if db and type(db.runs) == "table" then
        for _, r in ipairs(db.runs) do
            local x = _extractRunSelfData(r)
            if x then
                eat(x)
                seen[x.timestamp .. ":" .. x.dungeonMapID] = true
            end
        end
    end
    local imp = _G.MidnightUI_MPI_Imported
    if imp and type(imp.runs) == "table" then
        for _, r in ipairs(imp.runs) do
            local x = _extractRunSelfData(r)
            if x then
                local key = x.timestamp .. ":" .. x.dungeonMapID
                if not seen[key] then
                    eat(x)
                    seen[key] = true
                end
            end
        end
    end
    return out
end

-- Names of currently-active M+ dungeons via Blizzard's API. Same source
-- the dungeon journal + LFG dropdown uses, so localization comes for
-- free. Returns nil if the API isn't available yet (early-load races).
local function _getSeasonDungeons()
    if not (C_ChallengeMode and C_ChallengeMode.GetMapTable
            and C_ChallengeMode.GetMapUIInfo) then return nil end
    local ok, mapIDs = pcall(C_ChallengeMode.GetMapTable)
    if not ok or type(mapIDs) ~= "table" or #mapIDs == 0 then return nil end
    local list = {}
    for _, mapID in ipairs(mapIDs) do
        local okN, name = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
        if okN and type(name) == "string" and name ~= "" then
            list[#list + 1] = name
        end
    end
    return #list > 0 and list or nil
end

-- Look up the active role's score payload. Tries:
--   1. The web overlay's roles[] array (Companion v2.1+).
--   2. Local SavedVar runs for the current spec (always works).
--
-- Returns (active, fallback). Both are role payload tables of the same
-- shape the dashboard_api.php emits. `active` is the role matching the
-- player's current spec (when we have data for it). `fallback` is the
-- player's most-played role — what to show when the active spec has
-- no runs yet.
function MPI.GetActiveRoleScore()
    local activeRole = MPI.GetActiveRole()
    local web = _G.MidnightUI_MPI_Web
    local webHasRoles = type(web) == "table"
        and type(web.roles) == "table" and #web.roles > 0

    -- Prefer the web overlay (matches what the player sees on the website).
    if webHasRoles then
        local fallback = web.roles[1]  -- server-sorted by run count desc
        if not activeRole then return nil, fallback end
        for _, r in ipairs(web.roles) do
            if r.role == activeRole then return r, fallback end
        end
        return nil, fallback
    end

    -- Web overlay missing or older shape — compute from local SavedVar.
    -- Build a "most-played role" payload for the fallback slot too, so the
    -- UI can display SOMETHING even when the player swaps to a fresh spec.
    local active = activeRole and MPI.ComputeLocalRoleScore(activeRole) or nil

    -- Pick a fallback by checking each role and returning the one with
    -- the most runs. Prevents a panel-blank state when the active spec
    -- has no data yet.
    local function bestLocalFallback()
        local best
        for _, role in ipairs({ "TANK", "HEALER", "DPS", "AUG" }) do
            local r = MPI.ComputeLocalRoleScore(role)
            if r and (not best or r.runs > best.runs) then best = r end
        end
        return best
    end
    return active, bestLocalFallback()
end

-- ============================================================
-- SECTION: Constants + MPI Table + DB Init + Utility Helpers
-- READS: nothing at load time
-- WRITES: MPI{}, _G.MidnightUI_MPI (via EnsureDB on login)
-- SCORE_TIERS/ROLE_WEIGHTS/KEY_BRACKETS/MASTERY_LEVELS/
--   BADGE_DEFS are pure read-only config tables.
-- MPI.EnsureDB() initialises the SavedVar sub-tables on first
--   use (profiles{}, runs{}, scores{}, sync metadata).
-- MPI.DebugLog routes to MidnightUI_DebugSource if available.
-- ============================================================
-- (MPI table itself declared at top of file, before any MPI.X defs.)

-- Trace ring buffer. Piggybacks on the existing MidnightUI_MPI SavedVar so it
-- survives /reload without needing a new SV slot registered in the .toc. The
-- buffer lives on _G.MidnightUI_MPI.debugTrace and is bounded to avoid growth.
-- Load marker lets us confirm the file actually executed even if everything
-- else fails afterward.
_G.MidnightUI_MPI = _G.MidnightUI_MPI or {}
_G.MidnightUI_MPI.debugTrace = _G.MidnightUI_MPI.debugTrace or {}
_G.MidnightUI_MPI.lastLoadTime = date("%Y-%m-%d %H:%M:%S")
MPI._trace     = _G.MidnightUI_MPI.debugTrace
MPI._traceMax  = 500
-- Mark the file as loaded before anything else can fail below.
MPI._trace[#MPI._trace + 1] = date("%H:%M:%S") .. "  MidnightPI.lua loaded"

function MPI.DebugLog(msg)
    local m = tostring(msg)
    local buf = MPI._trace
    buf[#buf + 1] = date("%H:%M:%S") .. "  " .. m
    local overflow = #buf - MPI._traceMax
    if overflow > 0 then
        for i = 1, overflow do table.remove(buf, 1) end
    end
end

MPI._ready        = false
MPI._apiAvailable = false
MPI._activeRun    = nil
MPI._encounterLog = {}
MPI._rosterSnapshot = {}
MPI._leavers      = {}
MPI._surrendered  = false
MPI._taggedThisSession = {}
MPI._heroPill     = nil
MPI._dashboardBuilt = false
MPI._sidebarBuilt  = false

local db

local function SafeCall(fn, ...)
    if type(fn) ~= "function" then return nil end
    local ok, r1, r2, r3, r4, r5, r6, r7, r8 = pcall(fn, ...)
    if not ok then return nil end
    return r1, r2, r3, r4, r5, r6, r7, r8
end

local function GetClassColor(classFile)
    if not classFile or classFile == "" or not RAID_CLASS_COLORS then return 0.8, 0.8, 0.8 end
    local cc = RAID_CLASS_COLORS[classFile]
    if cc then return cc.r, cc.g, cc.b end
    return 0.8, 0.8, 0.8
end

local function TrySetFont(fs, fontPath, size, flags)
    if not fs or not fs.SetFont then return end
    local resolved = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath(fontPath or TITLE_FONT)) or (fontPath or TITLE_FONT)
    pcall(fs.SetFont, fs, resolved, size or 12, flags or "")
end


local DEFAULT_SETTINGS = {
    enabled       = true,
    showInHero    = true,
    showInRows    = true,
    showInSidebar = true,
    maxStoredRuns     = MAX_RUNS,
    maxStoredProfiles = MAX_PROFILES,
    -- When true, the addon calls LoggingCombat(true) at M+ key start and
    -- LoggingCombat(false) at key end (only if we were the ones who turned
    -- it on). The resulting WoWCombatLog.txt is what the Companion parses
    -- for features the in-addon combat log can't reach due to taint.
    combatLogAutoForKeys = true,
}

local DEFAULT_PERSONAL = {
    totalRuns      = 0,
    timedRuns      = 0,
    avgMPI         = 0,
    consistencyScore = 0,
    byDungeon      = {},
    weeklyRuns     = 0,
    weeklyTimedRuns = 0,
    weeklyAvgMPI   = 0,
    weekStart      = 0,
}

local function GetServerWeekStart()
    local now = time()
    local utc = date("!*t", now)
    local resetDay = 3
    if GetCurrentRegion then
        local ok, region = pcall(GetCurrentRegion)
        if ok and region and region ~= 1 then resetDay = 4 end
    end
    local daysSinceReset = (utc.wday - resetDay) % 7
    local resetDate = now - (daysSinceReset * 86400)
    local t = date("!*t", resetDate)
    t.hour = 7; t.min = 0; t.sec = 0
    local resetTime = time(t)
    if resetTime > now then resetTime = resetTime - (7 * 86400) end
    return resetTime
end

function MPI.EnsureDB()
    if not _G.MidnightUI_MPI then
        _G.MidnightUI_MPI = {
            version    = MPI_VERSION,
            runs       = {},
            profiles   = {},
            personal   = {},
            settings   = {},
            _nameIndex = {},
        }
    end
    db = _G.MidnightUI_MPI

    db.version = db.version or MPI_VERSION

    db.runs       = db.runs       or {}
    db.profiles   = db.profiles   or {}
    db.settings   = db.settings   or {}
    db._nameIndex = db._nameIndex or {}

    for k, v in pairs(DEFAULT_SETTINGS) do
        if db.settings[k] == nil then db.settings[k] = v end
    end

    if not db.personal or type(db.personal) ~= "table" then
        db.personal = {}
    end
    for k, v in pairs(DEFAULT_PERSONAL) do
        if db.personal[k] == nil then db.personal[k] = v end
    end
    db.personal.byDungeon = db.personal.byDungeon or {}

    while #db.runs > db.settings.maxStoredRuns do
        table.remove(db.runs)
    end

    local profileCount = 0
    for _ in pairs(db.profiles) do profileCount = profileCount + 1 end
    if profileCount > db.settings.maxStoredProfiles then
        local sorted = {}
        for guid, prof in pairs(db.profiles) do
            sorted[#sorted + 1] = { guid = guid, lastSeen = prof.lastSeen or 0 }
        end
        table.sort(sorted, function(a, b) return a.lastSeen > b.lastSeen end)
        for i = db.settings.maxStoredProfiles + 1, #sorted do
            db.profiles[sorted[i].guid] = nil
        end
    end

    local weekStart = GetServerWeekStart()
    if (db.personal.weekStart or 0) < weekStart then
        db.personal.weeklyRuns = 0
        db.personal.weeklyTimedRuns = 0
        db.personal.weeklyAvgMPI = 0
        db.personal.weekStart = weekStart
    end

    MPI.RebuildNameIndex()
end

function MPI.RebuildNameIndex()
    if not db then return end
    db._nameIndex = {}
    for guid, prof in pairs(db.profiles) do
        if prof.name then
            db._nameIndex[prof.name] = guid
            local shortName = prof.name:match("^([^%-]+)")
            if shortName and shortName ~= prof.name then
                if not db._nameIndex[shortName] then
                    db._nameIndex[shortName] = guid
                end
            end
        end
    end
end

function MPI.LookupByName(name)
    if not db or not name then return nil, nil end
    local guid = db._nameIndex[name]
    if guid and db.profiles[guid] then return guid, db.profiles[guid] end
    local shortName = name:match("^([^%-]+)")
    if shortName then
        guid = db._nameIndex[shortName]
        if guid and db.profiles[guid] then return guid, db.profiles[guid] end
    end
    local communityProf = MPI.CommunityLookup(name)
    if communityProf then
        return "community-" .. name, communityProf
    end
    return nil, nil
end


-- ============================================================
-- SECTION: Player Scoring Functions
-- READS: per-run combat data tables (avoidable dmg, deaths,
--   output, interrupts, finishes)
-- WRITES: nothing (pure computation → returns 0-100 int)
-- FUNCTIONS:
--   ScoreAwareness(avoidableDmg, totalDmgTaken) → 0-100
--   ScoreSurvival(deaths, duration) → 0-100
--   ScoreDpsOutput(dps, keyLevel, role) → 0-100
--   ScoreTankOutput / ScoreHealerOutput — role-specific
--   ScoreInterrupts(count, duration) → 0-100
--   ScoreCompletion(finished, total) → 0-100
--   CompositeScore(subscores, role) → weighted sum using
--     ROLE_WEIGHTS[role][i] per subscore index.
-- ============================================================
-- Awareness: cohort-percentile first, hardcoded threshold fallback.
-- Cohort path: compare your avoidable damage to the p10/p25/p50/p75/p90/p99 of
-- everyone else running the same class / role / dungeon / key bracket.
-- Fallback path: hardcoded per-minute thresholds by role.
function MPI.ScoreAwareness(avoidableDmg, durationSec, role, cohort)
    avoidableDmg = avoidableDmg or 0
    durationSec  = max(1, durationSec or 1)
    role = role or "DPS"
    if role == "DAMAGER" then role = "DPS" end

    if cohort and cohort.avoidableDamage then
        return MPI.PercentileScore(avoidableDmg, cohort.avoidableDamage, true)
    end

    local bench = AVOIDABLE_BENCHMARKS[role] or AVOIDABLE_BENCHMARKS.DPS
    local perMinute = (avoidableDmg / durationSec) * 60

    if perMinute <= bench.min then return 100 end
    if perMinute >= bench.max then return 0 end

    local t = (perMinute - bench.min) / (bench.max - bench.min)
    return max(0, min(100, floor(100 * (1 - t) + 0.5)))
end

function MPI.ScoreSurvivalSimple(deaths)
    deaths = deaths or 0
    if deaths <= 0 then return 100 end
    return max(0, floor(100 * (0.85 ^ deaths) + 0.5))
end

-- Bounded blame survival score.
-- ctx = {
--   deaths          = int count of deaths this run,
--   role            = "TANK" | "HEALER" | "DPS" | "DAMAGER",
--   deathRecaps     = array of recap tables (events, maxHealth, timestamp of final hit),
--   avoidableSpells = { [spellID] = true } lookup,
--   encounterLog    = run encounter array (each has startTime, endTime, success),
--   tankDeathTimes  = array of absolute timestamps when the tank died (for chain logic),
--   defensivesAvailable = optional { [recapIndex] = count of major CDs off cooldown at death },
-- }
-- Returns (score, deathDetails).
--
-- Key rules:
--   * Only ONE blame modifier applies per death (not stacked).
--   * Wipe modifier only reduces deaths that happened IN the wiped encounter.
--   * Tank-down chain only applies to non-tank deaths within 15s of a tank death
--     AND in the same encounter.
--   * Tank deaths never get the wipe or tank-down excuse.
--   * Unused defensives at death → +50% blame surcharge (no reduction allowed).
--   * Minimum per-death blame: 0.3 (was 0.1 — too lenient).
function MPI.ScoreSurvival(ctx)
    ctx = ctx or {}
    local deaths = ctx.deaths or 0
    if deaths <= 0 then return 100, {} end

    local role = ctx.role or "DPS"
    if role == "DAMAGER" then role = "DPS" end

    local deathRecaps = ctx.deathRecaps or {}
    if #deathRecaps == 0 then
        return MPI.ScoreSurvivalSimple(deaths), {}
    end

    local avoidableSpells = ctx.avoidableSpells or {}
    local encounterLog   = ctx.encounterLog or {}
    local tankDeathTimes = ctx.tankDeathTimes or {}
    local defensives     = ctx.defensivesAvailable or {}

    local function encounterFor(ts)
        for _, enc in ipairs(encounterLog) do
            if enc.startTime and enc.endTime and ts >= enc.startTime and ts <= enc.endTime then
                return enc
            end
        end
        return nil
    end

    local function tankDiedWithin15s(deathTs, inEncounter)
        for _, tts in ipairs(tankDeathTimes) do
            if tts < deathTs and (deathTs - tts) <= 15 then
                if not inEncounter then return true end
                local tankEnc = encounterFor(tts)
                if tankEnc == inEncounter then return true end
            end
        end
        return false
    end

    local totalBlame = 0
    local deathDetails = {}

    for idx, recap in ipairs(deathRecaps) do
        local events = recap.events or {}
        local maxHp = recap.maxHealth or 1

        local totalRecapDmg = 0
        local avoidableRecapDmg = 0
        local lastTs = recap.timestamp or 0
        for _, ev in ipairs(events) do
            local amt = ev.amount or 0
            if amt > 0 then
                totalRecapDmg = totalRecapDmg + amt
                if avoidableSpells[ev.spellID] then
                    avoidableRecapDmg = avoidableRecapDmg + amt
                end
                if (ev.timestamp or 0) > lastTs then lastTs = ev.timestamp end
            end
        end

        local avoidablePct = totalRecapDmg > 0 and (avoidableRecapDmg / totalRecapDmg) or 0

        -- Baseline blame by cause.
        local blame, reason
        if avoidablePct > 0.5 then
            blame, reason = 1.0, "avoidable"
        elseif avoidablePct > 0.2 then
            blame, reason = 0.7, "partly_avoidable"
        else
            blame, reason = 0.4, "unavoidable"
        end

        -- Find single best excuse.
        local myEnc = encounterFor(lastTs)
        local excuse = nil

        -- Excuse 1: tank went down within 15s in same encounter (non-tank deaths only).
        if role ~= "TANK" and tankDiedWithin15s(lastTs, myEnc) then
            excuse = { factor = 0.4, tag = "tank_down" }
        end

        -- Excuse 2: encounter wipe this death belongs to (non-tank deaths only).
        if not excuse and role ~= "TANK" and myEnc and myEnc.success == false then
            excuse = { factor = 0.5, tag = "wipe" }
        end

        -- Excuse 3: overkill one-shot (any role).
        if not excuse and maxHp > 0 then
            local lastEvent
            for i = #events, 1, -1 do
                if (events[i].amount or 0) > 0 then lastEvent = events[i]; break end
            end
            if lastEvent then
                local overkillRatio = (lastEvent.overkill or 0) / maxHp
                if overkillRatio > 0.4 then
                    excuse = { factor = 0.5, tag = "oneshot" }
                end
            end
        end

        if excuse then
            blame = blame * excuse.factor
            reason = reason .. "+" .. excuse.tag
        end

        -- Surcharge: unused defensives at death.
        if (defensives[idx] or 0) >= 2 then
            blame = blame * 1.5
            reason = reason .. "+defensives_available"
            excuse = nil  -- surcharge overrides any excuse
        end

        blame = max(0.3, min(1.5, blame))
        totalBlame = totalBlame + blame

        deathDetails[#deathDetails + 1] = {
            blame = blame,
            reason = reason,
            avoidablePct = floor(avoidablePct * 100),
            defensivesAvailable = defensives[idx] or 0,
        }
    end

    -- Deaths without recap data: moderate baseline blame.
    local unaccountedDeaths = deaths - #deathRecaps
    if unaccountedDeaths > 0 then
        totalBlame = totalBlame + (unaccountedDeaths * 0.6)
    end

    -- Cap total reduction: can't reduce blame below 60% of raw death count.
    local floor60 = deaths * 0.6
    if totalBlame < floor60 then totalBlame = floor60 end

    local score = max(0, floor(100 * (0.85 ^ totalBlame) + 0.5))
    return score, deathDetails
end

-- DPS output: community-comparative first, relative-to-top as secondary signal.
-- Community median is the RIGHT answer: "are you pulling weight vs others running this
-- same class/spec/dungeon/key?" Relative-to-top is softer — spec-to-spec gap varies.
-- Weighting: 70% community / 30% relative when community data available.
-- Fallback: pure relative-to-top when no community data (first-run scenarios).
-- ctx = {
--   playerDamage, allDpsAmounts (array of DPS players' damage),
--   communityMedian (optional),
-- }
function MPI.ScoreDpsOutput(ctx)
    ctx = ctx or {}
    local playerDmg = ctx.playerDamage or 0
    local allDps    = ctx.allDpsAmounts or {}

    local topDmg = 0
    for _, dmg in ipairs(allDps) do
        if dmg > topDmg then topDmg = dmg end
    end
    local relScore = topDmg > 0 and min(100, (playerDmg / topDmg) * 100) or 50

    -- Preferred: cohort percentile — compare against same class/spec/dungeon/key.
    -- 70% cohort percentile + 30% relative-to-top.
    if ctx.cohort and ctx.cohort.damageDone then
        local percScore = MPI.PercentileScore(playerDmg, ctx.cohort.damageDone, false)
        return max(0, min(100, floor(percScore * 0.70 + relScore * 0.30 + 0.5)))
    end

    return max(0, min(100, floor(relScore + 0.5)))
end

-- Tank output: damage share + threat retention. NO pull efficiency — that was
-- prescribing pace/playstyle. Timing the key is what matters, not how fast you pulled.
-- Weights: 70% damage share + 30% threat retention (when available).
-- Fallback (no threat data): 100% damage share.
--
-- Damage share curve softened — 80 at 18% share, 100 at 25%. A Prot Paladin
-- hitting 18% isn't failing; they're contributing at their spec's level.
-- ctx = {
--   tankDamage, groupTotalDamage,
--   tankPhysicalDamageTaken, groupPhysicalDamageTaken (optional — threat proxy),
-- }
function MPI.ScoreTankOutput(ctx)
    ctx = ctx or {}
    local tankDmg   = ctx.tankDamage or 0
    local groupDmg  = ctx.groupTotalDamage or 0

    -- Preferred: cohort percentile on raw damage (reflects spec contribution norms).
    -- Fallback: tiered share curve — 100 at 30%+ (Guardian/Vengeance/DK territory),
    -- 75-85 at 18-22% (Prot Paladin's natural share), 60 at 15%, 0 at 8%.
    local shareScore
    if ctx.cohort and ctx.cohort.damageDone then
        shareScore = MPI.PercentileScore(tankDmg, ctx.cohort.damageDone, false)
    elseif groupDmg <= 0 then
        shareScore = 50
    else
        local share = tankDmg / groupDmg
        if share >= 0.30 then shareScore = 100
        elseif share >= 0.22 then shareScore = 85 + (share - 0.22) / 0.08 * 15
        elseif share >= 0.18 then shareScore = 75 + (share - 0.18) / 0.04 * 10
        elseif share >= 0.15 then shareScore = 60 + (share - 0.15) / 0.03 * 15
        elseif share >= 0.08 then shareScore = (share - 0.08) / 0.07 * 60
        else shareScore = 0 end
    end

    -- Threat retention: tank's share of group physical damage taken.
    -- 100 at 85%+, 0 at 50%-.
    local threatScore, hasThreat
    if ctx.tankPhysicalDamageTaken and ctx.groupPhysicalDamageTaken
       and ctx.groupPhysicalDamageTaken > 0 then
        local share = ctx.tankPhysicalDamageTaken / ctx.groupPhysicalDamageTaken
        if share >= 0.85 then threatScore = 100
        elseif share <= 0.50 then threatScore = 0
        else threatScore = (share - 0.50) / 0.35 * 100 end
        hasThreat = true
    end

    local score = hasThreat
        and (shareScore * 0.70 + threatScore * 0.30)
        or  shareScore
    return max(0, min(100, floor(score + 0.5)))
end

-- Healer output: healing coverage + damage prevented. Damage dealt is NOT scored.
-- ctx = {
--   healingDone, absorbsApplied (optional, sum of absorb shields),
--   groupDamageTaken,
-- }
-- Healer output: healing primary (75%) + damage secondary (25%).
-- Good healers DPS between heals. Damage isn't required to score well but
-- contributing it can push a score from A to S. Conversely, neglecting heals
-- can't be made up by high damage — coverage dominates the score.
-- ctx = {
--   healingDone, absorbsApplied, groupDamageTaken,
--   damageDone, groupTotalDamage,
--   cohort (optional),
-- }
function MPI.ScoreHealerOutput(ctx)
    ctx = ctx or {}
    local healing    = ctx.healingDone or 0
    local absorbs    = ctx.absorbsApplied or 0
    local groupTaken = ctx.groupDamageTaken or 0
    local damageDone = ctx.damageDone or 0
    local groupDmg   = ctx.groupTotalDamage or 0

    -- Healing component (cohort-first).
    -- Realistic coverage: 40-60% is solid, 60%+ excellent. Tanks self-sustain a lot;
    -- DPS have self-heals/leech; healer covers the rest. 80%+ is extraordinary.
    local healingScore
    if ctx.cohort and ctx.cohort.healingDone then
        healingScore = MPI.PercentileScore(healing + absorbs, ctx.cohort.healingDone, false)
    elseif groupTaken > 0 then
        local coverage = (healing + absorbs) / groupTaken
        if coverage >= 0.60 then healingScore = 100
        elseif coverage >= 0.40 then healingScore = 85 + (coverage - 0.40) / 0.20 * 15
        elseif coverage >= 0.25 then healingScore = 65 + (coverage - 0.25) / 0.15 * 20
        elseif coverage >= 0.15 then healingScore = 40 + (coverage - 0.15) / 0.10 * 25
        else healingScore = coverage / 0.15 * 40 end
    else
        healingScore = 50
    end

    -- Damage contribution component (cohort-first).
    -- Healers typically contribute 5-15% of group damage depending on spec
    -- and encounter intensity. 15% share or top-of-cohort = 100.
    local damageScore
    if ctx.cohort and ctx.cohort.damageDone then
        damageScore = MPI.PercentileScore(damageDone, ctx.cohort.damageDone, false)
    elseif groupDmg > 0 then
        local share = damageDone / groupDmg
        if share >= 0.15 then damageScore = 100
        elseif share >= 0.10 then damageScore = 75 + (share - 0.10) / 0.05 * 25
        elseif share >= 0.05 then damageScore = 40 + (share - 0.05) / 0.05 * 35
        elseif share >= 0.02 then damageScore = 15 + (share - 0.02) / 0.03 * 25
        else damageScore = share / 0.02 * 15 end
    else
        damageScore = 50
    end

    local score = healingScore * 0.75 + damageScore * 0.25
    return max(0, min(100, floor(score + 0.5)))
end

-- Aug output: personal damage + group-performance proxy.
-- Aug empowers others, so personal damage expected lower than pure DPS.
-- ctx = {
--   augDamage, groupTotalDamage,
--   groupDamageAboveExpected (optional — how much group output exceeded baseline),
-- }
function MPI.ScoreAugOutput(ctx)
    ctx = ctx or {}
    local augDmg    = ctx.augDamage or 0
    local groupDmg  = ctx.groupTotalDamage or 0

    -- Personal damage share: 100 at 13% (Aug benchmark).
    local personalScore = 50
    if groupDmg > 0 then
        local share = augDmg / groupDmg
        personalScore = min(100, floor(share * 770 + 0.5))
    end

    -- Group uplift: if group is over-performing baseline, credit Aug.
    local upliftScore = 70  -- neutral default until we have baseline
    if ctx.groupDamageAboveExpected ~= nil then
        if ctx.groupDamageAboveExpected >= 0.15 then upliftScore = 100
        elseif ctx.groupDamageAboveExpected <= -0.15 then upliftScore = 0
        else upliftScore = 50 + (ctx.groupDamageAboveExpected / 0.15) * 50 end
    end

    local score = personalScore * 0.50 + upliftScore * 0.50
    return max(20, min(100, floor(score + 0.5)))
end

-- Utility score: kicks + dispels + class-capability awareness.
-- ctx = {
--   interrupts, dispels,
--   groupTotalInterrupts, groupTotalDispels,
--   groupDispelsByType     = optional { magic=N, curse=N, poison=N, disease=N, bleed=N },
--   playerClass, playerSpec,
--   kickAvailabilityRatio  = optional 0-1 (kicks landed / kicks available, from CLEU),
-- }
--
-- Point budget: kicks 55, dispels 35, class-utility-penalty up to -20.
-- Availability ratio (when known) applies a multiplier to kick score.
function MPI.ScoreUtility(ctx)
    ctx = ctx or {}
    local interrupts = ctx.interrupts or 0
    local dispels    = ctx.dispels or 0
    local groupInt   = ctx.groupTotalInterrupts or 0

    -- Kick score: base on group share × sqrt(count). Max 55 points.
    local kickScore
    if groupInt >= 10 then
        local share = interrupts / groupInt
        kickScore = min(55, (sqrt(interrupts) * 10) * (0.5 + share))
    elseif groupInt > 0 then
        kickScore = min(55, sqrt(interrupts) * 12)
    else
        kickScore = 30  -- no kickable casts this run, neutral
    end

    -- Availability ratio multiplier (only when CLEU data present).
    if ctx.kickAvailabilityRatio ~= nil then
        local r = max(0, min(1, ctx.kickAvailabilityRatio))
        -- 1.0 ratio keeps full score; 0.5 ratio → 70% of score; 0.0 ratio → 40%.
        kickScore = kickScore * (0.4 + 0.6 * r)
    end

    -- Dispel score: sqrt scaling up to 35 points.
    local dispelScore = min(35, sqrt(max(0, dispels)) * 12)

    -- Class capability penalty:
    -- If your class can dispel a type and the group had dispels of that type happen
    -- (someone else cleaned up), but you contributed 0 dispels, you miss credit.
    local capPenalty = 0
    local dispelCap = LookupClassCapability(DISPEL_TYPES, ctx.playerClass, ctx.playerSpec)
    if dispelCap and dispels == 0 and ctx.groupDispelsByType then
        local coveredByOthers = 0
        for dtype, count in pairs(ctx.groupDispelsByType) do
            if dispelCap[dtype] and (count or 0) > 0 then
                coveredByOthers = coveredByOthers + count
            end
        end
        if coveredByOthers >= 10 then
            capPenalty = 20
        elseif coveredByOthers >= 5 then
            capPenalty = 12
        elseif coveredByOthers >= 1 then
            capPenalty = 5
        end
    end

    local score = floor(kickScore + dispelScore + 0.5) - capPenalty
    return max(0, min(100, score))
end

-- Consistency: per-run execution score. "Did you finish what you started, and
-- did you stay engaged?" Every run has its own Consistency value, directly
-- tied to observable behaviour on THAT key. Profile-level Consistency is the
-- trailing average of recent per-run values (see UpdatePersonalStats).
--
-- Signals (all optional; sensible defaults applied):
--   result         -- "timed" | "depleted"
--   stars          -- 0..3 keystone upgrade levels (0 when not timed)
--   encounterWipes -- integer boss wipe count from run state
--   didLeaveEarly  -- this player was in MPI._leavers at completion
--   surrendered    -- group voted Surrender Keystone
--
-- Returns: score (0-100), breakdown table { reasons = { {kind, delta, text}, … } }
-- that the UI surfaces verbatim so players see exactly what drove the number.
function MPI.ScoreConsistency(ctx)
    ctx = ctx or {}
    local result        = ctx.result or "depleted"
    local stars         = ctx.stars or 0
    local wipes         = ctx.encounterWipes or 0
    local didLeaveEarly = ctx.didLeaveEarly or false
    local surrendered   = ctx.surrendered or false

    local score
    local reasons = {}

    if didLeaveEarly then
        score = 10
        reasons[#reasons + 1] = { kind = "left_early", delta = 0, text = "Left before the group finished" }
    elseif surrendered then
        score = 30
        reasons[#reasons + 1] = { kind = "surrendered", delta = 0, text = "Group surrendered the keystone" }
    elseif result == "timed" then
        if stars >= 3 then
            score = 100
            reasons[#reasons + 1] = { kind = "timed", delta = 0, text = "3-chest timed" }
        elseif stars >= 2 then
            score = 95
            reasons[#reasons + 1] = { kind = "timed", delta = 0, text = "2-chest timed" }
        else
            score = 88
            reasons[#reasons + 1] = { kind = "timed", delta = 0, text = "Key timed" }
        end
    else
        score = 65
        reasons[#reasons + 1] = { kind = "depleted", delta = 0, text = "Depleted but finished — partial credit" }
    end

    -- Wipe penalty: light touch (-4 each, capped at -16). Wipes already
    -- affect Awareness/Survival via avoidable damage and deaths; this is a
    -- group-level disruption signal, not a double-dip.
    if wipes > 0 and score > 0 then
        local wipePenalty = -min(16, wipes * 4)
        local newScore = max(0, score + wipePenalty)
        reasons[#reasons + 1] = {
            kind  = "wipes",
            delta = newScore - score,
            text  = string.format("Group wiped %d time%s on bosses", wipes, wipes == 1 and "" or "s"),
        }
        score = newScore
    end

    return score, { reasons = reasons }
end

-- Proven Level: median of the best 5 timed keys (or all if fewer) in the current season.
-- runs = array of { keyLevel, result ("timed"|"depleted"), timestamp, ... }
-- seasonStartTs = optional unix timestamp; if set, only runs after this are counted.
function MPI.ComputeProvenLevel(runs, seasonStartTs)
    if not runs or #runs == 0 then return 0 end
    local timedLevels = {}
    for _, r in ipairs(runs) do
        if r.result == "timed" and (r.keyLevel or 0) > 0 then
            if not seasonStartTs or (r.timestamp or 0) >= seasonStartTs then
                timedLevels[#timedLevels + 1] = r.keyLevel
            end
        end
    end
    if #timedLevels == 0 then return 0 end
    table.sort(timedLevels, function(a, b) return a > b end)
    local topN = min(5, #timedLevels)
    local midIdx = floor((topN + 1) / 2)
    return timedLevels[midIdx] or 0
end

-- Gear tier lookup by item level.
function MPI.GetGearTier(ilvl, tierTable)
    ilvl = ilvl or 0
    tierTable = tierTable or DEFAULT_GEAR_TIERS
    for _, tier in ipairs(tierTable) do
        if ilvl >= tier.min and ilvl <= tier.max then
            return tier
        end
    end
    return tierTable[#tierTable]  -- fallback to lowest
end

-- ── v8 (2026-05-02): Blizzard-aligned scoring ─────────────────────────────
-- Per-run multiplier: Blizzard's par-time linear scaling (±5% within ±40%
-- of par time, additional -5% for missing the timer, ×0 beyond 140% par).
-- Replaces the old discrete stars multiplier {0.70, 1.00, 1.05, 1.10}.
local PAR_BONUS_MAX        = 0.05
local PAR_BONUS_THRESHOLD  = 0.40
local PAR_DEPLETION_PEN    = 0.05
local PAR_KEY_DEAD_FRAC    = 1.40

function MPI.ComputeParTimeMultiplier(result, durationSec, timeLimitSec)
    if not timeLimitSec or timeLimitSec <= 0 then
        return result == "timed" and 1.00 or 0.95
    end
    local parFrac = (durationSec or 0) / timeLimitSec

    -- "Key dies past 140% par" only applies to depleted runs. A timed
    -- run by definition beat the timer; trust the result column.
    if result ~= "timed" and parFrac > PAR_KEY_DEAD_FRAC then return 0.0 end

    -- Clamp timed runs with bad duration data to ≤100% par.
    if result == "timed" and parFrac > 1.0 then parFrac = 1.0 end

    local offset = 1.0 - parFrac
    local linear = offset * PAR_BONUS_MAX / PAR_BONUS_THRESHOLD
    if linear > PAR_BONUS_MAX then linear = PAR_BONUS_MAX end
    if linear < -PAR_BONUS_MAX then linear = -PAR_BONUS_MAX end

    if result == "timed" then
        return 1.0 + linear
    end
    local m = 1.0 + linear - PAR_DEPLETION_PEN
    if m < 0 then m = 0 end
    return m
end

-- Back-compat: old callers still pass (result, stars). v8 falls back to a
-- close approximation when par-time data is unavailable.
function MPI.ComputeStarsMultiplier(result, stars)
    if result ~= "timed" then return 0.95 end
    stars = stars or 1
    if stars >= 3 then return 1.05 end
    if stars >= 2 then return 1.025 end
    return 1.00
end

-- Verified-peak gate (informational only as of v8): "verified" = ≥2 timed
-- runs at the same key level. Surfaced for UI but no longer drives math.
local PEAK_MIN_RUNS    = 2
local PEAK_EXEC_FLOOR  = 50
local PEAK_TOPN_RECENT = 10

-- Blizzard's per-dungeon base score table for current WoW M+ (TWW-era,
-- single-affix system). Lua tables are 1-indexed, so index = key level
-- doesn't quite line up with PHP's 0-indexed array; we compensate in
-- MPI.BlizzardBaseScore. Above +30 extrapolates at +15/level.
-- Mirrors BLIZZARD_BASE_BY_LEVEL on the server in php/includes/scoring.php.
local BLIZZARD_BASE_BY_LEVEL = {
    [2]=155,  [3]=170,  [4]=200,  [5]=215,  [6]=230,
    [7]=260,  [8]=275,  [9]=290,  [10]=320, [11]=335,
    [12]=365, [13]=380, [14]=395, [15]=410, [16]=425,
    [17]=440, [18]=455, [19]=470, [20]=485, [21]=500,
    [22]=515, [23]=530, [24]=545, [25]=560, [26]=575,
    [27]=590, [28]=605, [29]=620, [30]=635,
}

-- Current WoW uses a single-affix system per season (no Tyrannical /
-- Fortified split per dungeon since DF S3). Each run is its own complete
-- dungeon score — no two-week combine.
local BLIZZARD_DUAL_AFFIX_FACTOR = 1.0

-- Time-bonus magnitude for current WoW M+. Up to ±15 points within ±40%
-- of par time (linear).
local TIME_BONUS_MAX = 15.0

-- Anchor curve mapping summed Blizzard-equivalent score to MPI base (0–100).
-- Calibrated to current Blizzard scoring magnitudes:
--   8 timed at +2  ≈ 1240 raw → 30 base
--   8 timed at +10 ≈ 2560 raw → 70 base (median anchor)
--   8 timed at +20 ≈ 3880 raw → 95 base
--   4500+ raw → 100 base (cap; top-of-ladder ~4000-4500)
local MPI_BASE_ANCHORS = {
    { 0,    0.0  }, { 800,  20.0 }, { 1240, 30.0 }, { 1720, 50.0 },
    { 2560, 70.0 }, { 3280, 85.0 }, { 3880, 95.0 }, { 4500, 100.0 },
}

local MPI_QUALITY_MIN    = 0.50
local MPI_QUALITY_MAX    = 1.10
local MPI_QUALITY_CENTER = 70.0

-- Blizzard-style per-dungeon base score for a key level. Levels 2–30 are
-- the published Blizzard table; above +30 extrapolates at +15/level.
function MPI.BlizzardBaseScore(keyLevel)
    keyLevel = tonumber(keyLevel) or 0
    if keyLevel < 2 then return 0 end
    if keyLevel <= 30 then return BLIZZARD_BASE_BY_LEVEL[keyLevel] or 0 end
    return 635 + 15 * (keyLevel - 30)
end

-- Blizzard-style timing bonus on a per-week per-dungeon score.
-- Returns nil when the key blew past 140% par (caller treats as 0 score).
function MPI.BlizzardTimeBonus(result, durationSec, timeLimitSec)
    if not timeLimitSec or timeLimitSec <= 0 then
        return result == "timed" and 0.0 or -TIME_BONUS_MAX
    end
    local parFrac = (durationSec or 0) / timeLimitSec

    -- "Key dies past 140% par" only applies to depleted runs.
    if result ~= "timed" and parFrac > PAR_KEY_DEAD_FRAC then return nil end

    -- Clamp legacy timed runs with bad duration data to ≤100% par.
    if result == "timed" and parFrac > 1.0 then parFrac = 1.0 end

    local offset = 1.0 - parFrac
    local linear = offset * TIME_BONUS_MAX / PAR_BONUS_THRESHOLD
    if linear > TIME_BONUS_MAX then linear = TIME_BONUS_MAX end
    if linear < -TIME_BONUS_MAX then linear = -TIME_BONUS_MAX end

    if result == "timed" then return linear end
    return linear - TIME_BONUS_MAX
end

-- Per-run Blizzard-equivalent (two-week combined) score.
function MPI.BlizzardScorePerRun(keyLevel, result, durationSec, timeLimitSec)
    keyLevel = tonumber(keyLevel) or 0
    if keyLevel <= 0 then return 0 end
    local bonus = MPI.BlizzardTimeBonus(result, durationSec, timeLimitSec)
    if bonus == nil then return 0 end
    local base = MPI.BlizzardBaseScore(keyLevel)
    local perWeek = base + bonus
    if perWeek < 0 then perWeek = 0 end
    return perWeek * BLIZZARD_DUAL_AFFIX_FACTOR
end

-- Anchor-interpolate a summed Blizzard-equivalent score → MPI base 0–100.
function MPI.MpiBaseFromBlizzardTotal(total)
    total = tonumber(total) or 0
    if total <= 0 then return 0.0 end
    local prev = MPI_BASE_ANCHORS[1]
    for i = 1, #MPI_BASE_ANCHORS do
        local a = MPI_BASE_ANCHORS[i]
        if total <= a[1] then
            local span = a[1] - prev[1]
            if span <= 0 then return a[2] end
            local t = (total - prev[1]) / span
            return prev[2] + (a[2] - prev[2]) * t
        end
        prev = a
    end
    return 100.0
end

-- Back-compat: v8 keeps a one-arg "progression factor" for callers that
-- want a single 0-1 number per key level. Derived from the Blizzard table
-- × dual-affix × 8 dungeons → MPI base / 100.
function MPI.ProgressionFactor(keyLevel)
    keyLevel = tonumber(keyLevel) or 0
    if keyLevel <= 0 then return 0.0 end
    local base = MPI.BlizzardBaseScore(keyLevel)
    local eightAtLevel = base * BLIZZARD_DUAL_AFFIX_FACTOR * 8
    return MPI.MpiBaseFromBlizzardTotal(eightAtLevel) / 100.0
end

-- Compute overall MPI from a list of per-run rows (v8: Blizzard-equivalent
-- base × execution-quality multiplier).
--
-- Per dungeon, the player's BEST run by Blizzard-equivalent score contributes;
-- bests are summed across dungeons → mpiBase via the anchor curve. Quality
-- multiplier = (Blizzard-base-weighted average execution) / 70, clamped to
-- [0.5, 1.10]. Coverage is implicit in the per-dungeon sum.
--
-- Each `runs` entry should provide: keyLevel, mpiScore, dungeonName,
-- durationSec, timeLimitSec, result. Pre-v8 callers passing only
-- {keyLevel, mpiScore} get a degraded fallback.
--
-- Returns: overall (0-100 int), verifiedPeak (key level int), info table.
function MPI.ComputeOverallFromRuns(runs)
    local empty = {
        overall = 0, mpiBase = 0, qualityMultiplier = 1.0,
        blizzardEquivalent = 0, verifiedPeak = 0, verified = false,
    }
    if type(runs) ~= "table" or #runs == 0 then
        return 0, 0, empty
    end

    local bestByDungeon = {}     -- dungeonName → { blizz, exec, level }
    local timedRunsByLevel = {}  -- keyLevel → count (timed only)

    for _, r in ipairs(runs) do
        local kl       = r.keyLevel or 0
        local exec     = r.mpiScore or 0
        local dungeon  = r.dungeonName or ""
        local duration = r.durationSec or 0
        local timeLim  = r.timeLimitSec or 0
        local result   = r.result or "depleted"

        if kl > 0 and dungeon ~= "" then
            local blizz
            if timeLim > 0 and duration > 0 then
                blizz = MPI.BlizzardScorePerRun(kl, result, duration, timeLim)
            else
                local base = MPI.BlizzardBaseScore(kl)
                local penalty = (result == "timed") and 0.0 or -5.0
                local perWeek = base + penalty
                if perWeek < 0 then perWeek = 0 end
                blizz = perWeek * BLIZZARD_DUAL_AFFIX_FACTOR
            end

            if result == "timed" then
                timedRunsByLevel[kl] = (timedRunsByLevel[kl] or 0) + 1
            end

            local cur = bestByDungeon[dungeon]
            if not cur or blizz > cur.blizz then
                bestByDungeon[dungeon] = { blizz = blizz, exec = exec, level = kl }
            end
        end
    end

    -- Sum per-dungeon bests → Blizzard-equivalent total + weighted execution.
    local blizzardTotal, weightedExecSum, dungeonsContributing = 0.0, 0.0, 0
    for _, b in pairs(bestByDungeon) do
        blizzardTotal     = blizzardTotal + b.blizz
        weightedExecSum   = weightedExecSum + b.blizz * b.exec
        dungeonsContributing = dungeonsContributing + 1
    end

    if dungeonsContributing == 0 then
        return 0, 0, empty
    end

    local mpiBaseFloat = MPI.MpiBaseFromBlizzardTotal(blizzardTotal)
    local weightedExec = (blizzardTotal > 0) and (weightedExecSum / blizzardTotal) or 0.0
    local quality = weightedExec / MPI_QUALITY_CENTER
    if quality < MPI_QUALITY_MIN then quality = MPI_QUALITY_MIN end
    if quality > MPI_QUALITY_MAX then quality = MPI_QUALITY_MAX end

    local finalMpi = math.floor(mpiBaseFloat * quality + 0.5)
    if finalMpi < 0 then finalMpi = 0 end
    if finalMpi > 100 then finalMpi = 100 end

    -- Verified peak: highest key with ≥PEAK_MIN_RUNS timed runs at that level.
    local verifiedPeak, verified = 0, false
    do
        local levels = {}
        for k in pairs(timedRunsByLevel) do levels[#levels + 1] = k end
        table.sort(levels, function(a, b) return a > b end)
        for _, k in ipairs(levels) do
            if timedRunsByLevel[k] >= PEAK_MIN_RUNS then
                verifiedPeak = k
                verified = true
                break
            end
        end
        if verifiedPeak == 0 and #levels > 0 then
            verifiedPeak = levels[1]  -- fallback: highest single-run timed key
        end
    end

    local info = {
        overall            = finalMpi,
        mpiBase            = math.floor(mpiBaseFloat + 0.5),
        qualityMultiplier  = quality,
        blizzardEquivalent = math.floor(blizzardTotal + 0.5),
        verifiedPeak       = verifiedPeak,
        verified           = verified,
        peakExec           = weightedExec,
        progFactor         = mpiBaseFloat / 100.0,
    }
    return finalMpi, verifiedPeak, info
end

-- Per-run execution score.
-- ctx = { role, awareness, survival, output, utility, consistency,
--         keyLevel (informational only), stars, result ("timed"|"depleted") }
-- Pure execution: dimensions × stars/timing. No key-difficulty cap, no
-- proven-level adjust, no gear modifier. A flawless run scores ~100 at any
-- key level. Progression is reflected only in the OVERALL MPI (see
-- ComputeOverallFromRuns).
function MPI.ComputeRunScore(ctx)
    ctx = ctx or {}
    local role = ctx.role
    if role == "DAMAGER" then role = "DPS" end
    local weights = ROLE_WEIGHTS[role] or ROLE_WEIGHTS.DPS

    local aware = ctx.awareness or 0
    local surv  = ctx.survival or 0
    local out   = ctx.output or 0
    local util  = ctx.utility or 0
    local cons  = ctx.consistency

    local base
    if cons == nil then
        -- Consistency not scored yet (new player with <3 runs).
        -- Redistribute its 15% weight evenly across the other four.
        local redist = weights[5] / 4
        base = aware * (weights[1] + redist)
             + surv  * (weights[2] + redist)
             + out   * (weights[3] + redist)
             + util  * (weights[4] + redist)
    else
        base = aware * weights[1]
             + surv  * weights[2]
             + out   * weights[3]
             + util  * weights[4]
             + cons  * weights[5]
    end

    -- v8 (2026-05-02): per-run multiplier replaced with Blizzard's par-time
    -- linear scaling (±5% within ±40% of par, additional -5% for missing
    -- the timer, ×0 beyond 140% par). Falls back to the discrete stars
    -- shim only when par-time data isn't on the run.
    local mul
    if ctx.timeLimitSec and ctx.timeLimitSec > 0
       and ctx.durationSec and ctx.durationSec > 0 then
        mul = MPI.ComputeParTimeMultiplier(ctx.result, ctx.durationSec, ctx.timeLimitSec)
    else
        mul = MPI.ComputeStarsMultiplier(ctx.result, ctx.stars)
    end
    local clamped = max(0, min(100, floor(base * mul + 0.5)))
    MPI.DebugLog(string.format("ScoreBreakdown key=+%d result=%s role=%s base=%.2f mul=%.3f -> %d",
        ctx.keyLevel or 0, tostring(ctx.result or "?"), tostring(role),
        base, mul, clamped))
    return clamped
end

function MPI.GetScoreTier(score)
    score = score or 0
    for _, t in ipairs(SCORE_TIERS) do
        if score >= t[1] then
            return t[2], t[3], t[4], t[5]
        end
    end
    -- Unreachable in practice — the table's last entry has min=0 — but
    -- keep a sane fallback that matches D's colors so any defensive
    -- caller never gets a nil tuple.
    return "D", 1.00, 0.35, 0.35
end

function MPI.GetBarColor(value)
    value = value or 0
    if value >= 95 then return 1.00, 0.84, 0.00 end
    if value >= 85 then return 0.65, 0.55, 0.98 end
    if value >= 70 then return 0.40, 0.70, 0.95 end
    if value >= 55 then return 0.95, 0.80, 0.20 end
    if value >= 40 then return 1.00, 0.55, 0.26 end
    return 1.00, 0.35, 0.35
end

function MPI.ComputeTrend(recentScores, previousScores)
    if not recentScores or #recentScores == 0 then return "stable" end
    if not previousScores or #previousScores == 0 then return "stable" end

    local recentAvg, prevAvg = 0, 0
    for _, s in ipairs(recentScores) do recentAvg = recentAvg + s end
    for _, s in ipairs(previousScores) do prevAvg = prevAvg + s end
    recentAvg = recentAvg / #recentScores
    prevAvg = prevAvg / #previousScores

    local diff = recentAvg - prevAvg
    if diff > 5 then return "up"
    elseif diff < -5 then return "down"
    else return "stable" end
end

-- Badge determination. Much stricter; prestige badges require elite play.
-- ctx = {
--   playerData            (required): the player being scored,
--   runResult, stars, keyLevel,
--   provenLevel           (optional): player's Proven Level at time of run,
--   runDurationSec,
--   groupPlayers          (array): all players in the group for MVP/top-DPS checks,
--   encounterWipes, encounterDispelCounts,
--   groupTotalInterrupts, groupTotalDispels,
--   mitigationUptimePct   (optional, from live combat log),
--   kickAvailabilityRatio (optional, from live combat log),
-- }
function MPI.DetermineRunBadges(ctx)
    local badges = {}
    if not ctx or not ctx.playerData then return badges end
    local p = ctx.playerData
    local role = p.weightRole or p.role or "DPS"
    if role == "DAMAGER" then role = "DPS" end

    local deaths = p.deaths or 0
    local interrupts = p.interrupts or 0
    local dispels = p.dispels or 0
    local avoidable = p.avoidableDamage or 0
    local groupInt = ctx.groupTotalInterrupts or 0
    local groupWipes = ctx.encounterWipes or 0
    local timed = ctx.runResult == "timed"
    local stars = ctx.stars or 0
    local keyLevel = ctx.keyLevel or 0
    local proven = ctx.provenLevel or 0

    -- Active Kicker: 15+ kicks AND 30%+ group share AND 80%+ availability (if known).
    if interrupts >= 15 and groupInt > 0 and (interrupts / groupInt) >= 0.30 then
        local availOk = ctx.kickAvailabilityRatio == nil or ctx.kickAvailabilityRatio >= 0.80
        if availOk then
            badges[#badges + 1] = BADGE_DEFS.ACTIVE_KICKER.id
        end
    end

    -- Clean Run: 0 deaths AND 0 avoidable AND 0 wipes.
    if deaths == 0 and avoidable == 0 and groupWipes == 0 then
        badges[#badges + 1] = BADGE_DEFS.CLEAN_RUN.id
    end

    -- Timed+: timed 2+ stars AND key at or above Proven Level.
    if timed and stars >= 2 and keyLevel >= proven then
        badges[#badges + 1] = BADGE_DEFS.TIMED_PLUS.id
    end

    -- Flawless: group-wide 0 deaths AND personal 0 avoidable AND timed.
    local groupDeaths = 0
    if ctx.groupPlayers then
        for _, gp in pairs(ctx.groupPlayers) do
            groupDeaths = groupDeaths + (gp.deaths or 0)
        end
    end
    if timed and groupDeaths == 0 and avoidable == 0 then
        badges[#badges + 1] = BADGE_DEFS.FLAWLESS.id
    end

    -- No Wipe: 0 encounter wipes in the run.
    if groupWipes == 0 then
        badges[#badges + 1] = BADGE_DEFS.NO_WIPE.id
    end

    -- MVP: highest composite MPI in the group.
    if ctx.groupPlayers and p.mpiScore then
        local topMpi = 0
        for _, gp in pairs(ctx.groupPlayers) do
            if (gp.mpiScore or 0) > topMpi then topMpi = gp.mpiScore end
        end
        if p.mpiScore >= topMpi and topMpi > 0 then
            badges[#badges + 1] = BADGE_DEFS.MVP.id
        end
    end

    -- Wall (Tank only): <800k avoidable AND 80%+ mitigation uptime on +10+.
    if role == "TANK" and keyLevel >= 10 and avoidable < 800000
       and (ctx.mitigationUptimePct or 0) >= 0.80 then
        badges[#badges + 1] = BADGE_DEFS.WALL.id
    end

    -- Triage Master (Healer only): 0 group deaths from avoidable damage.
    -- Approximation: 0 group deaths (strict) until death-cause tracking matures.
    if role == "HEALER" and groupDeaths == 0 and timed then
        badges[#badges + 1] = BADGE_DEFS.TRIAGE_MASTER.id
    end

    -- Reaper (DPS only): top damage AND top kicks AND 0 deaths.
    if role == "DPS" and deaths == 0 and ctx.groupPlayers then
        local topDmg, topKicks = 0, 0
        for _, gp in pairs(ctx.groupPlayers) do
            if (gp.damageDone or 0) > topDmg then topDmg = gp.damageDone end
            if (gp.interrupts or 0) > topKicks then topKicks = gp.interrupts end
        end
        if (p.damageDone or 0) >= topDmg and topDmg > 0
           and interrupts >= topKicks and topKicks > 0 then
            badges[#badges + 1] = BADGE_DEFS.REAPER.id
        end
    end

    -- Chain Breaker: 5+ dispels in a single encounter.
    if ctx.encounterDispelCounts then
        for _, count in pairs(ctx.encounterDispelCounts) do
            if (count or 0) >= 5 then
                badges[#badges + 1] = BADGE_DEFS.CHAIN_BREAKER.id
                break
            end
        end
    end

    -- Under Pressure: timed a +15 or higher.
    if timed and keyLevel >= 15 then
        badges[#badges + 1] = BADGE_DEFS.UNDER_PRESSURE.id
    end

    -- Precision Kicker: 100% kick availability ratio (requires CLEU data).
    if ctx.kickAvailabilityRatio ~= nil and ctx.kickAvailabilityRatio >= 0.99 and interrupts >= 5 then
        badges[#badges + 1] = BADGE_DEFS.PRECISION_KICKER.id
    end

    -- ── ELITE BADGES (top-tier players only) ────────────────────────────

    -- Ascendant: S-tier composite MPI on timed +15+.
    if timed and keyLevel >= 15 and (p.mpiScore or 0) >= 95 then
        badges[#badges + 1] = BADGE_DEFS.ASCENDANT.id
    end

    -- Dragonslayer: timed +20 or higher.
    if timed and keyLevel >= 20 then
        badges[#badges + 1] = BADGE_DEFS.DRAGONSLAYER.id
    end

    -- Untouchable: timed +15+ with zero group deaths.
    if timed and keyLevel >= 15 and groupDeaths == 0 then
        badges[#badges + 1] = BADGE_DEFS.UNTOUCHABLE.id
    end

    -- Speed Demon: 3-star timed +15+ (stars=3 implies 40%+ time remaining per Blizzard).
    if timed and keyLevel >= 15 and stars >= 3 then
        badges[#badges + 1] = BADGE_DEFS.SPEED_DEMON.id
    end

    -- ── MYTHIC TIER (the impossible badge) ──────────────────────────────
    -- Transcendent: timed +20+ with 3-star, 95+ MPI, 0 group deaths, 0 avoidable damage.
    -- This stacks five elite conditions on the hardest content. Not humanly farmable.
    if timed and keyLevel >= 20 and stars >= 3
       and (p.mpiScore or 0) >= 95
       and groupDeaths == 0
       and avoidable == 0 then
        badges[#badges + 1] = BADGE_DEFS.TRANSCENDENT.id
    end

    return badges
end

function MPI.DetermineProfileBadges(profile)
    local badges = {}
    if not profile then return badges end

    -- Improver: scores up 10+ points (was 5+).
    if profile.trend == "up" and profile.recentScores and profile.previousScores then
        local rAvg, pAvg = 0, 0
        for _, s in ipairs(profile.recentScores)   do rAvg = rAvg + s end
        for _, s in ipairs(profile.previousScores) do pAvg = pAvg + s end
        if #profile.recentScores   > 0 then rAvg = rAvg / #profile.recentScores end
        if #profile.previousScores > 0 then pAvg = pAvg / #profile.previousScores end
        if (rAvg - pAvg) >= 10 then
            badges[#badges + 1] = BADGE_DEFS.IMPROVER.id
        end
    end

    local totalStarted = (profile.runsTracked or 0)
    local abandoned = (profile.abandonedRuns or 0)
    if totalStarted >= 5 then
        local completionRate = (totalStarted - abandoned) / totalStarted
        if completionRate >= 0.95 then
            badges[#badges + 1] = BADGE_DEFS.IRON_WILL.id
        end
    end

    local masteredCount = 0
    if profile.dungeonMastery then
        for _, mastery in pairs(profile.dungeonMastery) do
            if mastery.level == "MASTERED" then
                masteredCount = masteredCount + 1
            end
        end
    end
    if masteredCount >= 4 then
        badges[#badges + 1] = BADGE_DEFS.DUNGEON_EXPERT.id
    end

    -- Title Chaser: Proven Level of +15 or higher.
    if (profile.provenLevel or 0) >= 15 then
        badges[#badges + 1] = BADGE_DEFS.TITLE_CHASER.id
    end

    -- ── ELITE PROFILE BADGES ────────────────────────────────────────────

    -- Dragonslayer: ever timed a +20 this season.
    if (profile.peakTimedKey or 0) >= 20 then
        badges[#badges + 1] = BADGE_DEFS.DRAGONSLAYER.id
    end

    -- Godstreak: 10+ consecutive timed keys at +10 or higher at any point.
    if (profile.maxTimedStreakAt10Plus or 0) >= 10 then
        badges[#badges + 1] = BADGE_DEFS.GODSTREAK.id
    end

    return badges
end

function MPI.ComputeDungeonMastery(dungeonRuns)
    if not dungeonRuns or #dungeonRuns == 0 then return "LEARNING" end

    local timedCount = 0
    local totalScore = 0
    for _, run in ipairs(dungeonRuns) do
        if run.timed then timedCount = timedCount + 1 end
        totalScore = totalScore + (run.score or 0)
    end
    local avgScore = totalScore / #dungeonRuns

    if timedCount >= MASTERY_LEVELS.MASTERED.minTimedRuns and avgScore >= MASTERY_LEVELS.MASTERED.minAvgScore then
        return "MASTERED"
    elseif timedCount >= MASTERY_LEVELS.COMFORTABLE.minTimedRuns and avgScore >= MASTERY_LEVELS.COMFORTABLE.minAvgScore then
        return "COMFORTABLE"
    end
    return "LEARNING"
end

function MPI.ComputePressureCurve(runs)
    local brackets = {}
    local counts = {}
    for _, b in ipairs(KEY_BRACKETS) do
        brackets[b[3]] = 0
        counts[b[3]] = 0
    end

    for _, run in ipairs(runs or {}) do
        local kl = run.keyLevel or 0
        for _, b in ipairs(KEY_BRACKETS) do
            if kl >= b[1] and kl <= b[2] then
                brackets[b[3]] = brackets[b[3]] + (run.score or 0)
                counts[b[3]] = counts[b[3]] + 1
                break
            end
        end
    end

    local curve = {}
    local peakLabel, peakAvg = "", 0
    for _, b in ipairs(KEY_BRACKETS) do
        local label = b[3]
        if counts[label] > 0 then
            local avg = floor(brackets[label] / counts[label] + 0.5)
            curve[label] = avg
            if avg > peakAvg then
                peakAvg = avg
                peakLabel = label
            end
        end
    end

    return curve, peakLabel
end

function MPI.GenerateSmartAlerts(profile, dungeonMapID)
    local alerts = {}
    if not profile then return alerts end

    local totalStarted = profile.runsTracked or 0
    local abandoned = profile.abandonedRuns or 0
    if totalStarted >= 5 and abandoned >= 3 then
        local pct = floor(abandoned / totalStarted * 100)
        alerts[#alerts + 1] = {
            text = "Left " .. abandoned .. " of " .. totalStarted .. " runs (" .. pct .. "%)",
            severity = "warning",
        }
    end

    if profile.recentScores and #profile.recentScores >= 5 then
        local allTimed = true
        for i = 1, min(5, #profile.recentScores) do
            if (profile.recentScores[i] or 0) < 60 then allTimed = false; break end
        end
        if allTimed then
            alerts[#alerts + 1] = {
                text = "On a hot streak — last 5 runs all B+ or better",
                severity = "positive",
            }
        end
    end

    if profile.trend == "up" and profile.recentScores and profile.previousScores then
        local recentAvg, prevAvg = 0, 0
        for _, s in ipairs(profile.recentScores) do recentAvg = recentAvg + s end
        for _, s in ipairs(profile.previousScores) do prevAvg = prevAvg + s end
        if #profile.recentScores > 0 then recentAvg = recentAvg / #profile.recentScores end
        if #profile.previousScores > 0 then prevAvg = prevAvg / #profile.previousScores end
        if recentAvg - prevAvg >= 15 then
            alerts[#alerts + 1] = {
                text = "Improving rapidly — scores up " .. floor(recentAvg - prevAvg) .. " points",
                severity = "positive",
            }
        end
    end

    if dungeonMapID and profile.dungeonMastery then
        local mastery = profile.dungeonMastery[dungeonMapID]
        if mastery then
            if mastery.level == "MASTERED" then
                alerts[#alerts + 1] = {
                    text = "Strong in this dungeon (Mastered)",
                    severity = "positive",
                }
            elseif mastery.level == "LEARNING" then
                alerts[#alerts + 1] = {
                    text = "Still learning this dungeon",
                    severity = "info",
                }
            end
        else
            alerts[#alerts + 1] = {
                text = "No data for this dungeon",
                severity = "info",
            }
        end
    end

    if profile.roleHistory and #profile.roleHistory >= 2 then
        local recent = profile.roleHistory[1]
        local prev = profile.roleHistory[2]
        if recent ~= prev then
            alerts[#alerts + 1] = {
                text = "Recently switched to " .. (recent == "TANK" and "Tank" or recent == "HEALER" and "Healer" or "DPS") .. " — limited data",
                severity = "info",
            }
        end
    end

    return alerts
end



-- ============================================================
-- SECTION: Event Registration + Run Tracking Pipeline
-- READS: MidnightUI_MPI SavedVar, SnapshotRoster output
-- WRITES: runtime run state (activeRun table), SavedVar runs
-- eventFrame handles:
--   ADDON_LOADED → EnsureDB + LoadCommunityDB + boot UI.
--   CHALLENGE_MODE_START → SnapshotRoster + begin run record.
--   CHALLENGE_MODE_COMPLETED → finalize run, compute scores,
--     write to SavedVar profiles + runs tables.
--   ENCOUNTER_START/END → sub-encounter timing.
--   GROUP_ROSTER_UPDATE → refresh SnapshotRoster mid-run.
--   PLAYER_LOGOUT → MPI.BuildExportData() for companion sync.
-- SnapshotRoster() reads party/raid GUIDs + roles via
--   UnitGUID, UnitGroupRolesAssigned, GetSpecializationInfo.
-- ============================================================
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")

local PersistActiveRun, ClearPersistedActiveRun, TryRestoreActiveRun
local StartLiveCombatTracking, StopLiveCombatTracking

local function PatchRunsMissingDeaths()
    if not db or not db.runs then return end
    local patchedCount = 0
    for i, run in ipairs(db.runs) do
        if run and not run._deathsPatched and run.players and run.detail and run.detail.deathRecaps and #run.detail.deathRecaps > 0 then
            local deathCount = {}
            for _, recap in ipairs(run.detail.deathRecaps) do
                local n = recap and recap.playerName
                if n then
                    deathCount[n] = (deathCount[n] or 0) + 1
                    local short = n:match("^([^%-]+)")
                    if short and short ~= n then
                        deathCount[short] = (deathCount[short] or 0) + 1
                    end
                end
            end

            local avoidableSpellLookup = {}
            if run.detail.damageTakenBySpell then
                for _, spells in pairs(run.detail.damageTakenBySpell) do
                    for _, sp in ipairs(spells) do
                        if sp.isAvoidable and sp.spellID then
                            avoidableSpellLookup[sp.spellID] = true
                        end
                    end
                end
            end

            local groupDeathTotal = 0
            for _, p in pairs(run.players) do
                local meterD = p.deaths or 0
                local recapD = deathCount[p.name] or 0
                if recapD == 0 and p.name then
                    local short = p.name:match("^([^%-]+)")
                    if short then recapD = deathCount[short] or 0 end
                end
                p.deaths = max(meterD, recapD)
                groupDeathTotal = groupDeathTotal + p.deaths
            end

            local tankName, tankDeaths = nil, 0
            for _, p in pairs(run.players) do
                if p.role == "TANK" then tankName = p.name; tankDeaths = p.deaths or 0; break end
            end

            local encounterWipeCount = 0
            if run.encounters then
                for _, enc in ipairs(run.encounters) do
                    if enc.success == false then encounterWipeCount = encounterWipeCount + 1 end
                end
            end

            -- Collect tank death timestamps from recaps (for chain blame scoping).
            local tankDeathTimes = {}
            for _, recap in ipairs(run.detail.deathRecaps) do
                for _, pl in pairs(run.players) do
                    if pl.role == "TANK" and recap.playerName == pl.name then
                        local lastTs = recap.timestamp or 0
                        for _, ev in ipairs(recap.events or {}) do
                            if (ev.timestamp or 0) > lastTs then lastTs = ev.timestamp end
                        end
                        tankDeathTimes[#tankDeathTimes + 1] = lastTs
                        break
                    end
                end
            end

            for _, p in pairs(run.players) do
                local playerRecaps = {}
                for _, recap in ipairs(run.detail.deathRecaps) do
                    if recap.playerName == p.name then
                        playerRecaps[#playerRecaps + 1] = recap
                    end
                end
                local survivalScore = MPI.ScoreSurvival({
                    deaths          = p.deaths,
                    role            = p.weightRole or p.role,
                    deathRecaps     = playerRecaps,
                    avoidableSpells = avoidableSpellLookup,
                    encounterLog    = run.encounters,
                    tankDeathTimes  = tankDeathTimes,
                })
                p.survivalScore = survivalScore
                p.mpiScore = MPI.ComputeRunScore({
                    role        = p.weightRole or p.role,
                    awareness   = p.awarenessScore or 0,
                    survival    = p.survivalScore or 0,
                    output      = p.outputScore or 0,
                    utility     = p.utilityScore or 0,
                    consistency = p.consistencyScore,  -- nil when not yet scored
                    keyLevel    = run.keyLevel or 0,
                    stars       = run.stars or 0,
                    result      = run.result or "depleted",
                    durationSec = run.durationSec or 0,
                    timeLimitSec = run.timeLimitSec or 0,
                })
                MPI.DebugLog(string.format("PATCH run[%d] %s deaths=%d surv=%d -> MPI=%d",
                    i, tostring(p.name), p.deaths, p.survivalScore, p.mpiScore))
            end

            run._deathsPatched = true
            patchedCount = patchedCount + 1
        end
    end
    if patchedCount > 0 then
        MPI.DebugLog(string.format("PatchRunsMissingDeaths: patched %d run(s)", patchedCount))
        if MPI.UpdatePersonalStats then MPI.UpdatePersonalStats() end
        MPI.BuildExportData()
    end
end

-- Recompute every stored run's scores using current formulas.
-- Triggered when db.scoringVersion != SCORING_VERSION (after a scoring change ships).
-- Processes runs in chronological order so Proven Level updates progressively.
function MPI.RescoreAllRuns()
    if not db or not db.runs or #db.runs == 0 then return 0 end

    -- Sort runs chronologically (oldest first) so Proven Level grows with history.
    local chronologicalRuns = {}
    for i, r in ipairs(db.runs) do chronologicalRuns[#chronologicalRuns + 1] = r end
    table.sort(chronologicalRuns, function(a, b)
        return (a.timestamp or 0) < (b.timestamp or 0)
    end)

    local rescoredCount = 0
    local completedTimedRuns = {}  -- accumulator for Proven Level at each point in time

    for _, run in ipairs(chronologicalRuns) do
        if run.players and next(run.players) then
            local effDuration = max(1, run.durationSec or 1)
            local groupTotalDamage = 0
            local groupTotalDamageTaken = 0
            local groupTotalInterrupts = 0
            local allDpsAmounts = {}

            for _, p in pairs(run.players) do
                groupTotalDamage      = groupTotalDamage + (p.damageDone or 0)
                groupTotalDamageTaken = groupTotalDamageTaken + (p.damageTaken or 0)
                groupTotalInterrupts  = groupTotalInterrupts + (p.interrupts or 0)
                if (p.role == "DAMAGER" or p.role == "DPS") then
                    allDpsAmounts[#allDpsAmounts + 1] = p.damageDone or 0
                end
            end

            -- Tank death timestamps from recaps.
            local tankDeathTimes = {}
            local avoidableSpellLookup = {}
            if run.detail then
                if run.detail.damageTakenBySpell then
                    for _, spells in pairs(run.detail.damageTakenBySpell) do
                        for _, sp in ipairs(spells) do
                            if sp.isAvoidable and sp.spellID then
                                avoidableSpellLookup[sp.spellID] = true
                            end
                        end
                    end
                end
                if run.detail.deathRecaps then
                    for _, recap in ipairs(run.detail.deathRecaps) do
                        for _, pl in pairs(run.players) do
                            if pl.role == "TANK" and recap.playerName == pl.name then
                                local lastTs = recap.timestamp or 0
                                for _, ev in ipairs(recap.events or {}) do
                                    if (ev.timestamp or 0) > lastTs then lastTs = ev.timestamp end
                                end
                                tankDeathTimes[#tankDeathTimes + 1] = lastTs
                                break
                            end
                        end
                    end
                end
            end

            local encounterDurationSum = 0
            if run.encounters then
                for _, enc in ipairs(run.encounters) do
                    if enc.startTime and enc.endTime then
                        encounterDurationSum = encounterDurationSum + (enc.endTime - enc.startTime)
                    end
                end
            end
            local encounterWipeCount = 0
            if run.encounters then
                for _, enc in ipairs(run.encounters) do
                    if enc.success == false then encounterWipeCount = encounterWipeCount + 1 end
                end
            end

            -- Proven Level at this point in player history (based on runs BEFORE this one).
            local historicalProven = MPI.ComputeProvenLevel(completedTimedRuns)

            for guid, p in pairs(run.players) do
                local role = p.weightRole or p.role or "DPS"
                if role == "DAMAGER" then role = "DPS" end

                local runCohort = MPI.GetCohort(p.class, role, run.dungeonMapID, run.keyLevel)

                local awarenessScore = MPI.ScoreAwareness(p.avoidableDamage, effDuration, role, runCohort)

                local playerRecaps = {}
                if run.detail and run.detail.deathRecaps then
                    for _, recap in ipairs(run.detail.deathRecaps) do
                        if recap.playerName == p.name then
                            playerRecaps[#playerRecaps + 1] = recap
                        end
                    end
                end

                local survivalScore = MPI.ScoreSurvival({
                    deaths          = p.deaths,
                    role            = role,
                    deathRecaps     = playerRecaps,
                    avoidableSpells = avoidableSpellLookup,
                    encounterLog    = run.encounters,
                    tankDeathTimes  = tankDeathTimes,
                })

                local outputScore
                if role == "TANK" then
                    outputScore = MPI.ScoreTankOutput({
                        tankDamage       = p.damageDone,
                        groupTotalDamage = groupTotalDamage,
                        cohort           = runCohort,
                    })
                elseif role == "HEALER" then
                    outputScore = MPI.ScoreHealerOutput({
                        healingDone      = p.healingDone,
                        groupDamageTaken = groupTotalDamageTaken,
                        damageDone       = p.damageDone,
                        groupTotalDamage = groupTotalDamage,
                        cohort           = runCohort,
                    })
                elseif role == "AUG" then
                    outputScore = MPI.ScoreAugOutput({
                        augDamage        = p.damageDone,
                        groupTotalDamage = groupTotalDamage,
                    })
                else
                    outputScore = MPI.ScoreDpsOutput({
                        playerDamage  = p.damageDone,
                        allDpsAmounts = allDpsAmounts,
                        cohort        = runCohort,
                    })
                end

                local utilityScore = MPI.ScoreUtility({
                    interrupts           = p.interrupts,
                    dispels              = p.dispels,
                    groupTotalInterrupts = groupTotalInterrupts,
                    playerClass          = p.class,
                    playerSpec           = p.spec,
                })

                -- Consistency is now per-run execution (timed/depleted/leaver/wipes).
                -- Rebuild from the stored run state so older rows also migrate.
                local didLeaveEarly = false
                if run.leavers and guid and run.leavers[guid] then
                    didLeaveEarly = true
                end
                local consistencyScore, consistencyBreakdown = MPI.ScoreConsistency({
                    result         = run.result,
                    stars          = run.stars,
                    encounterWipes = encounterWipeCount,
                    didLeaveEarly  = didLeaveEarly,
                    surrendered    = run.surrendered,
                })
                p.consistencyScore     = consistencyScore
                p.consistencyBreakdown = consistencyBreakdown

                -- Proven Level + gear only applies to the player's own profile at time of run.
                -- For other group members in this rescore, we don't have their gear tier.
                local provenLvl
                local gearTier
                if guid and db.profiles[guid] and db.profiles[guid].ilvl then
                    gearTier = MPI.GetGearTier(db.profiles[guid].ilvl)
                end
                if p.name and db._nameIndex and db._nameIndex[p.name]
                   and db.profiles[db._nameIndex[p.name]] == db.profiles[UnitGUID and UnitGUID("player") or ""] then
                    provenLvl = historicalProven
                end

                local mpiScore = MPI.ComputeRunScore({
                    role        = role,
                    awareness   = awarenessScore,
                    survival    = survivalScore,
                    output      = outputScore,
                    utility     = utilityScore,
                    consistency = consistencyScore,
                    keyLevel    = run.keyLevel or 0,
                    stars       = run.stars or 0,
                    result      = run.result or "depleted",
                    durationSec = run.durationSec or 0,
                    timeLimitSec = run.timeLimitSec or 0,
                    provenLevel = provenLvl,
                    gearTier    = gearTier,
                })

                p.awarenessScore = awarenessScore
                p.survivalScore  = survivalScore
                p.outputScore    = outputScore
                p.utilityScore   = utilityScore
                p.mpiScore       = mpiScore
                p.weightRole     = role
            end

            -- Recompute badges AFTER all players have scores (MVP needs group).
            for _, p in pairs(run.players) do
                p.badges = MPI.DetermineRunBadges({
                    playerData           = p,
                    runResult            = run.result,
                    stars                = run.stars,
                    keyLevel             = run.keyLevel,
                    provenLevel          = historicalProven,
                    runDurationSec       = effDuration,
                    groupPlayers         = run.players,
                    encounterWipes       = encounterWipeCount,
                    groupTotalInterrupts = groupTotalInterrupts,
                })
            end

            rescoredCount = rescoredCount + 1

            -- Append this run to the timed-run accumulator for next iteration's Proven Level.
            if run.result == "timed" then
                completedTimedRuns[#completedTimedRuns + 1] = run
            end
        end
    end

    db.scoringVersion = SCORING_VERSION
    MPI.DebugLog(string.format("RescoreAllRuns: rescored %d run(s) to scoring v%d",
        rescoredCount, SCORING_VERSION))
    return rescoredCount
end

local function OnAddonLoaded(_, addonName)
    if addonName ~= ADDON_NAME then return end

    MPI.EnsureDB()

    -- Seed combat-log ownership from SavedVariable before anything else so
    -- TryRestoreActiveRun / Enable paths see the correct pre-reload state.
    if db and db.personal and db.personal.combatLogOwnedByUs ~= nil then
        MPI._combatLogOwnedByUs = db.personal.combatLogOwnedByUs and true or false
    end

    TryRestoreActiveRun()
    pcall(PatchRunsMissingDeaths)

    -- Scoring version check: recompute all stored runs if formulas have changed.
    if db and (db.scoringVersion or 0) ~= SCORING_VERSION then
        pcall(MPI.RescoreAllRuns)
        if MPI.BuildExportData then pcall(MPI.BuildExportData) end
    end

    pcall(MPI.UpdatePersonalStats)

    MPI.LoadCommunityDB()

    C_Timer.After(8, function()
        if MPI._companionUpdateChecked then return end
        MPI._companionUpdateChecked = true
        local sync = _G.MidnightUI_MPI_Sync
        local meta = _G.MidnightUI_MPI_DBMeta
        if not meta or not meta.latestCompanionVersion then return end
        if not sync or not sync.lastSync then return end
        local current = sync.companionVersion or "0.0.0"
        local latest  = meta.latestCompanionVersion
        local function parseVer(s)
            local parts = {}
            for n in tostring(s):gmatch("%d+") do parts[#parts + 1] = tonumber(n) or 0 end
            return parts
        end
        local c, l = parseVer(current), parseVer(latest)
        local outdated = false
        for i = 1, 3 do
            if (l[i] or 0) > (c[i] or 0) then outdated = true break end
            if (l[i] or 0) < (c[i] or 0) then break end
        end
        if not outdated then return end

        local notif = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        notif:SetHeight(45)
        local messengerFrame = _G.MyMessengerFrame
        if messengerFrame then
            notif:SetPoint("BOTTOMLEFT", messengerFrame, "TOPLEFT", 0, 4)
            notif:SetPoint("BOTTOMRIGHT", messengerFrame, "TOPRIGHT", 0, 4)
        else
            notif:SetSize(420, 45)
            notif:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 220)
        end
        notif:SetBackdrop({ bgFile = W8, edgeFile = W8, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 } })
        notif:SetBackdropColor(0.04, 0.04, 0.07, 0.95)
        notif:SetBackdropBorderColor(0.61, 0.48, 0.93, 0.3)
        notif:SetFrameStrata("FULLSCREEN_DIALOG")
        notif:SetFrameLevel(500)

        local accent = notif:CreateTexture(nil, "OVERLAY")
        accent:SetHeight(2)
        accent:SetPoint("TOPLEFT")
        accent:SetPoint("TOPRIGHT")
        accent:SetColorTexture(0.61, 0.48, 0.93, 0.5)

        local icon = notif:CreateTexture(nil, "ARTWORK")
        icon:SetSize(22, 22)
        icon:SetPoint("LEFT", notif, "LEFT", 10, 0)
        icon:SetTexture("Interface\\Icons\\INV_Relics_Hourglass")
        icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

        local infoFS = notif:CreateFontString(nil, "OVERLAY")
        TrySetFont(infoFS, BODY_FONT, 11, "")
        infoFS:SetPoint("LEFT", icon, "RIGHT", 8, 0)
        infoFS:SetPoint("RIGHT", notif, "RIGHT", -115, 0)
        infoFS:SetText("|cff9b7aedMPI|r  There is a new version of the MPI Companion on Atyzi with important fixes.")
        infoFS:SetTextColor(0.78, 0.75, 0.85)

        local updateBtn = CreateFrame("Button", nil, notif)
        updateBtn:SetSize(70, 26)
        updateBtn:SetPoint("RIGHT", notif, "RIGHT", -30, 0)
        local updateBg = updateBtn:CreateTexture(nil, "BACKGROUND")
        updateBg:SetAllPoints()
        updateBg:SetColorTexture(0.40, 0.28, 0.65, 0.3)
        local updateFS = updateBtn:CreateFontString(nil, "OVERLAY")
        TrySetFont(updateFS, BODY_FONT, 11, "OUTLINE")
        updateFS:SetPoint("CENTER")
        updateFS:SetText("Update")
        updateFS:SetTextColor(0.61, 0.48, 0.93)
        local uT = updateBtn:CreateTexture(nil, "OVERLAY"); uT:SetHeight(1)
        uT:SetPoint("TOPLEFT"); uT:SetPoint("TOPRIGHT"); uT:SetColorTexture(0.61, 0.48, 0.93, 0.3)
        local uB = updateBtn:CreateTexture(nil, "OVERLAY"); uB:SetHeight(1)
        uB:SetPoint("BOTTOMLEFT"); uB:SetPoint("BOTTOMRIGHT"); uB:SetColorTexture(0.61, 0.48, 0.93, 0.3)
        local uL = updateBtn:CreateTexture(nil, "OVERLAY"); uL:SetWidth(1)
        uL:SetPoint("TOPLEFT"); uL:SetPoint("BOTTOMLEFT"); uL:SetColorTexture(0.61, 0.48, 0.93, 0.3)
        local uR = updateBtn:CreateTexture(nil, "OVERLAY"); uR:SetWidth(1)
        uR:SetPoint("TOPRIGHT"); uR:SetPoint("BOTTOMRIGHT"); uR:SetColorTexture(0.61, 0.48, 0.93, 0.3)
        updateBtn:SetScript("OnEnter", function() updateBg:SetColorTexture(0.40, 0.28, 0.65, 0.5) end)
        updateBtn:SetScript("OnLeave", function() updateBg:SetColorTexture(0.40, 0.28, 0.65, 0.3) end)
        updateBtn:SetScript("OnClick", function()
            if type(_G.MidnightUI_OpenURL) == "function" then
                _G.MidnightUI_OpenURL("https://mpi.atyzi.com")
            end
        end)

        local dismissBtn = CreateFrame("Button", nil, notif)
        dismissBtn:SetSize(16, 16)
        dismissBtn:SetPoint("RIGHT", notif, "RIGHT", -8, 0)
        local dismissFS = dismissBtn:CreateFontString(nil, "OVERLAY")
        TrySetFont(dismissFS, BODY_FONT, 11, "OUTLINE")
        dismissFS:SetPoint("CENTER")
        dismissFS:SetText("X")
        dismissFS:SetTextColor(0.5, 0.5, 0.5, 0.4)
        dismissBtn:SetScript("OnClick", function() notif:Hide() end)
        dismissBtn:SetScript("OnEnter", function() dismissFS:SetTextColor(1, 0.3, 0.3) end)
        dismissBtn:SetScript("OnLeave", function() dismissFS:SetTextColor(0.5, 0.5, 0.5, 0.4) end)

        notif:Show()

        C_Timer.After(30, function()
            if notif and notif:IsShown() then notif:Hide() end
        end)
    end)

    eventFrame:RegisterEvent("PLAYER_LOGOUT")

    if C_DamageMeter and C_DamageMeter.GetAvailableCombatSessions then
        MPI._apiAvailable = true
    end

    eventFrame:RegisterEvent("CHALLENGE_MODE_START")
    eventFrame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    eventFrame:RegisterEvent("CHALLENGE_MODE_RESET")
    eventFrame:RegisterEvent("ENCOUNTER_START")
    eventFrame:RegisterEvent("ENCOUNTER_END")
    eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
    -- Instance-entry trigger for /combatlog auto-toggle. Fires on login,
    -- /reload, and every zone change — see OnPlayerEnteringWorld for the
    -- party-instance check that drives logging on/off.
    eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")

    MPI._ready = true
    MPI.DebugLog(string.format("==== SESSION %s %s ==== apiReady=%s existingTrace=%d",
        date("%Y-%m-%d"), date("%H:%M:%S"),
        MPI._apiAvailable and "yes" or "no",
        #MPI._trace))

end

-- Resolve "our" realm in the slug form Blizzard uses for cross-realm
-- UnitName() returns ("AlteracMountains", not "Alterac Mountains"). This is
-- the value we stamp on roster entries whose realm came back empty — i.e.
-- the local player and any same-realm party members. Without this fallback,
-- those entries serialize as bare "Oeilvert" instead of "Oeilvert-Sargeras",
-- and the server's mpi_run_players insert (which requires name-realm format)
-- silently skips them.
local function ResolveOurRealmSlug()
    if GetNormalizedRealmName then
        local r = GetNormalizedRealmName()
        if r and r ~= "" then return r end
    end
    if UnitFullName then
        local _, r = UnitFullName("player")
        if r and r ~= "" then return (r:gsub("%s+", "")) end
    end
    if GetRealmName then
        local r = GetRealmName()
        if r and r ~= "" then return (r:gsub("%s+", "")) end
    end
    return nil
end

-- Append "-realm" to a name when we have a realm to attach. Returns the
-- input unchanged when realm is empty/nil. Centralizes the format so the
-- local-player path and the party-member path produce identical strings.
local function AppendRealm(name, realm)
    if not name or name == "" then return name end
    if not realm or realm == "" then return name end
    return name .. "-" .. realm
end

local function SnapshotRoster()
    local function normalizedGroupRole(unit)
        local role = UnitGroupRolesAssigned(unit)
        if role == "TANK" or role == "HEALER" or role == "DAMAGER" then
            return role
        end
        return "DAMAGER"
    end

    local roster = {}
    local ourRealm = ResolveOurRealmSlug()
    local myGUID = UnitGUID("player")
    if myGUID then
        local myName = AppendRealm(UnitName("player"), ourRealm)
        local _, myClass = UnitClass("player")
        local myRole = normalizedGroupRole("player")
        local mySpecIcon = 0
        local specIdx = GetSpecialization and GetSpecialization()
        if specIdx and GetSpecializationInfo then
            local _, _, _, icon = GetSpecializationInfo(specIdx)
            mySpecIcon = icon or 0
        end
        roster[myGUID] = { name = myName, class = myClass, role = myRole, specIcon = mySpecIcon }
    end

    local numGroup = GetNumGroupMembers() or 0
    local prefix = IsInRaid() and "raid" or "party"
    local count = IsInRaid() and numGroup or (numGroup - 1)
    for i = 1, count do
        local unit = prefix .. i
        local guid = UnitGUID(unit)
        if guid and guid ~= myGUID then
            local name, realm = UnitName(unit)
            -- Same-realm members come back with realm = nil/"". Fall back to
            -- ours, since they're necessarily on our realm in that case.
            if (not realm or realm == "") and ourRealm then
                realm = ourRealm
            end
            local _, classFile = UnitClass(unit)
            local role = normalizedGroupRole(unit)
            roster[guid] = {
                name     = AppendRealm(name, realm),
                class    = classFile or "",
                role     = role,
                specIcon = 0,
            }
        end
    end
    return roster
end

PersistActiveRun = function()
    pcall(function()
        if not db or not MPI._activeRun then return end
        db.inFlightRun = {
            activeRun      = MPI._activeRun,
            encounterLog   = MPI._encounterLog,
            leavers        = MPI._leavers,
            rosterSnapshot = MPI._rosterSnapshot,
            surrendered    = MPI._surrendered,
            savedAt        = time(),
        }
    end)
end

ClearPersistedActiveRun = function()
    pcall(function()
        if not db then return end
        db.inFlightRun = nil
    end)
end

TryRestoreActiveRun = function()
    pcall(function()
        if not db or not db.inFlightRun then return end
        local saved = db.inFlightRun

        local activeMapID
        if C_ChallengeMode and C_ChallengeMode.GetActiveChallengeMapID then
            activeMapID = C_ChallengeMode.GetActiveChallengeMapID()
        end
        if not activeMapID then
            MPI.DebugLog("TryRestoreActiveRun: no active key — clearing stale saved record")
            db.inFlightRun = nil
            return
        end

        if not saved.activeRun or saved.activeRun.mapID ~= activeMapID then
            MPI.DebugLog(string.format("TryRestoreActiveRun: saved mapID=%s differs from active mapID=%s — clearing",
                tostring(saved.activeRun and saved.activeRun.mapID), tostring(activeMapID)))
            db.inFlightRun = nil
            return
        end

        MPI._activeRun      = saved.activeRun
        MPI._encounterLog   = saved.encounterLog or {}
        MPI._leavers        = saved.leavers or {}
        MPI._rosterSnapshot = saved.rosterSnapshot or {}
        MPI._surrendered    = saved.surrendered or false

        local wallClockElapsed = time() - (MPI._activeRun.startTime or time())
        MPI._activeRun.startGetTime = GetTime() - wallClockElapsed

        local rosterCount = 0
        for _ in pairs(MPI._rosterSnapshot) do rosterCount = rosterCount + 1 end
        MPI.DebugLog(string.format("TryRestoreActiveRun: restored in-flight key mapID=%d key=+%d roster=%d — re-arming combat capture",
            MPI._activeRun.mapID or 0, MPI._activeRun.keyLevel or 0, rosterCount))

        if StartLiveCombatTracking then
            StartLiveCombatTracking()
        end
        EnableKeyCombatLog()
    end)
end

-- ============================================================
-- SECTION: Live Combat Tracking (intentionally stubbed)
-- WoW 12 (Midnight, Interface 120000+) hard-blocks every addon
-- from subscribing to COMBAT_LOG_EVENT_UNFILTERED. Confirmed by
-- exhaustive testing 2026-05-01 and by reading Details/BigWigs
-- (both gate CLEU behind IsAddonApocalypseWow / self:Retail()).
-- See gotcha_wow12_cleu_blocked.md for the full background.
--
-- The per-run telemetry this section used to capture — tank
-- mitigation uptime, defensive cooldown usage, death timestamps,
-- player kicks, healer absorbs, physical-vs-magical damage taken
-- — is now reconstructed server-side by parsing the bare combat
-- log file the Companion uploads (WoWCombatLog.txt). The Compute*
-- helpers below remain so the downstream scoring shape is stable;
-- they all return nil/0 because MPI._liveCombat is never populated.
--
-- Start/Stop are kept as no-ops because four call sites still
-- invoke them (TryRestoreActiveRun, OnChallengeModeStart,
-- OnChallengeModeCompleted, the CHALLENGE_MODE_RESET branch in
-- the main dispatcher). No data is touched.
-- ============================================================

StartLiveCombatTracking = function()
    MPI.DebugLog("live combat capture: noop (WoW 12 blocks CLEU; data sourced server-side)")
end

StopLiveCombatTracking = function()
    -- Intentional no-op. Symmetry with StartLiveCombatTracking.
end

-- Compute mitigation uptime percentage for the local player across the run.
local function ComputeLocalMitigationUptime(runDurationSec)
    if not MPI._liveCombat or not MPI._liveCombat.mitigationAura then return nil end
    if runDurationSec <= 0 then return nil end
    return min(1, MPI._liveCombat.mitigationAura.totalSec / runDurationSec)
end

-- For each death recap of a player, count defensives that were off cooldown
-- at the moment of death. Uses the player's class/role to look up defensive IDs.
local function ComputeDefensivesAvailableByDeath(playerName, playerClass, playerRole, deathRecaps)
    if not MPI._liveCombat then return nil end
    if not playerClass or not playerRole then return nil end
    if playerRole == "DAMAGER" then playerRole = "DPS" end

    local classEntry = DEFENSIVE_CDS[playerClass]
    if not classEntry then return nil end
    local cdList = classEntry[playerRole]
    if not cdList then return nil end

    local deathGetTimes = MPI._liveCombat.playerDeaths[playerName] or {}
    local playerCasts = MPI._liveCombat.defensiveCasts[playerName] or {}

    -- Approximate per-spell minimum cooldown (most major CDs 1-5 minutes).
    -- If we don't know, assume 120s as a baseline.
    local DEFAULT_CD = 120

    local result = {}
    for idx, _ in ipairs(deathRecaps) do
        local deathAt = deathGetTimes[idx]
        if not deathAt then break end
        local availableCount = 0
        for _, spellID in ipairs(cdList) do
            local lastCast = playerCasts[spellID]
            if not lastCast or (deathAt - lastCast) >= DEFAULT_CD then
                availableCount = availableCount + 1
            end
        end
        result[idx] = availableCount
    end
    return result
end

-- Kick availability ratio: kicks landed / (kicks landed + missed-while-available).
-- "Missed-while-available" = a kickable enemy cast completed while the player's
-- interrupt was off cooldown. A cast is "kickable" if we observed ANY player
-- interrupt that same spell ID during the run.
local function ComputeKickAvailabilityRatio(playerName, playerClass, playerSpec)
    if not MPI._liveCombat or not playerName or not playerClass then return nil end
    local interruptEntry = LookupClassCapability(INTERRUPTS, playerClass, playerSpec)
    if not interruptEntry then return nil end
    local cooldownSec = interruptEntry.cooldown or 15

    local kicksLanded = MPI._liveCombat.playerKicks[playerName] or 0
    if kicksLanded == 0 and #MPI._liveCombat.enemyCastCompletions == 0 then
        return nil  -- no signal either way
    end

    local lastKickAt = MPI._liveCombat.playerLastKickAt[playerName] or -1e9
    local missedWhileAvailable = 0

    for _, cast in ipairs(MPI._liveCombat.enemyCastCompletions) do
        if MPI._liveCombat.kickableSpellIDs[cast.spellID] then
            -- Was player's kick off CD at that moment?
            -- We only know ONE "last kick at" for the player; for accuracy we walk
            -- back through their kick history. Here we use the simpler heuristic:
            -- if cast happened at least CD seconds after the most recent prior kick,
            -- it was available. For multi-kick runs this is conservative (may
            -- count some unkickable-at-that-time as available), but it's directional.
            if (cast.time - lastKickAt) >= cooldownSec then
                missedWhileAvailable = missedWhileAvailable + 1
            end
        end
    end

    local opportunities = kicksLanded + missedWhileAvailable
    if opportunities == 0 then return nil end
    return kicksLanded / opportunities
end

-- Tank threat retention: tank's share of group physical damage taken.
local function ComputePhysicalDamageShares(tankName)
    if not MPI._liveCombat or not tankName then return nil, nil end
    local tankPhys = MPI._liveCombat.physicalDmgTaken[tankName] or 0
    local groupPhys = 0
    for _, amt in pairs(MPI._liveCombat.physicalDmgTaken) do
        groupPhys = groupPhys + amt
    end
    if groupPhys == 0 then return nil, nil end
    return tankPhys, groupPhys
end

-- Healer absorbs applied (approximate — CLEU SPELL_ABSORBED layout varies).
local function ComputeAbsorbsApplied(healerName)
    if not MPI._liveCombat or not healerName then return 0 end
    return MPI._liveCombat.absorbsAppliedBy[healerName] or 0
end

-- ============================================================
-- SECTION: WoWCombatLog.txt enable/disable for keys
-- Auto-toggles /combatlog so the Companion can parse the raw
-- file. Remembers whether *we* turned it on; never disables a
-- user's pre-existing logging on our way out.
-- ============================================================
-- Ownership persistence: `db.personal.combatLogOwnedByUs` survives /reload so
-- that if we reload into an active key where *we* had turned the log on, we
-- can still recognize it as ours and disable it cleanly at key end.
local function SetOwned(flag)
    MPI._combatLogOwnedByUs = flag and true or false
    if db and db.personal then
        db.personal.combatLogOwnedByUs = MPI._combatLogOwnedByUs
    end
end

local function EnableKeyCombatLog()
    if not db or not db.settings or db.settings.combatLogAutoForKeys == false then
        MPI.DebugLog("combatlog: auto-enable is OFF in settings; skipping")
        return
    end
    if type(LoggingCombat) ~= "function" then
        MPI.DebugLog("combatlog: LoggingCombat API not available")
        return
    end
    if MPI._combatLogOwnedByUs then
        MPI.DebugLog("combatlog: already on and owned by us; re-entry no-op")
        return
    end
    local wasOn = LoggingCombat() and true or false
    if wasOn then
        -- Always claim ownership when entering an M+ instance, regardless
        -- of whether the user or a previous session turned logging on.
        -- This addon is M+-only — if you've installed it with auto-toggle
        -- enabled (the default), you've opted in to having MPI manage
        -- /combatlog. The opt-out is the combatLogAutoForKeys setting
        -- gated above. Reason for the change: the prior "respect user
        -- ownership" branch meant any pre-existing /combatlog (including
        -- one the user toggled themselves to test something, or one left
        -- on after a crash) would never get auto-disabled when leaving
        -- the dungeon, defeating the file-size protection. See the
        -- May 2026 conversation around capture-rate diagnostics.
        SetOwned(true)
        MPI.DebugLog("combatlog: already ON at instance entry; claiming ownership so we'll auto-disable on exit")
        return
    end
    local ok, err = pcall(LoggingCombat, true)
    if ok then
        SetOwned(true)
        MPI.DebugLog("combatlog: enabled for this key (we own it; will auto-disable at key end)")
    else
        MPI.DebugLog("combatlog: enable FAILED: " .. tostring(err))
    end
end

local function DisableKeyCombatLog()
    local svOwned = db and db.personal and db.personal.combatLogOwnedByUs
    -- Trust either memory or SV as proof of ownership.
    if not (MPI._combatLogOwnedByUs or svOwned) then
        MPI.DebugLog("combatlog: not owned by us; leaving state untouched")
        return
    end
    if type(LoggingCombat) ~= "function" then
        SetOwned(false)
        return
    end
    local ok, err = pcall(LoggingCombat, false)
    if ok then
        MPI.DebugLog("combatlog: disabled at key end (we turned it on)")
    else
        MPI.DebugLog("combatlog: disable FAILED: " .. tostring(err))
    end
    SetOwned(false)
end

-- Instance-entry hook for /combatlog auto-toggle.
-- Fires every time the player loads into a new map (zone change, login,
-- /reload, hearthstone). When the new zone is a 5-man dungeon ("party"
-- instance), enable logging *before* the keystone is inserted so WoW has
-- already opened the log file by the time CHALLENGE_MODE_START fires —
-- otherwise that line never gets written and the Companion's slicer
-- can't identify the run. When leaving for any non-dungeon zone, disable
-- ours so log files don't accumulate while questing / in cities / raids.
local function OnPlayerEnteringWorld()
    local _, instanceType = GetInstanceInfo()
    MPI.DebugLog("combatlog: PLAYER_ENTERING_WORLD instanceType=" .. tostring(instanceType))
    if instanceType == "party" then
        EnableKeyCombatLog()
    else
        DisableKeyCombatLog()
    end
end

local function OnChallengeModeStart()
    MPI.DebugLog("=== CHALLENGE_MODE_START fired ===")
    EnableKeyCombatLog()
    if not MPI._apiAvailable then
        MPI.DebugLog("ABORT: C_ChallengeMode API not available on this client")
        return
    end

    StartLiveCombatTracking()

    local mapID = C_ChallengeMode.GetActiveChallengeMapID and C_ChallengeMode.GetActiveChallengeMapID()
    if not mapID then
        MPI.DebugLog("ABORT: GetActiveChallengeMapID returned nil (no active key)")
        return
    end

    local keyLevel, affixes
    if C_ChallengeMode.GetActiveKeystoneInfo then
        keyLevel, affixes = C_ChallengeMode.GetActiveKeystoneInfo()
    end

    local dungeonName, _, timeLimitSec
    if C_ChallengeMode.GetMapUIInfo then
        dungeonName, _, timeLimitSec = C_ChallengeMode.GetMapUIInfo(mapID)
    end

    local affixStr = "none"
    if affixes and #affixes > 0 then
        affixStr = table.concat(affixes, ",")
    end
    MPI.DebugLog(string.format("run-begin mapID=%d name=%s key=+%d timeLimit=%ds affixes=[%s]",
        mapID, tostring(dungeonName or "Unknown"), keyLevel or 0, floor(timeLimitSec or 0), affixStr))

    MPI._activeRun = {
        startTime    = time(),
        startGetTime = GetTime(),
        mapID        = mapID,
        dungeonName  = dungeonName or "Unknown",
        keyLevel     = keyLevel or 0,
        timeLimitSec = timeLimitSec or 0,
        affixes      = affixes or {},
    }
    MPI._encounterLog = {}
    MPI._leavers = {}
    MPI._rosterSnapshot = SnapshotRoster()

    local rosterCount = 0
    for guid, info in pairs(MPI._rosterSnapshot) do
        rosterCount = rosterCount + 1
        MPI.DebugLog(string.format("  roster[%d] name=%s class=%s role=%s",
            rosterCount, tostring(info.name), tostring(info.class), tostring(info.role)))
    end
    MPI.DebugLog(string.format("roster snapshot taken: %d players", rosterCount))
    MPI.DebugLog("tracking active — awaiting ENCOUNTER_START / CHALLENGE_MODE_COMPLETED")
    PersistActiveRun()
end

local function OnEncounterStart(encounterID, encounterName)
    if not MPI._activeRun then
        MPI.DebugLog(string.format("ENCOUNTER_START ignored (no active key): id=%s name=%s",
            tostring(encounterID), tostring(encounterName)))
        return
    end
    MPI.DebugLog(string.format("ENCOUNTER_START id=%s name=%s — boss pull #%d",
        tostring(encounterID), tostring(encounterName), #MPI._encounterLog + 1))
    MPI._encounterLog[#MPI._encounterLog + 1] = {
        id = encounterID,
        name = encounterName,
        startTime = GetTime(),
        endTime = nil,
        success = nil,
    }
    PersistActiveRun()
end

local function HarvestEncounterData()
    local result = { players = {}, topSpells = {} }
    if not C_DamageMeter or not C_DamageMeter.GetCombatSessionFromType then return result end

    local sessionType = nil
    if Enum and Enum.DamageMeterSessionType then
        sessionType = Enum.DamageMeterSessionType.CurrentEncounter
                   or Enum.DamageMeterSessionType.CurrentBoss
                   or Enum.DamageMeterSessionType.LastEncounter
                   or Enum.DamageMeterSessionType.LastFight
    end
    if not sessionType then
        return result
    end

    local probeOk, probeSession = pcall(C_DamageMeter.GetCombatSessionFromType,
        sessionType, Enum.DamageMeterType.DamageDone)
    if not probeOk or not probeSession then return result end

    local VALID = {
        WARRIOR=1, PALADIN=1, HUNTER=1, ROGUE=1, PRIEST=1, DEATHKNIGHT=1,
        SHAMAN=1, MAGE=1, WARLOCK=1, MONK=1, DRUID=1, DEMONHUNTER=1, EVOKER=1,
    }

    local meterTypes = {
        { enum = Enum.DamageMeterType.DamageDone,          field = "damageDone" },
        { enum = Enum.DamageMeterType.DamageTaken,          field = "damageTaken" },
        { enum = Enum.DamageMeterType.AvoidableDamageTaken, field = "avoidableDamage" },
        { enum = Enum.DamageMeterType.HealingDone,          field = "healingDone" },
        { enum = Enum.DamageMeterType.Interrupts,           field = "interrupts" },
        { enum = Enum.DamageMeterType.Dispels,              field = "dispels" },
        { enum = Enum.DamageMeterType.Deaths,               field = "deaths" },
    }

    -- Same secret-value taint defense as HarvestDamageMeterData: wrap
    -- each combat source in pcall + skip tainted names. Without this,
    -- a single Aug-Evoker-style tainted name aborts the harvest, leaves
    -- enc.stats unset, and the run record ships with empty per-encounter
    -- players (UI shows 0 damage / 0 taken on every boss segment).
    local issec = _G.issecretvalue or function() return false end
    for _, mt in ipairs(meterTypes) do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType, sessionType, mt.enum)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID[source.classFilename] and source.name then
                        if not result.players[source.name] then
                            result.players[source.name] = {
                                name = source.name,
                                class = source.classFilename,
                                isLocal = source.isLocalPlayer or false,
                            }
                        end
                        result.players[source.name][mt.field] = source.totalAmount or 0
                    end
                end)
            end
        end
    end

    local encGuidLookup = {}
    do
        local lok, lsess = pcall(C_DamageMeter.GetCombatSessionFromType,
            sessionType, Enum.DamageMeterType.DamageDone)
        if lok and lsess and lsess.combatSources then
            for _, source in ipairs(lsess.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID[source.classFilename] and source.name then
                        encGuidLookup[source.name] = source.sourceGUID
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            sessionType, Enum.DamageMeterType.DamageDone)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID[source.classFilename] and source.name then
                        local spells = nil
                        if C_DamageMeter.GetCombatSessionSourceFromType then
                            local sok, sd = pcall(C_DamageMeter.GetCombatSessionSourceFromType,
                                sessionType, Enum.DamageMeterType.DamageDone, source.sourceGUID)
                            if sok and sd and sd.combatSpells then spells = sd.combatSpells end
                        end
                        if spells then
                            local breakdown = {}
                            for _, sp in ipairs(spells) do
                                breakdown[#breakdown + 1] = {
                                    spellID   = sp.spellID or 0,
                                    spellName = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total     = sp.totalAmount or 0,
                                }
                            end
                            table.sort(breakdown, function(a, b) return a.total > b.total end)
                            while #breakdown > 8 do table.remove(breakdown) end
                            if not result.topSpells[source.name] then result.topSpells[source.name] = {} end
                            result.topSpells[source.name].damage = breakdown
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            sessionType, Enum.DamageMeterType.DamageTaken)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID[source.classFilename] and source.name then
                        local spells = nil
                        if C_DamageMeter.GetCombatSessionSourceFromType then
                            local sok, sd = pcall(C_DamageMeter.GetCombatSessionSourceFromType,
                                sessionType, Enum.DamageMeterType.DamageTaken, source.sourceGUID)
                            if sok and sd and sd.combatSpells then spells = sd.combatSpells end
                        end
                        if spells then
                            local avoidable = {}
                            for _, sp in ipairs(spells) do
                                if sp.isAvoidable then
                                    avoidable[#avoidable + 1] = {
                                        spellID     = sp.spellID or 0,
                                        spellName   = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                        total       = sp.totalAmount or 0,
                                        isDeadly    = sp.isDeadly or false,
                                        sourceName  = sp.creatureName or "Unknown",
                                    }
                                end
                            end
                            table.sort(avoidable, function(a, b) return a.total > b.total end)
                            if not result.topSpells[source.name] then result.topSpells[source.name] = {} end
                            result.topSpells[source.name].avoidable = avoidable
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            sessionType, Enum.DamageMeterType.HealingDone)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID[source.classFilename] and source.name then
                        local spells = nil
                        if C_DamageMeter.GetCombatSessionSourceFromType then
                            local sok, sd = pcall(C_DamageMeter.GetCombatSessionSourceFromType,
                                sessionType, Enum.DamageMeterType.HealingDone, source.sourceGUID)
                            if sok and sd and sd.combatSpells then spells = sd.combatSpells end
                        end
                        if spells then
                            local breakdown = {}
                            for _, sp in ipairs(spells) do
                                breakdown[#breakdown + 1] = {
                                    spellID   = sp.spellID or 0,
                                    spellName = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total     = sp.totalAmount or 0,
                                }
                            end
                            table.sort(breakdown, function(a, b) return a.total > b.total end)
                            while #breakdown > 8 do table.remove(breakdown) end
                            if not result.topSpells[source.name] then result.topSpells[source.name] = {} end
                            result.topSpells[source.name].healing = breakdown
                        end
                    end
                end)
            end
        end
    end

    return result
end

local function OnEncounterEnd(encounterID, encounterName, difficultyID, groupSize, success)
    if not MPI._activeRun then
        MPI.DebugLog(string.format("ENCOUNTER_END ignored (no active key): id=%s success=%s",
            tostring(encounterID), tostring(success)))
        return
    end

    -- Defensive belt: HarvestEncounterData touches C_DamageMeter, which can
    -- still surface secret-tainted values that the inner per-source pcalls
    -- might miss. A throw at this call site used to abort OnEncounterEnd
    -- entirely, leaving enc.stats unset and the run record shipping with
    -- empty per-encounter players (UI shows 0 damage / 0 taken on every
    -- boss). Pcall here so the encounter is always logged with at least
    -- the success/duration even if the meter harvest fails.
    local encounterStats
    local okHarvest, errHarvest = pcall(function() encounterStats = HarvestEncounterData() end)
    if not okHarvest then
        MPI.DebugLog("WARN: HarvestEncounterData threw: " .. tostring(errHarvest))
        encounterStats = { players = {}, topSpells = {} }
    end

    local harvestedPlayers = 0
    if encounterStats and encounterStats.players then
        for _ in pairs(encounterStats.players) do harvestedPlayers = harvestedPlayers + 1 end
    end

    local matched = false
    for i = #MPI._encounterLog, 1, -1 do
        local enc = MPI._encounterLog[i]
        if enc.id == encounterID and not enc.endTime then
            enc.endTime = GetTime()
            enc.success = (success == 1)
            enc.stats = encounterStats
            local dur = floor(enc.endTime - (enc.startTime or enc.endTime))
            MPI.DebugLog(string.format("ENCOUNTER_END id=%s name=%s success=%s duration=%ds harvested=%d players",
                tostring(encounterID), tostring(encounterName), tostring(success == 1), dur, harvestedPlayers))
            matched = true
            break
        end
    end
    if not matched then
        MPI.DebugLog(string.format("ENCOUNTER_END id=%s had no open pull record (already closed or never started)",
            tostring(encounterID)))
    end
    PersistActiveRun()
end

local function OnGroupRosterUpdate()
    if not MPI._activeRun or not MPI._rosterSnapshot then return end
    if C_ChallengeMode.IsChallengeModeActive and not C_ChallengeMode.IsChallengeModeActive() then return end

    local currentGUIDs = {}
    local myGUID = UnitGUID("player")
    if myGUID then currentGUIDs[myGUID] = true end

    local numGroup = GetNumGroupMembers() or 0
    local prefix = IsInRaid() and "raid" or "party"
    local count = IsInRaid() and numGroup or (numGroup - 1)
    for i = 1, count do
        local guid = UnitGUID(prefix .. i)
        if guid then currentGUIDs[guid] = true end
    end

    for guid in pairs(MPI._leavers) do
        if currentGUIDs[guid] then
            MPI._leavers[guid] = nil
        end
    end

    local hasExistingLeaver = false
    for _ in pairs(MPI._leavers) do hasExistingLeaver = true; break end

    local now = time()
    for guid, info in pairs(MPI._rosterSnapshot) do
        if not currentGUIDs[guid] and guid ~= myGUID then
            if not MPI._leavers[guid] then
                MPI._leavers[guid] = {
                    name = info.name,
                    leftAt = now,
                    isInitiator = not hasExistingLeaver,
                }
                MPI.DebugLog(string.format("LEAVER detected: name=%s initiator=%s",
                    tostring(info.name), tostring(not hasExistingLeaver)))
                hasExistingLeaver = true
            end
        end
    end

    local rosterSize = 0
    for _ in pairs(MPI._rosterSnapshot) do rosterSize = rosterSize + 1 end
    local leaverCount = 0
    local earliestLeave, latestLeave = nil, nil
    for _, lInfo in pairs(MPI._leavers) do
        leaverCount = leaverCount + 1
        if not earliestLeave or lInfo.leftAt < earliestLeave then earliestLeave = lInfo.leftAt end
        if not latestLeave or lInfo.leftAt > latestLeave then latestLeave = lInfo.leftAt end
    end
    if leaverCount >= (rosterSize - 1) and earliestLeave and latestLeave and (latestLeave - earliestLeave) <= 5 then
        for _, lInfo in pairs(MPI._leavers) do
            lInfo.isInitiator = false
        end
        if not MPI._surrendered then
            MPI.DebugLog(string.format("GROUP SURRENDER detected (leavers=%d within %ds) — no blame assigned",
                leaverCount, latestLeave - earliestLeave))
        end
        MPI._surrendered = true
    end
    PersistActiveRun()
end

local function HarvestDamageMeterData()
    local data = {}
    if not C_DamageMeter or not C_DamageMeter.GetCombatSessionFromType then return data end

    local meterTypes = {
        { enum = Enum.DamageMeterType.DamageDone,          field = "damageDone" },
        { enum = Enum.DamageMeterType.DamageTaken,          field = "damageTaken" },
        { enum = Enum.DamageMeterType.AvoidableDamageTaken, field = "avoidableDamage" },
        { enum = Enum.DamageMeterType.HealingDone,          field = "healingDone" },
        { enum = Enum.DamageMeterType.Dispels,              field = "dispels" },
        { enum = Enum.DamageMeterType.Interrupts,           field = "interrupts" },
        { enum = Enum.DamageMeterType.Deaths,               field = "deaths" },
    }

    local VALID_CLASSES = {
        WARRIOR=1, PALADIN=1, HUNTER=1, ROGUE=1, PRIEST=1, DEATHKNIGHT=1,
        SHAMAN=1, MAGE=1, WARLOCK=1, MONK=1, DRUID=1, DEMONHUNTER=1, EVOKER=1,
    }

    -- WoW 12 returns secret-tainted strings from C_DamageMeter for some
    -- party members during M+ (mainly Aug Evokers / soulbound names while
    -- in restricted execution). Indexing `data[source.name]` with a secret
    -- string raises "attempted to index a table that cannot be indexed
    -- with secret keys" — which used to abort the whole completion handler
    -- and silently drop the run. Per-source pcall keeps the harvest alive.
    local issec = _G.issecretvalue or function() return false end
    for _, mt in ipairs(meterTypes) do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, mt.enum)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    local isPlayerSource = source.classFilename and VALID_CLASSES[source.classFilename]
                    if isPlayerSource and source.name then
                        if not data[source.name] then
                            data[source.name] = {
                                name = source.name,
                                class = source.classFilename,
                                specIcon = source.specIconID or 0,
                                isLocal = source.isLocalPlayer or false,
                            }
                        end
                        data[source.name][mt.field] = source.totalAmount or 0
                    end
                end)
            end
        end
    end

    local ok, dpsSession = pcall(C_DamageMeter.GetCombatSessionFromType,
        Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.Dps)
    if ok and dpsSession then
        for name, d in pairs(data) do
            d._durationSec = dpsSession.durationSeconds or 0
        end
    end

    return data
end


local VALID_CLASSES_DETAIL = {
    WARRIOR=1, PALADIN=1, HUNTER=1, ROGUE=1, PRIEST=1, DEATHKNIGHT=1,
    SHAMAN=1, MAGE=1, WARLOCK=1, MONK=1, DRUID=1, DEMONHUNTER=1, EVOKER=1,
}

local function GetSpellsForSource(meterType, sourceGUID)
    if not C_DamageMeter.GetCombatSessionSourceFromType then return nil end
    local ok, sourceData = pcall(C_DamageMeter.GetCombatSessionSourceFromType,
        Enum.DamageMeterSessionType.Overall, meterType, sourceGUID)
    if not ok or not sourceData or not sourceData.combatSpells then return nil end
    return sourceData.combatSpells
end

local function BuildSourceGUIDLookup()
    local lookup = {}
    if not C_DamageMeter or not C_DamageMeter.GetCombatSessionFromType then return lookup end

    local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
        Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.DamageDone)
    if ok and session and session.combatSources then
        for _, source in ipairs(session.combatSources) do
            if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                lookup[source.name] = source.sourceGUID
            end
        end
    end
    return lookup
end

local function HarvestDetailedCombatData()
    local detail = {
        damageTakenBySpell = {},
        damageDoneBySpell  = {},
        healingDoneBySpell = {},
        deathRecaps        = {},
        interruptLog       = {},
        dispelLog          = {},
    }

    if not C_DamageMeter then return detail end

    -- Same secret-value taint defense as HarvestDamageMeterData and
    -- HarvestEncounterData: pcall + skip per source. Without this,
    -- a single tainted name aborts the whole detail harvest, leaves
    -- run_detail with only `affixes`+`encounters`, and the run sheet
    -- ships missing all spell breakdowns and death recaps.
    local issec = _G.issecretvalue or function() return false end

    local guidLookup = BuildSourceGUIDLookup()

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.DamageTaken)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                        local spells = GetSpellsForSource(Enum.DamageMeterType.DamageTaken, source.sourceGUID)
                        if spells then
                            local breakdown = {}
                            for _, sp in ipairs(spells) do
                                breakdown[#breakdown + 1] = {
                                    spellID     = sp.spellID or 0,
                                    spellName   = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total       = sp.totalAmount or 0,
                                    isAvoidable = sp.isAvoidable or false,
                                    isDeadly    = sp.isDeadly or false,
                                    sourceName  = sp.creatureName or "Unknown",
                                }
                            end
                            table.sort(breakdown, function(a, b) return a.total > b.total end)
                            detail.damageTakenBySpell[source.name] = breakdown
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.DamageDone)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                        local spells = GetSpellsForSource(Enum.DamageMeterType.DamageDone, source.sourceGUID)
                        if spells then
                            local breakdown = {}
                            for _, sp in ipairs(spells) do
                                breakdown[#breakdown + 1] = {
                                    spellID   = sp.spellID or 0,
                                    spellName = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total     = sp.totalAmount or 0,
                                }
                            end
                            table.sort(breakdown, function(a, b) return a.total > b.total end)
                            while #breakdown > 15 do table.remove(breakdown) end
                            detail.damageDoneBySpell[source.name] = breakdown
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.HealingDone)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                        local spells = GetSpellsForSource(Enum.DamageMeterType.HealingDone, source.sourceGUID)
                        if spells then
                            local breakdown = {}
                            for _, sp in ipairs(spells) do
                                breakdown[#breakdown + 1] = {
                                    spellID   = sp.spellID or 0,
                                    spellName = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total     = sp.totalAmount or 0,
                                }
                            end
                            table.sort(breakdown, function(a, b) return a.total > b.total end)
                            while #breakdown > 15 do table.remove(breakdown) end
                            detail.healingDoneBySpell[source.name] = breakdown
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.Interrupts)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                        local spells = GetSpellsForSource(Enum.DamageMeterType.Interrupts, source.sourceGUID)
                        if spells then
                            for _, sp in ipairs(spells) do
                                detail.interruptLog[#detail.interruptLog + 1] = {
                                    playerName = source.name,
                                    spellID    = sp.spellID or 0,
                                    spellName  = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total      = sp.totalAmount or 0,
                                    target     = sp.creatureName or "",
                                }
                            end
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.Dispels)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename] and source.name then
                        local spells = GetSpellsForSource(Enum.DamageMeterType.Dispels, source.sourceGUID)
                        if spells then
                            for _, sp in ipairs(spells) do
                                detail.dispelLog[#detail.dispelLog + 1] = {
                                    playerName = source.name,
                                    spellID    = sp.spellID or 0,
                                    spellName  = (C_Spell and C_Spell.GetSpellInfo and (C_Spell.GetSpellInfo(sp.spellID) or {}).name) or (GetSpellInfo and select(1, GetSpellInfo(sp.spellID))) or ("Spell " .. (sp.spellID or 0)),
                                    total      = sp.totalAmount or 0,
                                }
                            end
                        end
                    end
                end)
            end
        end
    end

    do
        local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
            Enum.DamageMeterSessionType.Overall, Enum.DamageMeterType.Deaths)
        if ok and session and session.combatSources then
            for _, source in ipairs(session.combatSources) do
                pcall(function()
                    if issec(source.name) or issec(source.classFilename) then return end
                    if source.classFilename and VALID_CLASSES_DETAIL[source.classFilename]
                       and source.name and source.deathRecapID then
                        local recap = { playerName = source.name, events = {}, maxHealth = 0 }

                        if C_DeathRecap and C_DeathRecap.HasRecapEvents then
                            local hasRecap = SafeCall(C_DeathRecap.HasRecapEvents, source.deathRecapID)
                            if hasRecap then
                                local events = SafeCall(C_DeathRecap.GetRecapEvents, source.deathRecapID)
                                local mhp = SafeCall(C_DeathRecap.GetRecapMaxHealth, source.deathRecapID)
                                recap.maxHealth = mhp or 0

                                if events then
                                    for _, ev in ipairs(events) do
                                        local sid = ev.spellId or ev.spellID or 0
                                        local sname = ev.spellName
                                        if not sname or sname == "" then
                                            if sid > 0 then
                                                if C_Spell and C_Spell.GetSpellInfo then
                                                    local info = C_Spell.GetSpellInfo(sid)
                                                    sname = info and info.name or nil
                                                elseif GetSpellInfo then
                                                    sname = select(1, GetSpellInfo(sid))
                                                end
                                            end
                                        end
                                        if not sname or sname == "" then
                                            -- No spell ID typically means a melee swing (physical) or
                                            -- an untagged environmental hit. Label it so players aren't
                                            -- left wondering what "Unknown" means.
                                            if (ev.school or 0) == 1 then
                                                sname = "Melee"
                                            else
                                                sname = "Environmental"
                                            end
                                        end
                                        recap.events[#recap.events + 1] = {
                                            source    = ev.sourceName or ev.source or "Unknown",
                                            spellID   = sid,
                                            spellName = sname,
                                            amount    = ev.amount or ev.damage or 0,
                                            timestamp = ev.timestamp or 0,
                                            overkill  = ev.overkill or 0,
                                            school    = ev.school or 0,
                                        }
                                    end
                                end
                            end
                        end

                        if #recap.events > 0 then
                            detail.deathRecaps[#detail.deathRecaps + 1] = recap
                        end
                    end
                end)
            end
        end
    end

    return detail
end

local function OnChallengeModeCompleted()
    MPI.DebugLog("=== CHALLENGE_MODE_COMPLETED handler fired (after 2s delay) ===")
    -- Stop live combat tracking regardless; the data we have is the data we have.
    pcall(StopLiveCombatTracking)
    if not MPI._activeRun then
        MPI.DebugLog("ABORT: no active run record (was OnChallengeModeStart skipped?)")
        return
    end
    if not db then
        MPI.DebugLog("ABORT: SavedVar db not initialised")
        return
    end

    local completionInfo
    if C_ChallengeMode.GetChallengeCompletionInfo then
        local ok, info = pcall(C_ChallengeMode.GetChallengeCompletionInfo)
        if ok then completionInfo = info end
        if not ok then MPI.DebugLog("WARN: GetChallengeCompletionInfo threw: " .. tostring(info)) end
    end

    local result = "depleted"
    local stars = 0
    local durationSec = 0
    if completionInfo then
        if completionInfo.onTime then
            result = "timed"
            stars = completionInfo.keystoneUpgradeLevels or 1
        end
        durationSec = (completionInfo.time or 0) / 1000
    else
        MPI.DebugLog("WARN: completionInfo is nil — result will default to 'depleted', duration 0")
    end
    MPI.DebugLog(string.format("completion summary result=%s stars=%d duration=%ds (limit=%ds)",
        result, stars, floor(durationSec), floor(MPI._activeRun.timeLimitSec or 0)))

    local hasLeavers = false
    if not MPI._surrendered then
        for _ in pairs(MPI._leavers) do hasLeavers = true; break end
    end

    -- Both harvest paths touch C_DamageMeter, which can return secret-tainted
    -- values in WoW 12. A throw here used to abort the completion handler
    -- before the run was stored — wrap them so the run survives even if a
    -- future API field becomes tainted in a way the inner guards miss.
    local meterData
    local okMeter, errMeter = pcall(function() meterData = HarvestDamageMeterData() end)
    if not okMeter then
        MPI.DebugLog("WARN: HarvestDamageMeterData threw: " .. tostring(errMeter))
        meterData = {}
    end
    local meterCount = 0
    if meterData then for _ in pairs(meterData) do meterCount = meterCount + 1 end end
    MPI.DebugLog(string.format("damage-meter harvest: %d player entries", meterCount))

    local detailedData
    local okDetail, errDetail = pcall(function() detailedData = HarvestDetailedCombatData() end)
    if not okDetail then
        MPI.DebugLog("WARN: HarvestDetailedCombatData threw: " .. tostring(errDetail))
        detailedData = nil
    end
    local detailDeathRecaps = (detailedData and detailedData.deathRecaps) and #detailedData.deathRecaps or 0
    MPI.DebugLog(string.format("detailed combat harvest: deathRecaps=%d", detailDeathRecaps))

    local deathRecapCountByName = {}
    if detailedData and detailedData.deathRecaps then
        for _, recap in ipairs(detailedData.deathRecaps) do
            local rn = recap and recap.playerName
            if rn then
                deathRecapCountByName[rn] = (deathRecapCountByName[rn] or 0) + 1
                local shortRn = rn:match("^([^%-]+)")
                if shortRn and shortRn ~= rn then
                    deathRecapCountByName[shortRn] = (deathRecapCountByName[shortRn] or 0) + 1
                end
            end
        end
    end

    local players = {}
    local allDpsAmounts = {}
    local groupTotalDamage = 0
    local groupTotalDamageTaken = 0
    local groupTotalHealingDone = 0
    local groupTotalInterrupts = 0

    local myGUID = UnitGUID("player")
    for guid, info in pairs(MPI._rosterSnapshot) do
        local md = meterData[info.name]
        if not md then
            local shortName = info.name and info.name:match("^([^%-]+)")
            if shortName then md = meterData[shortName] end
        end
        md = md or {}

        local meterDeaths = md.deaths or 0
        local recapDeaths = deathRecapCountByName[info.name] or 0
        if recapDeaths == 0 and info.name then
            local shortName = info.name:match("^([^%-]+)")
            if shortName then recapDeaths = deathRecapCountByName[shortName] or 0 end
        end
        local finalDeaths = max(meterDeaths, recapDeaths)

        local p = {
            name            = info.name or md.name or "Unknown",
            class           = info.class or md.class or "",
            role            = info.role or "DAMAGER",
            specIcon        = md.specIcon or info.specIcon or 0,
            damageDone      = md.damageDone or 0,
            damageTaken     = md.damageTaken or 0,
            avoidableDamage = md.avoidableDamage or 0,
            healingDone     = md.healingDone or 0,
            dispels         = md.dispels or 0,
            interrupts      = md.interrupts or 0,
            deaths          = finalDeaths,
        }
        players[guid] = p
        groupTotalDamage = groupTotalDamage + p.damageDone
        groupTotalDamageTaken = groupTotalDamageTaken + p.damageTaken
        groupTotalHealingDone = groupTotalHealingDone + p.healingDone
        groupTotalInterrupts = groupTotalInterrupts + p.interrupts

        if p.role == "DAMAGER" then
            allDpsAmounts[#allDpsAmounts + 1] = p.damageDone
        end
    end

    local effectiveDuration = max(1, durationSec)
    local groupDTPS = groupTotalDamageTaken / effectiveDuration

    local groupDeathTotal = 0
    for _, pl in pairs(players) do groupDeathTotal = groupDeathTotal + (pl.deaths or 0) end
    local encounterWipeCount = 0
    for _, enc in ipairs(MPI._encounterLog) do
        if enc.success == false then encounterWipeCount = encounterWipeCount + 1 end
    end

    local avoidableSpellLookup = {}
    if detailedData and detailedData.damageTakenBySpell then
        for _, spells in pairs(detailedData.damageTakenBySpell) do
            for _, sp in ipairs(spells) do
                if sp.isAvoidable and sp.spellID then
                    avoidableSpellLookup[sp.spellID] = true
                end
            end
        end
    end

    local tankName = nil
    local tankDeaths = 0
    for _, pl in pairs(players) do
        if pl.role == "TANK" then
            tankName = pl.name
            tankDeaths = pl.deaths or 0
            break
        end
    end

    pcall(function()
        MPI.DebugLog("--- score inputs audit ---")
        for guid, info in pairs(MPI._rosterSnapshot) do
            local p = players[guid]
            local flag = (p and p.damageDone > 0) and "ok" or "MISSING"
            MPI.DebugLog(string.format("  %s class=%s role=%s dmg=%d taken=%d heal=%d int=%d dsp=%d deaths=%d meter=%s",
                tostring(info.name), tostring(info.class), tostring(info.role),
                p and p.damageDone or 0, p and p.damageTaken or 0, p and p.healingDone or 0,
                p and p.interrupts or 0, p and p.dispels or 0, p and p.deaths or 0, flag))
        end
        local avoidCount = 0
        for _ in pairs(avoidableSpellLookup) do avoidCount = avoidCount + 1 end
        MPI.DebugLog(string.format("group totals dmg=%d taken=%d heal=%d int=%d deaths=%d wipes=%d avoidSpells=%d",
            groupTotalDamage, groupTotalDamageTaken, groupTotalHealingDone,
            groupTotalInterrupts, groupDeathTotal, encounterWipeCount, avoidCount))
        MPI.DebugLog(string.format("encounter log: %d entries", #MPI._encounterLog))
        for i, enc in ipairs(MPI._encounterLog) do
            local dur = (enc.endTime and enc.startTime) and floor(enc.endTime - enc.startTime) or 0
            MPI.DebugLog(string.format("  enc[%d] id=%s name=%s success=%s duration=%ds",
                i, tostring(enc.id), tostring(enc.name), tostring(enc.success), dur))
        end
    end)

    -- Tank death timestamps for chain-blame detection.
    local tankDeathTimestamps = {}
    if detailedData and detailedData.deathRecaps then
        for _, recap in ipairs(detailedData.deathRecaps) do
            for _, pl in pairs(players) do
                if pl.role == "TANK" and recap.playerName == pl.name then
                    local lastTs = recap.timestamp or 0
                    for _, ev in ipairs(recap.events or {}) do
                        if (ev.timestamp or 0) > lastTs then lastTs = ev.timestamp end
                    end
                    tankDeathTimestamps[#tankDeathTimestamps + 1] = lastTs
                    break
                end
            end
        end
    end

    -- Encounter duration sum (for tank pull efficiency).
    local encounterDurationSum = 0
    for _, enc in ipairs(MPI._encounterLog) do
        if enc.startTime and enc.endTime then
            encounterDurationSum = encounterDurationSum + (enc.endTime - enc.startTime)
        end
    end

    -- Local player ilvl and gear tier for Proven Level / gear modifier.
    local localIlvl = 0
    if GetAverageItemLevel then
        local _, equipped = GetAverageItemLevel()
        localIlvl = floor(equipped or 0)
    end
    local localGearTier = MPI.GetGearTier(localIlvl)

    -- Live combat telemetry (captured by COMBAT_LOG_EVENT_UNFILTERED during the run).
    local localMitigationPct = ComputeLocalMitigationUptime(effectiveDuration)
    -- Pre-compute defensives-available-by-death per player (uses captured cast log).
    for _, p in pairs(players) do
        local playerRole = p.role
        if playerRole == "DAMAGER" then playerRole = "DPS" end
        local recapsForPlayer = {}
        if detailedData and detailedData.deathRecaps then
            for _, recap in ipairs(detailedData.deathRecaps) do
                if recap.playerName == p.name then
                    recapsForPlayer[#recapsForPlayer + 1] = recap
                end
            end
        end
        if #recapsForPlayer > 0 then
            p._defensivesAvailableByDeath = ComputeDefensivesAvailableByDeath(
                p.name, p.class, playerRole, recapsForPlayer)
        end
    end

    -- Local player gets mitigation uptime if they're a tank.
    -- Also: kick availability ratio, absorbs applied, threat retention for tank.
    do
        local myName = UnitName("player")
        local mySpec
        if GetSpecialization and GetSpecializationInfoForClassID then
            -- Best-effort; spec names keyed off spec index, mapped via table.
            local specIdx = GetSpecialization()
            if specIdx then
                local _, specName = GetSpecializationInfo(specIdx)
                if specName then mySpec = string.upper(specName) end
            end
        end

        for _, p in pairs(players) do
            if p.name == myName then
                if p.role == "TANK" then
                    p._mitigationUptimePct = localMitigationPct
                    -- Tank physical damage share for threat retention.
                    local tPhys, gPhys = ComputePhysicalDamageShares(myName)
                    if tPhys and gPhys then
                        p._tankPhysicalDamageTaken = tPhys
                        p._groupPhysicalDamageTaken = gPhys
                    end
                end
                if p.role == "HEALER" then
                    p._absorbsApplied = ComputeAbsorbsApplied(myName)
                end
                -- Kick availability for any role.
                local _, classFile = UnitClass("player")
                p._kickAvailabilityRatio = ComputeKickAvailabilityRatio(myName, classFile, mySpec)
            end
        end
    end

    -- Local player's Proven Level from their run history (timed keys only).
    local localProvenLevel = 0
    if db and db.runs then
        localProvenLevel = MPI.ComputeProvenLevel(db.runs)
    end

    -- Season start (best effort via Blizzard API; falls back to 90-day rolling).
    local seasonStartTs
    if C_MythicPlus and C_MythicPlus.GetCurrentSeason then
        local ok, seasonID = pcall(C_MythicPlus.GetCurrentSeason)
        if ok and seasonID and seasonID > 0 and C_MythicPlus.GetSeasonInfo then
            local ok2, info = pcall(C_MythicPlus.GetSeasonInfo, seasonID)
            if ok2 and info and info.startDate then seasonStartTs = info.startDate end
        end
    end
    if not seasonStartTs then seasonStartTs = time() - (90 * 86400) end

    local currentMapID = MPI._activeRun and MPI._activeRun.mapID or 0
    local currentKeyLevel = MPI._activeRun and MPI._activeRun.keyLevel or 0

    for guid, p in pairs(players) do
        local existingProfile = db.profiles[guid]
        local existingScores = existingProfile and existingProfile.recentScores or {}

        local role = p.role
        if role == "DAMAGER" then role = "DPS" end

        -- Cohort lookup: find this player's class/role/dungeon/bracket cohort if
        -- community data has reached sample threshold. nil = fall back to thresholds.
        local playerCohort = MPI.GetCohort(p.class, role, currentMapID, currentKeyLevel)

        local awarenessScore = MPI.ScoreAwareness(p.avoidableDamage, effectiveDuration, role, playerCohort)

        local playerDeathRecaps = {}
        if detailedData and detailedData.deathRecaps then
            for _, recap in ipairs(detailedData.deathRecaps) do
                if recap.playerName == p.name then
                    playerDeathRecaps[#playerDeathRecaps + 1] = recap
                end
            end
        end

        local survivalScore, deathDetails = MPI.ScoreSurvival({
            deaths          = p.deaths,
            role            = role,
            deathRecaps     = playerDeathRecaps,
            avoidableSpells = avoidableSpellLookup,
            encounterLog    = MPI._encounterLog,
            tankDeathTimes  = tankDeathTimestamps,
            defensivesAvailable = p._defensivesAvailableByDeath,
        })
        p._deathDetails = deathDetails

        local consistencyScore, consistencyBreakdown = MPI.ScoreConsistency({
            result         = result,
            stars          = stars,
            encounterWipes = encounterWipeCount,
            didLeaveEarly  = (MPI._leavers and MPI._leavers[guid]) and true or false,
            surrendered    = MPI._surrendered,
        })

        local outputScore
        local weightRole

        if role == "TANK" then
            outputScore = MPI.ScoreTankOutput({
                tankDamage               = p.damageDone,
                groupTotalDamage         = groupTotalDamage,
                tankPhysicalDamageTaken  = p._tankPhysicalDamageTaken,
                groupPhysicalDamageTaken = p._groupPhysicalDamageTaken,
                cohort                   = playerCohort,
            })
            weightRole = "TANK"
        elseif role == "HEALER" then
            outputScore = MPI.ScoreHealerOutput({
                healingDone      = p.healingDone,
                absorbsApplied   = p._absorbsApplied,
                groupDamageTaken = groupTotalDamageTaken,
                damageDone       = p.damageDone,
                groupTotalDamage = groupTotalDamage,
                cohort           = playerCohort,
            })
            weightRole = "HEALER"
        else
            local isAug = false
            if p.class == "EVOKER" then
                if AUG_EVOKER_ICONS[p.specIcon or 0] then
                    isAug = true
                end
                if guid == myGUID then
                    local specIdx = GetSpecialization and GetSpecialization()
                    if specIdx and GetSpecializationInfo then
                        local specID = GetSpecializationInfo(specIdx)
                        if specID == AUG_EVOKER_SPEC_ID then isAug = true end
                    end
                end
            end

            if isAug then
                outputScore = MPI.ScoreAugOutput({
                    augDamage        = p.damageDone,
                    groupTotalDamage = groupTotalDamage,
                })
                weightRole = "AUG"
            else
                outputScore = MPI.ScoreDpsOutput({
                    playerDamage   = p.damageDone,
                    allDpsAmounts  = allDpsAmounts,
                    cohort         = playerCohort,
                })
                weightRole = "DPS"
            end
        end

        local utilityScore = MPI.ScoreUtility({
            interrupts             = p.interrupts,
            dispels                = p.dispels,
            groupTotalInterrupts   = groupTotalInterrupts,
            groupTotalDispels      = p._groupTotalDispels,
            groupDispelsByType     = p._groupDispelsByType,    -- nil until phase 2
            playerClass            = p.class,
            playerSpec             = p.spec,
            kickAvailabilityRatio  = p._kickAvailabilityRatio, -- nil until CLEU tracking
        })

        -- Only the local player has a known Proven Level / gear tier.
        local isLocal = (guid == myGUID)
        local mpiScore = MPI.ComputeRunScore({
            role         = weightRole,
            awareness    = awarenessScore,
            survival     = survivalScore,
            output       = outputScore,
            utility      = utilityScore,
            consistency  = consistencyScore,
            keyLevel     = MPI._activeRun and MPI._activeRun.keyLevel or 0,
            stars        = stars,
            result       = result,
            durationSec  = MPI._activeRun and MPI._activeRun.durationSec or 0,
            timeLimitSec = MPI._activeRun and MPI._activeRun.timeLimitSec or 0,
            provenLevel  = isLocal and localProvenLevel or nil,
            gearTier     = isLocal and localGearTier or nil,
        })

        p.mpiScore             = mpiScore
        p.awarenessScore       = awarenessScore
        p.survivalScore        = survivalScore
        p.outputScore          = outputScore
        p.utilityScore         = utilityScore
        p.consistencyScore     = consistencyScore
        p.consistencyBreakdown = consistencyBreakdown
        p.weightRole           = weightRole
        if isLocal then
            p.ilvl           = localIlvl
            p.gearTierName   = localGearTier and localGearTier.name or nil
            p.provenLevel    = localProvenLevel
        end

        pcall(MPI.DebugLog, string.format("  score %s role=%s aware=%s surv=%s out=%s util=%s cons=%s -> MPI=%s",
            tostring(p.name), tostring(weightRole),
            tostring(awarenessScore), tostring(survivalScore), tostring(outputScore),
            tostring(utilityScore), tostring(consistencyScore), tostring(mpiScore)))
    end

    -- Badge determination runs AFTER all players have scores (MVP needs them).
    local newlyEarnedByLocal = {}
    for guid, p in pairs(players) do
        p.badges = MPI.DetermineRunBadges({
            playerData             = p,
            runResult              = result,
            stars                  = stars,
            keyLevel               = MPI._activeRun and MPI._activeRun.keyLevel or 0,
            provenLevel            = (guid == myGUID) and localProvenLevel or 0,
            runDurationSec         = effectiveDuration,
            groupPlayers           = players,
            encounterWipes         = encounterWipeCount,
            groupTotalInterrupts   = groupTotalInterrupts,
            groupTotalDispels      = nil,
            encounterDispelCounts  = nil,
            mitigationUptimePct    = p._mitigationUptimePct,
            kickAvailabilityRatio  = p._kickAvailabilityRatio,
        })

        -- Track local player's newly-earned badges (not seen in prior runs).
        if guid == myGUID and p.badges and #p.badges > 0 then
            local previouslyEarned = {}
            if db.personal and db.personal.everEarnedBadges then
                for _, prevBid in ipairs(db.personal.everEarnedBadges) do
                    previouslyEarned[prevBid] = true
                end
            end
            for _, bid in ipairs(p.badges) do
                if not previouslyEarned[bid] then
                    newlyEarnedByLocal[#newlyEarnedByLocal + 1] = bid
                end
            end
        end
    end

    -- Persist ever-earned badges and fire a celebration popup for each new one.
    if #newlyEarnedByLocal > 0 then
        if not db.personal.everEarnedBadges then db.personal.everEarnedBadges = {} end
        local seen = {}
        for _, bid in ipairs(db.personal.everEarnedBadges) do seen[bid] = true end
        for _, bid in ipairs(newlyEarnedByLocal) do
            if not seen[bid] then
                db.personal.everEarnedBadges[#db.personal.everEarnedBadges + 1] = bid
                seen[bid] = true
            end
        end
        if MPI.ShowBadgeEarnedToast then
            for i, bid in ipairs(newlyEarnedByLocal) do
                C_Timer.After((i - 1) * 0.8, function()
                    MPI.ShowBadgeEarnedToast(bid)
                end)
            end
        end
    end

    local encounters = {}
    local runGetTimeStart = MPI._activeRun.startGetTime or 0
    for _, enc in ipairs(MPI._encounterLog) do
        local relStart = (enc.startTime and runGetTimeStart > 0) and (enc.startTime - runGetTimeStart) or 0

        local bossPlayers = nil
        local bossSpells = nil
        if enc.stats then
            if enc.stats.players then
                bossPlayers = {}
                for name, pData in pairs(enc.stats.players) do
                    bossPlayers[#bossPlayers + 1] = {
                        name            = name,
                        class           = pData.class or "",
                        isLocal         = pData.isLocal or false,
                        damageDone      = pData.damageDone or 0,
                        damageTaken     = pData.damageTaken or 0,
                        avoidableDamage = pData.avoidableDamage or 0,
                        healingDone     = pData.healingDone or 0,
                        interrupts      = pData.interrupts or 0,
                        dispels         = pData.dispels or 0,
                        deaths          = pData.deaths or 0,
                    }
                end
            end
            bossSpells = enc.stats.topSpells
        end

        encounters[#encounters + 1] = {
            id        = enc.id,
            name      = enc.name,
            startTime = max(0, floor(relStart)),
            duration  = (enc.endTime and enc.startTime) and floor(enc.endTime - enc.startTime) or 0,
            success   = enc.success or false,
            players   = bossPlayers,
            spells    = bossSpells,
        }
    end

    -- ownerGUID: locks the run to the character who actually completed it so
    -- later cross-character exports can't zero out the "me" fields when
    -- UnitGUID("player") differs from the runner (account-wide SavedVar).
    local ownerGUID = UnitGUID("player")
    local ownerName = UnitName("player")
    local _, ownerRealm = UnitFullName and UnitFullName("player")
    local ownerFullName = (ownerName and ownerRealm and ownerRealm ~= "")
        and (ownerName .. "-" .. ownerRealm) or ownerName

    -- Capture the WoW patch string at run-completion time. The Companion
    -- forwards this to upload_runs.php, which derives MAJOR.MINOR as the
    -- season key. Without this, runs default to the current season on the
    -- server — fine until 12.1 ships, when accurate stamping matters.
    local wowPatch = nil
    if GetBuildInfo then
        local ok, ver = pcall(GetBuildInfo)
        if ok and type(ver) == "string" and ver ~= "" then wowPatch = ver end
    end

    local runRecord = {
        timestamp    = time(),
        dungeonMapID = MPI._activeRun.mapID,
        dungeonName  = MPI._activeRun.dungeonName,
        keyLevel     = MPI._activeRun.keyLevel,
        affixes      = MPI._activeRun.affixes or {},
        result       = result,
        stars        = stars,
        durationSec  = durationSec,
        timeLimitSec = MPI._activeRun.timeLimitSec,
        wowPatch     = wowPatch,
        players      = players,
        leavers      = hasLeavers and MPI._leavers or nil,
        encounters   = encounters,
        detail       = detailedData,
        ownerGUID    = ownerGUID,
        ownerName    = ownerFullName,
    }

    if detailedData then
        local deathBlame = {}
        for guid, p in pairs(players) do
            if p._deathDetails and #p._deathDetails > 0 then
                deathBlame[p.name] = p._deathDetails
            end
            p._deathDetails = nil
        end
        if next(deathBlame) then
            detailedData.deathBlame = deathBlame
        end
    end

    local playerCount = 0
    local encCount = #encounters
    for _ in pairs(players) do playerCount = playerCount + 1 end
    MPI.DebugLog(string.format("scoring complete: %d players, %d encounters, group DPS=%d, group DTPS=%d",
        playerCount, encCount, floor(groupTotalDamage / effectiveDuration), floor(groupDTPS)))

    for guid, p in pairs(players) do
        MPI.DebugLog(string.format("  score name=%s role=%s mpi=%d aware=%d surv=%d out=%d util=%d cons=%d dmg=%d dt=%d heal=%d deaths=%d",
            tostring(p.name), tostring(p.weightRole or p.role),
            p.mpiScore or 0, p.awarenessScore or 0, p.survivalScore or 0,
            p.outputScore or 0, p.utilityScore or 0, p.consistencyScore or 0,
            p.damageDone or 0, p.damageTaken or 0, p.healingDone or 0, p.deaths or 0))
    end

    table.insert(db.runs, 1, runRecord)
    while #db.runs > db.settings.maxStoredRuns do
        table.remove(db.runs)
    end
    MPI.DebugLog(string.format("run stored in SavedVar: db.runs now holds %d run(s) (max=%d)",
        #db.runs, db.settings.maxStoredRuns or 0))

    MPI.UpdateProfiles(runRecord)
    local profileCount = 0
    for _ in pairs(db.profiles or {}) do profileCount = profileCount + 1 end
    MPI.DebugLog(string.format("UpdateProfiles done: %d local profiles tracked", profileCount))

    MPI.UpdatePersonalStats()
    MPI.DebugLog("UpdatePersonalStats done")

    MPI.RebuildNameIndex()
    MPI.DebugLog("RebuildNameIndex done")

    MPI.BuildExportData()
    MPI.DebugLog("=== RUN WRITTEN TO SavedVar — /reload now to flush for MPI Companion → mpi.atyzi.com ===")

    pcall(MPI.ShowRunCompletePopup, runRecord, myGUID)

    MPI._activeRun = nil
    MPI._encounterLog = {}
    MPI._leavers = {}
    MPI._rosterSnapshot = {}
    MPI._surrendered = false
    ClearPersistedActiveRun()
    -- /combatlog stays on through key completion. Players are usually
    -- still inside the dungeon for ~30s afterwards (loot, screenshot,
    -- second key insert) — disabling here would lose the next key's
    -- start line. PLAYER_ENTERING_WORLD turns logging off when they
    -- actually leave the instance.
end

local function GetMpiTierColor(score)
    score = tonumber(score) or 0
    if score >= 90 then return 0.45, 0.85, 1.00 end
    if score >= 80 then return 0.55, 0.95, 0.55 end
    if score >= 70 then return 0.95, 0.85, 0.45 end
    if score >= 55 then return 1.00, 0.65, 0.35 end
    return 1.00, 0.45, 0.45
end

local RUN_POPUP_DIMS = {
    { field = "awarenessScore",   label = "AWARENESS"   },
    { field = "survivalScore",    label = "SURVIVAL"    },
    { field = "outputScore",      label = "OUTPUT"      },
    { field = "utilityScore",     label = "UTILITY"     },
    { field = "consistencyScore", label = "CONSISTENCY" },
}

-- UTF-8 glyphs built via string.char so Lua 5.1's inconsistent hex-escape
-- behaviour can't strip the bytes at parse time (we saw "xE2x98x85" leak
-- through literally before this).
local POPUP_STAR   = string.char(0xE2, 0x98, 0x85)   -- ★
local POPUP_EMDASH = string.char(0xE2, 0x80, 0x94)   -- —
local POPUP_BULLET = string.char(0xE2, 0x80, 0xA2)   -- •

-- Popup typography — explicit font choices. FRIZQT (default) reads as
-- cheap/generic; MORPHEUS is Blizzard's serif display face used for quest
-- titles and carries gravitas, ARIALN is the dedicated number font (tall,
-- narrow, reads well at all sizes).
local POPUP_DISPLAY_FONT = "Fonts\\MORPHEUS.TTF"   -- dungeon name
local POPUP_NUMBER_FONT  = "Fonts\\ARIALN.TTF"     -- scores, tier letter, dim values
local POPUP_LABEL_FONT   = "Fonts\\ARIALN.TTF"     -- captions (MYTHIC+ COMPLETED, etc.)

-- Build a MidnightUI-style button: dark backdrop, tier-colored border,
-- subtle hover shift. Replaces UIPanelButtonTemplate (the "cheap" look).
local function MakePopupButton(parent, text, w, h, primary)
    local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
    btn:SetSize(w, h)
    btn:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    local label = btn:CreateFontString(nil, "OVERLAY")
    TrySetFont(label, POPUP_LABEL_FONT, 12, "OUTLINE")
    label:SetPoint("CENTER", 0, 0)
    label:SetText(text)
    btn._label = label
    btn._primary = primary and true or false

    local function applyStyle(self, hovered)
        local ar, ag, ab = self._r or 0.45, self._g or 0.85, self._b or 1.0
        if self._primary then
            local bgAlpha = hovered and 0.95 or 0.80
            self:SetBackdropColor(ar * 0.20, ag * 0.20, ab * 0.20, bgAlpha)
            self:SetBackdropBorderColor(ar, ag, ab, hovered and 1.0 or 0.85)
            self._label:SetTextColor(1, 1, 1)
        else
            self:SetBackdropColor(0.06, 0.07, 0.10, hovered and 0.95 or 0.80)
            self:SetBackdropBorderColor(0.28, 0.30, 0.38, hovered and 0.90 or 0.55)
            self._label:SetTextColor(hovered and 0.90 or 0.70, hovered and 0.90 or 0.70, hovered and 0.95 or 0.75)
        end
    end
    btn._applyStyle = applyStyle

    btn:SetScript("OnEnter", function(self) applyStyle(self, true) end)
    btn:SetScript("OnLeave", function(self) applyStyle(self, false) end)
    applyStyle(btn, false)

    -- Override SetText so existing code paths that call btn:SetText("...")
    -- route to our custom label FontString instead of the unused Blizzard
    -- built-in button text. We normalise to uppercase for consistency.
    btn.SetText = function(self, text)
        self._label:SetText(string.upper(tostring(text or "")))
    end

    return btn
end

function MPI.ShowRunCompletePopup(runRecord, localGUID)
    if not runRecord then return end

    local me = runRecord.players and runRecord.players[localGUID]
    if not me then
        for _, p in pairs(runRecord.players or {}) do
            if p.name == UnitName("player") then me = p; break end
        end
    end

    local frame = MPI._runPopup
    if not frame then
        -- ── Container ────────────────────────────────────────────────────
        -- Wider + taller. Everything below anchors to "TOP" with centered
        -- offsets so the vertical flow reads like one column.
        frame = CreateFrame("Frame", "MidnightUIMPIRunPopup", UIParent, "BackdropTemplate")
        frame:SetSize(640, 620)
        frame:SetPoint("TOP", UIParent, "TOP", 0, -60)
        frame:SetFrameStrata("HIGH")
        frame:SetClampedToScreen(true)
        frame:SetMovable(true)
        frame:EnableMouse(true)
        frame:RegisterForDrag("LeftButton")
        frame:SetScript("OnDragStart", frame.StartMoving)
        frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
        frame:SetBackdrop({
            bgFile = W8, edgeFile = W8, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame:SetBackdropColor(0.04, 0.04, 0.07, 0.96)

        -- Single thin tier-colored accent bar. No glow — previously dominated
        -- the whole top half of the popup with pink/red tint on a D-tier run.
        local accent = frame:CreateTexture(nil, "OVERLAY")
        accent:SetPoint("TOPLEFT", 1, -1)
        accent:SetPoint("TOPRIGHT", -1, -1)
        accent:SetHeight(2)
        frame.accent = accent

        -- ── Header row ───────────────────────────────────────────────────
        local headerTitle = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(headerTitle, POPUP_LABEL_FONT, 11, "OUTLINE")
        headerTitle:SetPoint("TOP", 0, -22)
        headerTitle:SetText("MYTHIC+ COMPLETED")
        headerTitle:SetTextColor(0.55, 0.60, 0.72)
        frame.headerTitle = headerTitle

        local close = CreateFrame("Button", nil, frame)
        close:SetSize(28, 28)
        close:SetPoint("TOPRIGHT", -14, -14)
        local closeText = close:CreateFontString(nil, "OVERLAY")
        TrySetFont(closeText, POPUP_LABEL_FONT, 22, "OUTLINE")
        closeText:SetAllPoints(true)
        closeText:SetText("×")
        closeText:SetTextColor(0.60, 0.62, 0.70)
        close:SetScript("OnEnter", function() closeText:SetTextColor(1, 0.45, 0.45) end)
        close:SetScript("OnLeave", function() closeText:SetTextColor(0.60, 0.62, 0.70) end)
        close:SetScript("OnClick", function() frame:Hide() end)

        -- ── Dungeon + result (centered) ─────────────────────────────────
        -- Dungeon name in FRIZQT at a large size — MORPHEUS at this size
        -- with OUTLINE rendered jagged with ligature-heavy glyphs. FRIZQT
        -- without OUTLINE reads clean and modern.
        local dungeon = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(dungeon, "Fonts\\FRIZQT__.TTF", 24, "")
        dungeon:SetPoint("TOP", 0, -56)
        dungeon:SetJustifyH("CENTER")
        dungeon:SetTextColor(1, 1, 1)
        frame.dungeon = dungeon

        local resultLine = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(resultLine, POPUP_LABEL_FONT, 12, "")
        resultLine:SetPoint("TOP", 0, -94)
        resultLine:SetJustifyH("CENTER")
        resultLine:SetTextColor(0.62, 0.64, 0.72)
        frame.resultLine = resultLine

        -- Divider 1 (below header info).
        local div1 = frame:CreateTexture(nil, "ARTWORK")
        div1:SetPoint("TOPLEFT", 40, -120)
        div1:SetPoint("TOPRIGHT", -40, -120)
        div1:SetHeight(1)
        div1:SetColorTexture(1, 1, 1, 0.06)

        -- ── HERO: score box + tier box, side-by-side, centered ──────────
        -- Two 140x140 trophy-style boxes with a 28px gap between them.
        local heroTop = -148
        local boxSize = 140
        local heroGap = 28

        -- Score box (left half of hero): score number + "MPI" label.
        local scoreBox = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        scoreBox:SetSize(boxSize, boxSize)
        scoreBox:SetPoint("TOP", -(boxSize + heroGap) / 2, heroTop)
        scoreBox:SetBackdrop({
            bgFile = W8, edgeFile = W8, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame.scoreBox = scoreBox

        local scoreCaption = scoreBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(scoreCaption, POPUP_LABEL_FONT, 11, "OUTLINE")
        scoreCaption:SetPoint("TOP", 0, -16)
        scoreCaption:SetText("MPI SCORE")
        scoreCaption:SetTextColor(0.52, 0.55, 0.65)

        local scoreValue = scoreBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(scoreValue, POPUP_NUMBER_FONT, 60, "OUTLINE")
        scoreValue:SetPoint("CENTER", 0, -4)
        frame.scoreValue = scoreValue

        local scoreFooter = scoreBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(scoreFooter, POPUP_LABEL_FONT, 10, "OUTLINE")
        scoreFooter:SetPoint("BOTTOM", 0, 14)
        scoreFooter:SetText("OUT OF 100")
        scoreFooter:SetTextColor(0.40, 0.42, 0.50)

        -- Tier box (right half of hero): big tier letter + "TIER" label.
        local tierBox = CreateFrame("Frame", nil, frame, "BackdropTemplate")
        tierBox:SetSize(boxSize, boxSize)
        tierBox:SetPoint("TOP", (boxSize + heroGap) / 2, heroTop)
        tierBox:SetBackdrop({
            bgFile = W8, edgeFile = W8, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        frame.tierBox = tierBox

        local tierCaption = tierBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(tierCaption, POPUP_LABEL_FONT, 11, "OUTLINE")
        tierCaption:SetPoint("TOP", 0, -16)
        tierCaption:SetText("TIER RANK")
        tierCaption:SetTextColor(0.52, 0.55, 0.65)

        local tierLetter = tierBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(tierLetter, POPUP_DISPLAY_FONT, 64, "OUTLINE")
        tierLetter:SetPoint("CENTER", 0, -4)
        frame.tierLetter = tierLetter

        local roleBadge = tierBox:CreateFontString(nil, "OVERLAY")
        TrySetFont(roleBadge, POPUP_LABEL_FONT, 10, "OUTLINE")
        roleBadge:SetPoint("BOTTOM", 0, 14)
        roleBadge:SetTextColor(0.40, 0.42, 0.50)
        frame.roleBadge = roleBadge

        -- Divider 2 (below hero).
        local div2 = frame:CreateTexture(nil, "ARTWORK")
        div2:SetPoint("TOPLEFT", 40, -310)
        div2:SetPoint("TOPRIGHT", -40, -310)
        div2:SetHeight(1)
        div2:SetColorTexture(1, 1, 1, 0.06)

        local breakdownLabel = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(breakdownLabel, POPUP_LABEL_FONT, 12, "OUTLINE")
        breakdownLabel:SetPoint("TOP", 0, -324)
        breakdownLabel:SetText("PERFORMANCE BREAKDOWN")
        breakdownLabel:SetTextColor(0.52, 0.55, 0.65)

        -- ── Dimension rows (centered, with generous horizontal padding) ─
        -- Rows indented 70px each side for a ~500px content band, keeping
        -- label/bar/score/tier columns aligned and breathable.
        frame.dimRows = {}
        for i, def in ipairs(RUN_POPUP_DIMS) do
            local rowY = -346 - ((i - 1) * 26)
            local row = CreateFrame("Frame", nil, frame)
            row:SetHeight(20)
            row:SetPoint("TOPLEFT", 70, rowY)
            row:SetPoint("TOPRIGHT", -70, rowY)

            local lbl = row:CreateFontString(nil, "OVERLAY")
            TrySetFont(lbl, POPUP_LABEL_FONT, 12, "OUTLINE")
            lbl:SetPoint("LEFT", 0, 0)
            lbl:SetWidth(130)
            lbl:SetJustifyH("LEFT")
            lbl:SetText(def.label)
            lbl:SetTextColor(0.68, 0.70, 0.78)

            local trackBg = row:CreateTexture(nil, "BACKGROUND")
            trackBg:SetPoint("LEFT", 140, 0)
            trackBg:SetPoint("RIGHT", -90, 0)
            trackBg:SetHeight(6)
            trackBg:SetColorTexture(1, 1, 1, 0.05)

            local fill = row:CreateTexture(nil, "ARTWORK")
            fill:SetPoint("LEFT", trackBg, "LEFT", 0, 0)
            fill:SetHeight(6)

            local scoreTxt = row:CreateFontString(nil, "OVERLAY")
            TrySetFont(scoreTxt, POPUP_NUMBER_FONT, 15, "OUTLINE")
            scoreTxt:SetPoint("RIGHT", -36, 0)
            scoreTxt:SetJustifyH("RIGHT")
            scoreTxt:SetWidth(44)

            local tierTxt = row:CreateFontString(nil, "OVERLAY")
            TrySetFont(tierTxt, POPUP_NUMBER_FONT, 14, "OUTLINE")
            tierTxt:SetPoint("RIGHT", 0, 0)
            tierTxt:SetJustifyH("RIGHT")
            tierTxt:SetWidth(28)

            frame.dimRows[i] = { lbl = lbl, track = trackBg, fill = fill,
                                 scoreTxt = scoreTxt, tierTxt = tierTxt, field = def.field }
        end

        -- Divider 3 (below rows).
        local div3 = frame:CreateTexture(nil, "ARTWORK")
        div3:SetPoint("TOPLEFT", 40, -478)
        div3:SetPoint("TOPRIGHT", -40, -478)
        div3:SetHeight(1)
        div3:SetColorTexture(1, 1, 1, 0.06)

        local badgesLabel = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badgesLabel, POPUP_LABEL_FONT, 12, "OUTLINE")
        badgesLabel:SetPoint("TOP", 0, -492)
        badgesLabel:SetText("BADGES EARNED")
        badgesLabel:SetTextColor(0.52, 0.55, 0.65)
        frame.badgesLabel = badgesLabel

        local badges = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(badges, POPUP_LABEL_FONT, 13, "OUTLINE")
        badges:SetPoint("TOP", 0, -512)
        badges:SetWidth(560)
        badges:SetJustifyH("CENTER")
        badges:SetWordWrap(true)
        badges:SetTextColor(0.88, 0.90, 0.98)
        frame.badges = badges

        -- ── Bottom section ──────────────────────────────────────────────
        -- Full redesign: thin colored accent bar (status indicator) on the
        -- left, stacked title + subtitle next to it, buttons on the right.
        -- Subtle hairline divider above separates this from the badges.
        -- All elements share the same vertical centerline as the buttons.

        -- Hairline divider above the bottom section for visual separation.
        local bottomDiv = frame:CreateTexture(nil, "ARTWORK")
        bottomDiv:SetPoint("BOTTOMLEFT", 30, 86)
        bottomDiv:SetPoint("BOTTOMRIGHT", -30, 86)
        bottomDiv:SetHeight(1)
        bottomDiv:SetColorTexture(1, 1, 1, 0.06)

        -- Buttons first — everything else anchors relative to them.
        local primaryBtn = MakePopupButton(frame, "RELOAD NOW", 140, 34, true)
        primaryBtn:SetPoint("BOTTOMRIGHT", -30, 26)
        frame.primaryBtn = primaryBtn

        local secondaryBtn = MakePopupButton(frame, "LATER", 96, 34, false)
        secondaryBtn:SetPoint("RIGHT", primaryBtn, "LEFT", -10, 0)
        frame.secondaryBtn = secondaryBtn

        -- Status accent: thin colored vertical bar as indicator. Height
        -- matches the title+subtitle stack so it reads as a "gutter" mark,
        -- echoing the colored accent bar at the top of the popup.
        local cAccent = frame:CreateTexture(nil, "ARTWORK")
        cAccent:SetPoint("BOTTOMLEFT", 30, 30)
        cAccent:SetSize(3, 30)
        cAccent:SetColorTexture(0.5, 0.5, 0.5, 1)
        frame.companionDot = cAccent   -- data-binding name kept for compatibility

        -- Status title: bold uppercase, high-contrast, anchored to the top
        -- of the button row.
        local cTitle = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(cTitle, POPUP_LABEL_FONT, 12, "OUTLINE")
        cTitle:SetPoint("BOTTOMLEFT", cAccent, "TOPLEFT", 10, -14)
        cTitle:SetJustifyH("LEFT")
        cTitle:SetJustifyV("BOTTOM")
        cTitle:SetTextColor(0.95, 0.96, 0.98)
        frame.companionTitle = cTitle

        -- Status subtitle: muted detail, sits below the title.
        local cSub = frame:CreateFontString(nil, "OVERLAY")
        TrySetFont(cSub, POPUP_LABEL_FONT, 11, "")
        cSub:SetPoint("TOPLEFT", cTitle, "BOTTOMLEFT", 0, -2)
        cSub:SetPoint("RIGHT", secondaryBtn, "LEFT", -20, 0)
        cSub:SetJustifyH("LEFT")
        cSub:SetTextColor(0.55, 0.58, 0.68)
        cSub:SetWordWrap(false)
        frame.companionMsg = cSub      -- data-binding name kept for compatibility

        -- ── Fade-in animation (created once, replayed on every Show) ─────
        -- Alpha animations in WoW don't persist the final value — when they
        -- finish, the frame reverts to whatever alpha it had before. Must
        -- pin it to 1 in OnFinished, or the popup vanishes right after
        -- appearing. (Learned the hard way.)
        local fadeIn = frame:CreateAnimationGroup()
        local a1 = fadeIn:CreateAnimation("Alpha")
        a1:SetFromAlpha(0); a1:SetToAlpha(1); a1:SetDuration(0.55); a1:SetSmoothing("OUT")
        fadeIn:SetScript("OnFinished", function() frame:SetAlpha(1) end)
        frame._fadeIn = fadeIn

        MPI._runPopup = frame
    end

    -- ── Data binding ─────────────────────────────────────────────────────
    local dungeon = runRecord.dungeonName or "Mythic+ Run"
    local keyLevel = runRecord.keyLevel or 0
    local resultText, chestText
    if runRecord.result == "timed" then
        local starCount = runRecord.stars or 0
        resultText = "Timed"
        if     starCount >= 3 then chestText = "3-chest"
        elseif starCount == 2 then chestText = "2-chest"
        elseif starCount == 1 then chestText = "Timed"
        else                       chestText = "On the clock" end
    else
        resultText = "Depleted"
        chestText = "Over the timer"
    end

    -- No star glyphs in the subtitle — FRIZQT__.TTF doesn't render ★ and
    -- the chest count ("2-chest" etc.) already conveys the timer quality.
    frame.dungeon:SetText(string.format("%s  +%d", dungeon, keyLevel))
    frame.resultLine:SetText(string.format("%s   %s   %s",
        resultText, POPUP_BULLET, chestText))

    local mpi = me and me.mpiScore or 0
    local tr, tg, tb = GetMpiTierColor(mpi)
    local tierLetter = MPI.GetScoreTier(mpi)

    frame.scoreValue:SetText(tostring(mpi))
    frame.scoreValue:SetTextColor(tr, tg, tb)
    frame.tierLetter:SetText(tierLetter)
    frame.tierLetter:SetTextColor(tr, tg, tb)

    local roleDisplay = me and (me.weightRole or me.role) or ""
    if roleDisplay == "DAMAGER" then roleDisplay = "DPS" end
    frame.roleBadge:SetText(string.upper(roleDisplay))

    -- Tier accent touches — restrained now. Border + 2px bar + subtle hero
    -- box tint. No aggressive full-panel glow (was swamping the top third).
    frame:SetBackdropBorderColor(tr * 0.70, tg * 0.70, tb * 0.70, 0.55)
    frame.accent:SetColorTexture(tr, tg, tb, 1.0)
    frame.scoreBox:SetBackdropColor(tr * 0.06, tg * 0.06, tb * 0.06, 0.65)
    frame.scoreBox:SetBackdropBorderColor(tr, tg, tb, 0.45)
    frame.tierBox:SetBackdropColor(tr * 0.06, tg * 0.06, tb * 0.06, 0.65)
    frame.tierBox:SetBackdropBorderColor(tr, tg, tb, 0.45)
    frame.primaryBtn._r, frame.primaryBtn._g, frame.primaryBtn._b = tr, tg, tb
    frame.primaryBtn._applyStyle(frame.primaryBtn, false)

    for _, row in ipairs(frame.dimRows) do
        local val = me and me[row.field]
        if val == nil or (val == 0 and row.field == "consistencyScore") then
            row.scoreTxt:SetText(POPUP_EMDASH)
            row.scoreTxt:SetTextColor(0.45, 0.47, 0.54)
            row.tierTxt:SetText("")
            row.fill:SetWidth(0.0001)
        else
            val = val or 0
            local br, bg, bb = MPI.GetBarColor(val)
            local letter = MPI.GetScoreTier(val)
            row.scoreTxt:SetText(tostring(val))
            row.scoreTxt:SetTextColor(br, bg, bb)
            row.tierTxt:SetText(letter)
            row.tierTxt:SetTextColor(br, bg, bb)
            row.fill:SetColorTexture(br, bg, bb, 0.90)
            local trackWidth = row.track:GetWidth()
            if trackWidth <= 0 then trackWidth = 300 end
            row.fill:SetWidth(max(0.0001, trackWidth * (val / 100)))
        end
    end

    -- Badge pills — name-only (symbol glyphs don't render in FRIZQT, and
    -- the hex-escape syntax they're defined with is broken in WoW's Lua
    -- anyway). Bullet-separated names reads clean.
    local earnedBadges = me and me.badges or {}
    local parts = {}
    for _, bid in ipairs(earnedBadges) do
        local k = BADGE_ID_TO_KEY[bid]
        local info = k and BADGE_DEFS[k]
        if info and info.label then parts[#parts + 1] = info.label end
    end
    if #parts > 0 then
        frame.badgesLabel:Show()
        frame.badges:Show()
        frame.badges:SetText(table.concat(parts, "    " .. POPUP_BULLET .. "    "))
    else
        frame.badgesLabel:Hide()
        frame.badges:Hide()
    end

    local status = (MPI.GetCompanionStatus and MPI.GetCompanionStatus()) or "missing"
    local badgeCount = earnedBadges and #earnedBadges or 0
    MPI.DebugLog(string.format("RunPopup render: dungeon=%s +%d result=%s mpi=%d role=%s aware=%s surv=%s out=%s util=%s cons=%s badges=%d companion=%s",
        tostring(dungeon), keyLevel, tostring(runRecord.result),
        mpi, tostring(roleDisplay),
        tostring(me and me.awarenessScore),
        tostring(me and me.survivalScore),
        tostring(me and me.outputScore),
        tostring(me and me.utilityScore),
        tostring(me and me.consistencyScore),
        badgeCount, status))
    if status == "active" then
        frame.companionDot:SetColorTexture(0.30, 0.85, 0.55, 1)
        frame.companionTitle:SetText("COMPANION LINKED")
        frame.companionMsg:SetText("Uploads on your next reload")
        frame.primaryBtn:SetText("Reload Now")
        frame.primaryBtn:SetScript("OnClick", function()
            frame:Hide()
            ReloadUI()
        end)
        frame.secondaryBtn:SetText("Later")
        frame.secondaryBtn:SetScript("OnClick", function() frame:Hide() end)
    elseif status == "stale" then
        frame.companionDot:SetColorTexture(0.95, 0.80, 0.30, 1)
        frame.companionTitle:SetText("COMPANION OUT OF SYNC")
        frame.companionMsg:SetText("Reload to upload this run")
        frame.primaryBtn:SetText("Reload Now")
        frame.primaryBtn:SetScript("OnClick", function()
            frame:Hide()
            ReloadUI()
        end)
        frame.secondaryBtn:SetText("Later")
        frame.secondaryBtn:SetScript("OnClick", function() frame:Hide() end)
    else
        frame.companionDot:SetColorTexture(1.00, 0.45, 0.45, 1)
        frame.companionTitle:SetText("COMPANION NOT DETECTED")
        frame.companionMsg:SetText("Run saved locally  " .. POPUP_BULLET .. "  install to upload")
        frame.primaryBtn:SetText("Get Companion")
        frame.primaryBtn:SetScript("OnClick", function()
            if type(_G.MidnightUI_OpenURL) == "function" then
                _G.MidnightUI_OpenURL("https://mpi.atyzi.com")
            end
        end)
        frame.secondaryBtn:SetText("Dismiss")
        frame.secondaryBtn:SetScript("OnClick", function() frame:Hide() end)
    end

    -- Play a gentle alpha fade every time the popup is shown so it feels
    -- like a moment instead of a flashbang.
    frame:SetAlpha(0)
    frame:Show()
    if frame._fadeIn then
        frame._fadeIn:Stop()
        frame._fadeIn:Play()
    else
        frame:SetAlpha(1)
    end

    if frame._hideTimer then
        frame._hideTimer:Cancel()
    end
    frame._hideTimer = C_Timer.NewTimer(120, function()
        if frame:IsShown() then frame:Hide() end
    end)
end

function MPI.UpdateProfiles(runRecord)
    if not runRecord or not runRecord.players then return end

    local myGUID = UnitGUID("player")

    for guid, pData in pairs(runRecord.players) do
        local prof = db.profiles[guid]
        if not prof then
            prof = {
                name           = pData.name,
                class          = pData.class,
                primaryRole    = pData.role,
                runsTracked    = 0,
                abandonedRuns  = 0,
                avgMPI         = 0,
                recentScores   = {},
                previousScores = {},
                trend          = "stable",
                bestMPI        = 0,
                bestDungeon    = "",
                badges         = {},
                dungeonMastery = {},
                pressureCurve  = {},
                peakLevel      = "",
                roleHistory    = {},
                byRole         = {},
                tags           = {},
                note           = "",
                lastSeen       = 0,
                source         = "local",
            }
            db.profiles[guid] = prof
        end

        prof.name = pData.name or prof.name
        prof.class = pData.class or prof.class
        prof.lastSeen = time()
        prof.source = "local"

        local currentRole = pData.role or "DAMAGER"
        if not prof.roleHistory then prof.roleHistory = {} end
        if prof.roleHistory[1] ~= currentRole then
            table.insert(prof.roleHistory, 1, currentRole)
            if #prof.roleHistory > 5 then
                prof.roleHistory[#prof.roleHistory] = nil
            end
        end
        prof.primaryRole = currentRole

        prof.runsTracked = (prof.runsTracked or 0) + 1
        if runRecord.leavers then
            for _, lInfo in pairs(runRecord.leavers) do
                if lInfo.isInitiator and lInfo.name == pData.name then
                    prof.abandonedRuns = (prof.abandonedRuns or 0) + 1
                    break
                end
            end
        end

        if not prof.recentScores then prof.recentScores = {} end
        if not prof.previousScores then prof.previousScores = {} end

        table.insert(prof.recentScores, 1, pData.mpiScore)
        while #prof.recentScores > TREND_WINDOW do
            local overflow = table.remove(prof.recentScores)
            table.insert(prof.previousScores, 1, overflow)
        end
        while #prof.previousScores > TREND_WINDOW do
            table.remove(prof.previousScores)
        end

        -- Overall MPI: verified-peak model (mirrors mpi.atyzi.com).
        -- Per-dimension averages stay simple unweighted means across the
        -- player's last 20 runs — they're shown as bars in the profile and
        -- a straightforward average reads more intuitively than a weighted
        -- one. The overall (avgMPI) does the heavy lifting of representing
        -- progression × execution.
        local recentRuns = {}
        local dimSums = { awareness = 0, survival = 0, output = 0, utility = 0, consistency = 0 }
        local dimCount = 0
        for i, run in ipairs(db.runs) do
            local pd = run.players and run.players[guid]
            if pd then
                recentRuns[#recentRuns + 1] = {
                    mpiScore     = pd.mpiScore or 0,
                    keyLevel     = run.keyLevel or 0,
                    timestamp    = run.timestamp or 0,
                    dungeonName  = run.dungeonName or "",
                    durationSec  = run.durationSec or 0,
                    timeLimitSec = run.timeLimitSec or 0,
                    result       = run.result or "depleted",
                }
                dimSums.awareness   = dimSums.awareness   + (pd.awarenessScore   or 0)
                dimSums.survival    = dimSums.survival    + (pd.survivalScore    or 0)
                dimSums.output      = dimSums.output      + (pd.outputScore      or 0)
                dimSums.utility     = dimSums.utility     + (pd.utilityScore     or 0)
                dimSums.consistency = dimSums.consistency + (pd.consistencyScore or 0)
                dimCount = dimCount + 1
            end
            if i >= 20 then break end
        end
        if dimCount > 0 then
            prof.avgMPI           = MPI.ComputeOverallFromRuns(recentRuns)
            prof.awarenessScore   = floor(dimSums.awareness / dimCount + 0.5)
            prof.survivalScore    = floor(dimSums.survival / dimCount + 0.5)
            prof.outputScore      = floor(dimSums.output / dimCount + 0.5)
            prof.utilityScore     = floor(dimSums.utility / dimCount + 0.5)
            prof.consistencyScore = floor(dimSums.consistency / dimCount + 0.5)
        end

        if (pData.mpiScore or 0) > (prof.bestMPI or 0) then
            prof.bestMPI = pData.mpiScore
            prof.bestDungeon = runRecord.dungeonName
        end

        prof.trend = MPI.ComputeTrend(prof.recentScores, prof.previousScores)

        if not prof.byRole then prof.byRole = {} end
        local roleKey = (currentRole == "DAMAGER") and "DPS" or currentRole
        if not prof.byRole[roleKey] then
            prof.byRole[roleKey] = { avg = 0, runs = 0, recentScores = {} }
        end
        local roleData = prof.byRole[roleKey]
        roleData.runs = (roleData.runs or 0) + 1
        if not roleData.recentScores then roleData.recentScores = {} end
        table.insert(roleData.recentScores, 1, pData.mpiScore)
        while #roleData.recentScores > SCORE_WINDOW do
            table.remove(roleData.recentScores)
        end
        local roleSum = 0
        for _, s in ipairs(roleData.recentScores) do roleSum = roleSum + s end
        roleData.avg = floor(roleSum / #roleData.recentScores + 0.5)

        if not prof.dungeonMastery then prof.dungeonMastery = {} end
        local mapID = runRecord.dungeonMapID
        if mapID then
            if not prof.dungeonMastery[mapID] then
                prof.dungeonMastery[mapID] = { runs = {}, level = "LEARNING" }
            end
            local dm = prof.dungeonMastery[mapID]
            table.insert(dm.runs, 1, {
                score = pData.mpiScore,
                timed = runRecord.result == "timed",
            })
            while #dm.runs > 10 do table.remove(dm.runs) end
            dm.level = MPI.ComputeDungeonMastery(dm.runs)
        end

        if guid == myGUID then
            local pressureRuns = {}
            for _, run in ipairs(db.runs) do
                if run.players and run.players[guid] then
                    pressureRuns[#pressureRuns + 1] = {
                        keyLevel = run.keyLevel,
                        score = run.players[guid].mpiScore,
                        result = run.result,
                        timestamp = run.timestamp,
                    }
                end
            end
            prof.pressureCurve, prof.peakLevel = MPI.ComputePressureCurve(pressureRuns)

            -- Max streak of consecutive timed keys at +10 or higher (for Godstreak).
            table.sort(pressureRuns, function(a, b) return (a.timestamp or 0) < (b.timestamp or 0) end)
            local streak, maxStreak = 0, 0
            for _, r in ipairs(pressureRuns) do
                if r.result == "timed" and (r.keyLevel or 0) >= 10 then
                    streak = streak + 1
                    if streak > maxStreak then maxStreak = streak end
                else
                    streak = 0
                end
            end
            prof.maxTimedStreakAt10Plus = maxStreak

            -- Highest timed key this season (for Dragonslayer fallback on profile).
            local peakTimedKey = 0
            for _, r in ipairs(pressureRuns) do
                if r.result == "timed" and (r.keyLevel or 0) > peakTimedKey then
                    peakTimedKey = r.keyLevel
                end
            end
            prof.peakTimedKey = peakTimedKey
        end

        prof.badges = MPI.DetermineProfileBadges(prof)

        if pData.badges then
            if not prof.recentBadges then prof.recentBadges = {} end
            for _, bid in ipairs(pData.badges) do
                prof.recentBadges[bid] = time()
            end
        end
    end
end

function MPI.UpdatePersonalStats()
    if not db then return end
    local myGUID = UnitGUID("player")
    if not myGUID then return end

    local totalRuns, timedRuns = 0, 0
    local totalScore, scoreCount = 0, 0
    local weeklyRuns, weeklyTimed, weeklyScore, weeklyCount = 0, 0, 0, 0
    local weekStart = db.personal.weekStart or 0
    local byDungeon = {}

    for _, run in ipairs(db.runs) do
        if run.players and run.players[myGUID] then
            local myScore = run.players[myGUID].mpiScore or 0
            totalRuns = totalRuns + 1
            totalScore = totalScore + myScore
            scoreCount = scoreCount + 1

            if run.result == "timed" then timedRuns = timedRuns + 1 end

            if (run.timestamp or 0) >= weekStart then
                weeklyRuns = weeklyRuns + 1
                weeklyScore = weeklyScore + myScore
                weeklyCount = weeklyCount + 1
                if run.result == "timed" then weeklyTimed = weeklyTimed + 1 end
            end

            local mapID = run.dungeonMapID
            if mapID then
                if not byDungeon[mapID] then
                    byDungeon[mapID] = { runs = 0, timedRuns = 0, totalScore = 0, bestMPI = 0, bestLevel = 0, name = run.dungeonName }
                end
                local bd = byDungeon[mapID]
                bd.runs = bd.runs + 1
                bd.totalScore = bd.totalScore + myScore
                if run.result == "timed" then bd.timedRuns = bd.timedRuns + 1 end
                if myScore > bd.bestMPI then bd.bestMPI = myScore end
                if (run.keyLevel or 0) > bd.bestLevel then bd.bestLevel = run.keyLevel end
            end
        end
    end

    for mapID, bd in pairs(byDungeon) do
        bd.avgMPI = bd.runs > 0 and floor(bd.totalScore / bd.runs + 0.5) or 0
    end

    -- Profile-level Consistency = average of per-run Consistency values across
    -- the most recent SCORE_WINDOW runs for this player. Per-run values are
    -- already per-run execution scores (see MPI.ScoreConsistency), so the
    -- average is a true reliability indicator — "how often do you finish
    -- cleanly?" — not a spread metric.
    local consistencyScore = 0
    do
        local sum, count = 0, 0
        for i = #db.runs, 1, -1 do
            local run = db.runs[i]
            local rp  = run.players and run.players[myGUID]
            if rp and rp.consistencyScore then
                sum = sum + rp.consistencyScore
                count = count + 1
                if count >= SCORE_WINDOW then break end
            end
        end
        if count > 0 then
            consistencyScore = floor(sum / count + 0.5)
        end
    end

    -- Personal overall MPI: verified-peak model (mirrors mpi.atyzi.com).
    -- Falls back to simple average only if the helper isn't loaded yet.
    local overall = 0
    local overallPeak = 0
    if MPI.ComputeOverallFromRuns then
        local personalRuns = {}
        for _, run in ipairs(db.runs) do
            local rp = run.players and run.players[myGUID]
            if rp then
                personalRuns[#personalRuns + 1] = {
                    mpiScore     = rp.mpiScore or 0,
                    keyLevel     = run.keyLevel or 0,
                    timestamp    = run.timestamp or 0,
                    dungeonName  = run.dungeonName or "",
                    durationSec  = run.durationSec or 0,
                    timeLimitSec = run.timeLimitSec or 0,
                    result       = run.result or "depleted",
                }
            end
        end
        overall, overallPeak = MPI.ComputeOverallFromRuns(personalRuns)
    elseif scoreCount > 0 then
        overall = floor(totalScore / scoreCount + 0.5)
    end

    db.personal.totalRuns      = totalRuns
    db.personal.timedRuns      = timedRuns
    db.personal.avgMPI         = overall
    db.personal.verifiedPeak   = overallPeak or 0
    db.personal.consistencyScore = consistencyScore
    db.personal.byDungeon      = byDungeon
    db.personal.weeklyRuns = weeklyRuns
    db.personal.weeklyTimedRuns = weeklyTimed
    db.personal.weeklyAvgMPI = weeklyCount > 0 and floor(weeklyScore / weeklyCount + 0.5) or 0
end



local dashRefs = {}

-- ============================================================
-- SECTION: Dashboard UI Construction
-- READS: MPI.EnsureDB() data, SCORE_TIERS, BADGE_DEFS,
--   MASTERY_LEVELS, dashRefs (sub-frame ref table)
-- WRITES: MPI._dashboardBuilt flag, dashRefs[]
-- MPI.BuildDungeonDashboard(parentFrame):
--   Creates the MPI panel inside GroupFinder or standalone.
--   Sections: dungeon mastery grid (per-dungeon status card),
--   recent runs list (key level, score, badges per member),
--   player score card (composite + per-category bars).
-- UI helpers: CreateGlassPanelMPI, CreateScoreBar,
--   CreateBadgeStrip — small frame builders.
-- ============================================================
local function _trendStringAndColor(trend)
    if trend == "up"   then return "+", 0.35, 0.88, 0.45 end
    if trend == "down" then return "-", 1.00, 0.35, 0.35 end
    return "=", 0.95, 0.80, 0.20
end

-- Returns the player's effective average MPI score, preferring the website
-- overlay (authoritative — matches mpi.atyzi.com exactly) and falling back
-- to the locally-aggregated number. Same trust gates as the Season Progress
-- panel: trust the overlay if either the local SavedVariables shows tracked
-- runs OR the Companion has done a server sync within 24h. The two signals
-- defend against the addon-folder-copy threat the original guard worried
-- about, while still showing fresh-install accounts their real averages.
function MPI.GetEffectiveSelfScore()
    local p = (db and db.personal) or {}
    local localScore = tonumber(p.avgMPI) or 0
    local localRuns  = tonumber(p.totalRuns) or 0

    local w = _G.MidnightUI_MPI_Web
    if type(w) == "table" and (tonumber(w.totalRuns) or 0) > 0 then
        local webScore = tonumber(w.avgMPI) or 0
        local webRuns  = tonumber(w.totalRuns) or 0
        local trusted = false
        if db and type(db.profiles) == "table" then
            for _, prof in pairs(db.profiles) do
                if type(prof) == "table" and (tonumber(prof.runsTracked) or 0) > 0 then
                    trusted = true; break
                end
            end
        end
        if not trusted then
            local sync = _G.MidnightUI_MPI_Sync
            if type(sync) == "table" and tonumber(sync.lastSync) then
                local nowOk, now = pcall(time)
                if nowOk and type(now) == "number" and (now - sync.lastSync) < 86400 then
                    trusted = true
                end
            end
        end
        if trusted and webScore > 0 then
            return webScore, webRuns, "web"
        end
    end

    return localScore, localRuns, "local"
end

local function _applyMpiToPill(pill)
    if not pill or not pill._value or not pill._label then return end
    if not db or not db.settings.enabled or not db.settings.showInHero then
        pill._label:SetText("MPI RATING")
        pill._value:SetText("--")
        pill._value:SetTextColor(0.45, 0.45, 0.45)
        return
    end
    if not MPI._apiAvailable then
        pill._label:SetText("MPI RATING")
        pill._value:SetText("--")
        pill._value:SetTextColor(0.45, 0.45, 0.45)
        return
    end

    -- Resolve current spec + score data.
    local activeRole = MPI.GetActiveRole and MPI.GetActiveRole() or nil
    local active, fallback
    if MPI.GetActiveRoleScore then
        active, fallback = MPI.GetActiveRoleScore()
    end

    local function roleLabel(rl)
        if rl == "TANK"   then return "TANK" end
        if rl == "HEALER" then return "HEALER" end
        if rl == "AUG"    then return "AUG" end
        if rl == "DPS"    then return "DPS" end
        return rl or "DPS"
    end

    -- Three label states:
    --   1. Active spec HAS data        → "MPI · TANK", real score
    --   2. Active spec has NO data yet → "MPI · DPS",  value "--", tooltip
    --      explains there are no runs in this role + offers fallback context
    --   3. No spec detected (rare)     → "MPI RATING",  value "--"
    if not active and not activeRole then
        pill._label:SetText("MPI RATING")
        pill._value:SetText("--")
        pill._value:SetTextColor(0.45, 0.45, 0.45)
        if pill._dot then pill._dot:SetColorTexture(0.35, 0.35, 0.40) end
        pill:EnableMouse(false)
        pill:SetScript("OnEnter", nil)
        pill:SetScript("OnLeave", nil)
        return
    end

    -- Pill always labels by ACTIVE spec — even when there's no data
    -- for it yet, so a player in a fresh spec sees "MPI · DPS", not
    -- "MPI · TANK *" (the previous behavior, which surprised people
    -- who'd just swapped specs).
    local labelRole = activeRole or (active and active.role) or (fallback and fallback.role) or "DPS"
    pill._label:SetText("MPI \194\183 " .. roleLabel(labelRole))

    if not active then
        -- Active spec has no runs yet. Show a quiet "--" instead of
        -- borrowing another role's score.
        pill._value:SetText("--")
        pill._value:SetTextColor(0.55, 0.55, 0.55)
        if pill._dot then pill._dot:SetColorTexture(0.35, 0.35, 0.40) end
        pill:EnableMouse(true)
        pill:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
            GameTooltip:ClearLines()
            GameTooltip:AddLine("MPI Rating \194\183 " .. roleLabel(labelRole), 1, 1, 1)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(
                "No " .. roleLabel(labelRole) .. " runs yet this season.",
                0.85, 0.85, 0.85, true)
            GameTooltip:AddLine(
                "Time a key in this spec to see your score here.",
                0.55, 0.55, 0.55, true)
            if fallback then
                GameTooltip:AddLine(" ")
                local _, fr, fg, fb = MPI.GetScoreTier(fallback.overallMPI or 0)
                GameTooltip:AddDoubleLine(
                    "Most-Played Role \194\183 " .. roleLabel(fallback.role),
                    tostring(fallback.overallMPI or 0),
                    0.70, 0.70, 0.70, fr or 1, fg or 1, fb or 1)
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Detailed breakdown at mpi.atyzi.com/dashboard",
                0.55, 0.75, 0.95, true)
            GameTooltip:Show()
        end)
        pill:SetScript("OnLeave", function() GameTooltip:Hide() end)
        return
    end

    local pick = active
    local score = pick.overallMPI
    local tier, r, g, b = MPI.GetScoreTier(score)
    pill._value:SetText(tostring(score) .. "  " .. tier)
    pill._value:SetTextColor(r, g, b)

    if pill._dot then
        if pill._dot.SetGradient and CreateColor then
            pill._dot:SetGradient("VERTICAL",
                CreateColor(r, g, b, 0.3),
                CreateColor(r, g, b, 1.0))
        else
            pill._dot:SetColorTexture(r, g, b)
        end
    end

    pill:EnableMouse(true)
    pill:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:ClearLines()

        -- ── Header ─────────────────────────────────────────────────
        -- Active spec is guaranteed non-nil here (no-data case returned
        -- earlier), so the header reflects whatever spec the player is in.
        GameTooltip:AddLine("MPI Rating \194\183 " .. roleLabel(pick.role), 1, 1, 1)
        GameTooltip:AddLine(" ")

        -- ── Score block ────────────────────────────────────────────
        local tierLabel = TIER_LABELS[tier] or ""
        local scoreRight = tostring(score) .. "  " .. tier
                         .. (tierLabel ~= "" and ("  \194\183 " .. tierLabel) or "")
        GameTooltip:AddDoubleLine("Score", scoreRight, 0.70, 0.70, 0.70, r, g, b)

        -- Verified peak with a "(Provisional)" hint when one-off.
        local vp = pick.verifiedPeak or 0
        local hk = pick.highestKey   or 0
        if pick.peakIsVerified and vp > 0 then
            GameTooltip:AddDoubleLine("Verified Peak", "+" .. vp,
                0.70, 0.70, 0.70, 1, 1, 1)
        elseif hk > 0 then
            GameTooltip:AddDoubleLine("Highest Timed",
                "+" .. hk .. "  (Provisional — Do another to verify)",
                0.70, 0.70, 0.70, 0.85, 0.75, 0.45)
        else
            GameTooltip:AddDoubleLine("Highest Timed", "None",
                0.70, 0.70, 0.70, 0.55, 0.55, 0.55)
        end

        -- Best run: dungeon + key + score. Pull from local extras since
        -- the web-overlay role payload only carries the score number.
        local extras = MPI.GetRoleTooltipExtras and MPI.GetRoleTooltipExtras(pick.role) or nil
        if extras and extras.bestRun then
            local b = extras.bestRun
            local _, br_, bg_, bb_ = MPI.GetScoreTier(b.mpiScore)
            GameTooltip:AddDoubleLine("Best Run",
                "+" .. (b.keyLevel or 0) .. " " .. (b.dungeonName or "?") .. "  \194\183  " .. tostring(b.mpiScore),
                0.70, 0.70, 0.70, br_ or 1, bg_ or 1, bb_ or 1)
        elseif pick.bestMPI and pick.bestMPI > 0 then
            local _, br_, bg_, bb_ = MPI.GetScoreTier(pick.bestMPI)
            GameTooltip:AddDoubleLine("Best Run", tostring(pick.bestMPI),
                0.70, 0.70, 0.70, br_ or 1, bg_ or 1, bb_ or 1)
        end

        -- ── Per-dungeon breakdown ──────────────────────────────────
        -- Color carries the timed-vs-not signal: timed dungeons render
        -- in their tier color, untimed ones go grey. No glyph prefix
        -- because the default WoW UI font doesn't have the check mark
        -- character we tried first (rendered as a square).
        local seasonDungeons = _getSeasonDungeons()
        if seasonDungeons and extras then
            GameTooltip:AddLine(" ")
            local pool = #seasonDungeons
            local timedCount = pick.coverageDungeons or 0
            GameTooltip:AddLine(
                "This Season \194\183 " .. timedCount .. " / " .. pool .. " Timed",
                0.85, 0.85, 0.85)

            -- Sort: timed first (highest key first), then untimed in
            -- the order Blizzard returns them (matches the dungeon
            -- journal, so the eye lands where the player expects).
            local timed, untimed = {}, {}
            for _, name in ipairs(seasonDungeons) do
                local d = extras.dungeonBests[name]
                if d and d.timed then timed[#timed + 1] = { name = name, info = d }
                else                    untimed[#untimed + 1] = name
                end
            end
            table.sort(timed, function(a, b)
                if a.info.bestKey ~= b.info.bestKey then return a.info.bestKey > b.info.bestKey end
                return a.info.bestScore > b.info.bestScore
            end)

            for _, t in ipairs(timed) do
                local _, dr, dg, db_ = MPI.GetScoreTier(t.info.bestScore)
                GameTooltip:AddDoubleLine(
                    "  " .. t.name,
                    "+" .. t.info.bestKey .. "  \194\183  " .. t.info.bestScore,
                    0.85, 0.85, 0.85,
                    dr or 1, dg or 1, db_ or 1)
            end
            for _, name in ipairs(untimed) do
                GameTooltip:AddDoubleLine(
                    "  " .. name,
                    "Not Yet",
                    0.45, 0.45, 0.45,
                    0.45, 0.45, 0.45)
            end
        end

        -- ── How You Play (avg dim scores) ──────────────────────────
        local dims = {
            { label = "Awareness",   v = pick.avgAwareness   or 0 },
            { label = "Survival",    v = pick.avgSurvival    or 0 },
            { label = "Output",      v = pick.avgOutput      or 0 },
            { label = "Utility",     v = pick.avgUtility     or 0 },
            { label = "Consistency", v = pick.avgConsistency or 0 },
        }
        local sum = 0
        for _, d in ipairs(dims) do sum = sum + d.v end
        if sum > 0 then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("How You Play (Avg Per Run)", 0.85, 0.85, 0.85)
            for _, d in ipairs(dims) do
                local _, dr, dg, db_ = MPI.GetScoreTier(d.v)
                GameTooltip:AddDoubleLine("  " .. d.label,
                    string.format("%d", floor(d.v + 0.5)),
                    0.70, 0.70, 0.70, dr or 1, dg or 1, db_ or 1)
            end
        end

        -- ── Footer ─────────────────────────────────────────────────
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Switch spec to see your other roles' scores.", 0.55, 0.55, 0.55)
        GameTooltip:AddLine("Detailed breakdown at mpi.atyzi.com/dashboard",
            0.55, 0.75, 0.95, true)

        GameTooltip:Show()
    end)
    pill:SetScript("OnLeave", function() GameTooltip:Hide() end)
end

function MPI.UpdateDungeonHeroDashboard(R)
    if type(R) ~= "table" then return end
    _applyMpiToPill(R.dungeonHeroMPIPill)
end



local function CreateMPIPill(parent)
    local pill = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    pill:SetSize(110, 28)
    pill:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    pill:SetBackdropColor(0.06, 0.06, 0.08, 0.75)
    pill:SetBackdropBorderColor(0.20, 0.20, 0.25, 0.5)

    local dot = pill:CreateTexture(nil, "OVERLAY", nil, 2)
    dot:SetSize(3, 18)
    dot:SetPoint("LEFT", pill, "LEFT", 3, 0)
    dot:SetColorTexture(1, 1, 1)
    pill._dot = dot

    local label = pill:CreateFontString(nil, "OVERLAY")
    TrySetFont(label, BODY_FONT, 9, "OUTLINE")
    label:SetPoint("LEFT", dot, "RIGHT", 6, 0)
    label:SetTextColor(0.55, 0.55, 0.55)
    label:SetText("MPI")
    pill._label = label

    local value = pill:CreateFontString(nil, "OVERLAY")
    TrySetFont(value, TITLE_FONT, 11, "OUTLINE")
    value:SetPoint("LEFT", label, "RIGHT", 4, 0)
    value:SetPoint("RIGHT", pill, "RIGHT", -8, 0)
    value:SetJustifyH("RIGHT")
    pill._value = value

    return pill
end

function MPI.UpdatePremadeHeroPill(R)
    if type(R) ~= "table" then return end
    _applyMpiToPill(R.premadeHeroMPIPill)
end



local function ShowMPITooltip(frame, guid, prof, dungeonMapID)
    if not prof then return end
    GameTooltip:SetOwner(frame, "ANCHOR_RIGHT")
    GameTooltip:ClearLines()

    local cr, cg, cb = GetClassColor(prof.class)
    GameTooltip:AddLine(prof.name or "Unknown", cr, cg, cb)

    local score = prof.avgMPI or 0
    local tier, r, g, b = MPI.GetScoreTier(score)
    local trendArrow = prof.trend == "up" and " +" or prof.trend == "down" and " -" or ""
    local roleLabel = prof.primaryRole == "TANK" and "Tank" or prof.primaryRole == "HEALER" and "Healer" or "DPS"
    GameTooltip:AddLine("MPI: " .. score .. " (" .. tier .. ")" .. trendArrow .. "  as " .. roleLabel, r, g, b)

    GameTooltip:AddLine("Measures how they play, not just what keys they've completed.", 0.55, 0.55, 0.55)

    local source = prof.source or "network"
    if source == "local" then
        local runCount = prof.runsTracked or 0
        GameTooltip:AddLine("Verified: you ran " .. runCount .. " key" .. (runCount ~= 1 and "s" or "") .. " together", 0.35, 0.88, 0.45)
    elseif source == "guild" then
        GameTooltip:AddLine("Verified: observed by your guild", 0.70, 0.85, 1.00)
    elseif source == "network-observed" then
        local obsCount = prof._observerCount or 1
        GameTooltip:AddLine("Verified: observed by " .. obsCount .. " MidnightUI player" .. (obsCount ~= 1 and "s" or ""), 0.80, 0.80, 0.80)
    else
        GameTooltip:AddLine("Unverified: self-reported by player", 0.50, 0.50, 0.50)
    end

    local awr = prof.awarenessScore or 0
    local srv = prof.survivalScore or 0
    local out = prof.outputScore or 0
    local utl = prof.utilityScore or 0
    local con = prof.consistencyScore or 0
    if awr + srv + out + utl + con > 0 then
        local function dc(v)
            if v >= 75 then return "59e073"
            elseif v >= 50 then return "f2cc33"
            elseif v >= 25 then return "ff8c42"
            else return "ff5959" end
        end
        local dimLine = format(
            "|cff%sAWR %d|r  |cff%sSRV %d|r  |cff%sOUT %d|r  |cff%sUTL %d|r  |cff%sCON %d|r",
            dc(awr), awr, dc(srv), srv, dc(out), out, dc(utl), utl, dc(con), con
        )
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(dimLine)
    end

    if prof._discrepancy and prof._claimedScore then
        GameTooltip:AddLine("Score discrepancy: claims " .. prof._claimedScore .. ", observed avg " .. score, 1.0, 0.55, 0.20)
    end

    if prof.byRole then
        local roleCount = 0
        for _ in pairs(prof.byRole) do roleCount = roleCount + 1 end
        if roleCount > 1 then
            for roleName, roleData in pairs(prof.byRole) do
                if roleData.runs and roleData.runs > 0 then
                    local rTier, rr, rg, rb = MPI.GetScoreTier(roleData.avg or 0)
                    local rLabel = roleName == "TANK" and "Tank" or roleName == "HEALER" and "Healer" or "DPS"
                    GameTooltip:AddLine("  " .. rLabel .. ": " .. (roleData.avg or 0) .. " (" .. rTier .. ") — " .. roleData.runs .. " runs", rr, rg, rb)
                end
            end
        end
    end

    if dungeonMapID and prof.dungeonMastery and prof.dungeonMastery[dungeonMapID] then
        local dm = prof.dungeonMastery[dungeonMapID]
        local ml = MASTERY_LEVELS[dm.level]
        if ml then
            GameTooltip:AddLine("This dungeon: " .. ml.label, 0.7, 0.7, 0.7)
        end
    end

    if prof.peakLevel and prof.peakLevel ~= "" then
        GameTooltip:AddLine("Peaks at " .. prof.peakLevel, 0.7, 0.7, 0.7)
    end

    -- Badge display: grouped by rarity tier, color-coded so elite badges pop.
    if prof.badges and #prof.badges > 0 then
        GameTooltip:AddLine(" ")
        local groups = MPI.GroupBadgesByRarity(prof.badges)
        local tierOrder = { "mythic", "elite", "prestige", "standard" }
        for _, rarity in ipairs(tierOrder) do
            local list = groups[rarity]
            if #list > 0 then
                local lineParts = {}
                for _, bid in ipairs(list) do
                    lineParts[#lineParts + 1] = MPI.FormatBadge(bid, true)
                end
                local header = BADGE_RARITY_LABELS[rarity] or rarity
                GameTooltip:AddLine(header .. ":  " .. table.concat(lineParts, "  "),
                    0.9, 0.9, 0.9, true)
            end
        end
    end

    local alerts = MPI.GenerateSmartAlerts(prof, dungeonMapID)
    for _, alert in ipairs(alerts) do
        local ar, ag, ab = 0.7, 0.7, 0.7
        if alert.severity == "warning" then ar, ag, ab = 1.0, 0.55, 0.20
        elseif alert.severity == "positive" then ar, ag, ab = 0.35, 0.88, 0.45 end
        GameTooltip:AddLine(alert.text, ar, ag, ab, true)
    end

    local total = prof.runsTracked or 0
    local abandoned = prof.abandonedRuns or 0
    if total >= 3 then
        local completionPct = floor((total - abandoned) / total * 100)
        GameTooltip:AddLine(total .. " runs tracked | " .. completionPct .. "% completed", 0.5, 0.5, 0.5)
    end

    if prof.tags and #prof.tags > 0 then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Your tags: " .. table.concat(prof.tags, ", "), 0.70, 0.85, 1.00, true)
    end

    if prof.note and prof.note ~= "" then
        if not prof.tags or #prof.tags == 0 then GameTooltip:AddLine(" ") end
        GameTooltip:AddLine("Note: " .. prof.note, 0.85, 0.85, 0.60, true)
    end

    GameTooltip:Show()
end

local function ApplyTrustVisual(dot, source, r, g, b)
    if not dot then return end
    if source == "local" or source == "guild" or source == "network-observed" then
        dot._tex:SetColorTexture(r, g, b, 0.9)
        dot._tex:Show()
        dot._border:Hide()
        dot:SetAlpha(1.0)
    else
        dot._tex:SetColorTexture(r, g, b, 0.15)
        dot._tex:Show()
        dot._border:SetColorTexture(r, g, b, 0.6)
        dot._border:Show()
        dot:SetAlpha(0.5)
    end
end

local function ApplyTitleMPISuffix(row, info, prof, score, r, g, b)
    -- Only applies to rows in the Blizzard search result list (those
    -- have a .Name FontString). Applicant rows pass a stub info table
    -- with no .name and are skipped. Always rebuild from info.name so
    -- recycled rows never show a stale prefix. No tag is added when
    -- there is no profile for the leader.
    if not row or not row.Name or not info or not info.name then return end
    if not prof then
        row.Name:SetText(info.name)
        return
    end
    local tagText = "[MPI " .. tostring(score or 0) .. "]"
    local tagColor = string.format("|cff%02x%02x%02x",
        math.floor((r or 1) * 255 + 0.5),
        math.floor((g or 1) * 255 + 0.5),
        math.floor((b or 1) * 255 + 0.5))
    row.Name:SetText(tagColor .. tagText .. "|r  " .. info.name)
end

function MPI.UpdateListingRowMPI(row, info)
    if not db or not db.settings.enabled or not db.settings.showInRows then return end
    if not row or not info then return end

    if not row._mpiDot then
        local dot = CreateFrame("Frame", nil, row)
        dot:SetSize(10, 10)
        dot:SetPoint("TOPRIGHT", row, "TOPRIGHT", -8, -8)
        dot:SetFrameLevel(row:GetFrameLevel() + 3)

        local tex = dot:CreateTexture(nil, "OVERLAY")
        tex:SetAllPoints()
        tex:SetColorTexture(1, 1, 1)
        dot._tex = tex

        local border = dot:CreateTexture(nil, "BORDER")
        border:SetAllPoints()
        border:SetColorTexture(1, 1, 1, 0.5)
        dot._border = border
        border:Hide()

        dot:EnableMouse(true)
        dot:SetScript("OnEnter", function(self)
            if self._guid and self._prof then
                ShowMPITooltip(self, self._guid, self._prof, self._dungeonMapID)
            end
        end)
        dot:SetScript("OnLeave", function() GameTooltip:Hide() end)

        row._mpiDot = dot
    end

    local dot = row._mpiDot

    local leaderName = info.leaderName
    if not leaderName then
        dot:Hide()
        ApplyTitleMPISuffix(row, info, nil)
        return
    end

    local guid, prof = MPI.LookupByName(leaderName)
    if not prof then
        dot:Hide()
        ApplyTitleMPISuffix(row, info, nil)
        return
    end

    local score = prof.avgMPI or 0
    local _, r, g, b = MPI.GetScoreTier(score)
    dot._guid = guid
    dot._prof = prof
    dot._dungeonMapID = nil

    ApplyTrustVisual(dot, prof.source or "network", r, g, b)
    dot:Show()
    ApplyTitleMPISuffix(row, info, prof, score, r, g, b)
end



local sidebarRefs = {}

local function CreateGlassPanelMPI(parent, w, h)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    panel:SetSize(w, h)
    panel:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    panel:SetBackdropColor(0.06, 0.06, 0.08, 0.75)
    panel:SetBackdropBorderColor(0.20, 0.20, 0.25, 0.5)

    local frost = panel:CreateTexture(nil, "ARTWORK")
    frost:SetHeight(1)
    frost:SetPoint("TOPLEFT", panel, "TOPLEFT", 1, -1)
    frost:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -1, -1)
    frost:SetColorTexture(0.4, 0.4, 0.5, 0.15)

    return panel
end

function MPI.BuildSidebarSection(sidebarFrame)
    if MPI._sidebarBuilt or not sidebarFrame then return end

    local panel = CreateGlassPanelMPI(sidebarFrame, 228, 140)
    panel:SetPoint("BOTTOMLEFT", sidebarFrame, "BOTTOMLEFT", 16, 16)
    panel:SetPoint("BOTTOMRIGHT", sidebarFrame, "BOTTOMRIGHT", -16, 16)

    local header = panel:CreateFontString(nil, "OVERLAY")
    TrySetFont(header, BODY_FONT, 10, "")
    header:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -8)
    header:SetText("TEAM MPI")
    header:SetTextColor(0.55, 0.55, 0.55)
    sidebarRefs.header = header

    local summary = panel:CreateFontString(nil, "OVERLAY")
    TrySetFont(summary, BODY_FONT, 9, "")
    summary:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -10, -8)
    summary:SetTextColor(0.5, 0.5, 0.5)
    sidebarRefs.summary = summary

    sidebarRefs.rows = {}
    for i = 1, 5 do
        local row = CreateFrame("Frame", nil, panel)
        row:SetSize(208, 18)
        row:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -22 - (i - 1) * 20)

        local nameFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(nameFs, BODY_FONT, 10, "")
        nameFs:SetPoint("LEFT", row, "LEFT", 0, 0)
        nameFs:SetWidth(130)
        nameFs:SetJustifyH("LEFT")
        nameFs:SetWordWrap(false)
        row._name = nameFs

        local scoreFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(scoreFs, TITLE_FONT, 10, "")
        scoreFs:SetPoint("RIGHT", row, "RIGHT", -20, 0)
        row._score = scoreFs

        local trendFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(trendFs, TITLE_FONT, 10, "")
        trendFs:SetPoint("LEFT", scoreFs, "RIGHT", 4, 0)
        row._trend = trendFs

        sidebarRefs.rows[i] = row
    end

    local alertFs = panel:CreateFontString(nil, "OVERLAY")
    TrySetFont(alertFs, BODY_FONT, 9, "")
    alertFs:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 10, 6)
    alertFs:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -10, 6)
    alertFs:SetTextColor(0.7, 0.6, 0.3)
    alertFs:SetWordWrap(true)
    alertFs:SetJustifyH("LEFT")
    sidebarRefs.alertText = alertFs

    sidebarRefs.panel = panel
    MPI._sidebarBuilt = true
end

function MPI.UpdateSidebarTeamMPI(sidebarFrame, selectedResultID)
    if not db or not db.settings.enabled or not db.settings.showInSidebar then return end

    if not MPI._sidebarBuilt then
        MPI.BuildSidebarSection(sidebarFrame)
    end
    if not sidebarRefs.panel then return end

    if not selectedResultID then
        sidebarRefs.panel:Hide()
        return
    end

    local ok, info = pcall(C_LFGList.GetSearchResultInfo, selectedResultID)
    if not ok or not info then
        sidebarRefs.panel:Hide()
        return
    end

    local numMembers = info.numMembers or 0
    local knownCount, totalScore, rowIdx = 0, 0, 0
    local hasKicker = false
    local groupAlerts = {}

    for mi = 1, min(numMembers, 5) do
        local mOk, mRole, mClass, mClassFile, mName = pcall(C_LFGList.GetSearchResultMemberInfo, selectedResultID, mi)
        if mOk and mName then
            rowIdx = rowIdx + 1
            local row = sidebarRefs.rows[rowIdx]
            if row then
                local guid, prof = MPI.LookupByName(mName)
                local cr, cg, cb = GetClassColor(mClassFile)

                row._name:SetText(mName)
                row._name:SetTextColor(cr, cg, cb)

                if prof then
                    knownCount = knownCount + 1
                    local score = prof.avgMPI or 0
                    totalScore = totalScore + score
                    row._score:SetText(tostring(score))
                    -- Colour by confidence tier, not score magnitude:
                    -- Provisional = grey, Emerging+/Confirmed/Trusted = white.
                    -- Lets players see every MPI score but distinguishes
                    -- single-observer data at a glance.
                    if (prof._tier or "provisional") == "provisional" then
                        row._score:SetTextColor(0.55, 0.55, 0.55)
                    else
                        row._score:SetTextColor(1, 1, 1)
                    end

                    local trend = prof.trend or "stable"
                    if trend == "up" then
                        row._trend:SetText("+")
                        row._trend:SetTextColor(0.35, 0.88, 0.45)
                    elseif trend == "down" then
                        row._trend:SetText("-")
                        row._trend:SetTextColor(1.0, 0.35, 0.35)
                    else
                        row._trend:SetText("")
                    end

                    if prof.badges then
                        for _, bid in ipairs(prof.badges) do
                            if bid == BADGE_DEFS.ACTIVE_KICKER.id then hasKicker = true end
                        end
                    end
                else
                    row._score:SetText("?")
                    row._score:SetTextColor(0.4, 0.4, 0.4)
                    row._trend:SetText("")
                end

                row:Show()
            end
        end
    end

    for i = rowIdx + 1, 5 do
        if sidebarRefs.rows[i] then sidebarRefs.rows[i]:Hide() end
    end

    if knownCount > 0 then
        local avgTeam = floor(totalScore / knownCount + 0.5)
        local _, tr, tg, tb = MPI.GetScoreTier(avgTeam)
        local teamTier = MPI.GetScoreTier(avgTeam)
        sidebarRefs.summary:SetText(knownCount .. "/" .. numMembers .. " known — " .. teamTier .. " avg")
        sidebarRefs.summary:SetTextColor(tr, tg, tb)
    else
        sidebarRefs.summary:SetText(numMembers .. " members — no MPI data")
        sidebarRefs.summary:SetTextColor(0.45, 0.45, 0.45)
    end

    if knownCount > 0 and not hasKicker then
        groupAlerts[#groupAlerts + 1] = "No known interrupter"
    end

    if #groupAlerts > 0 then
        sidebarRefs.alertText:SetText(table.concat(groupAlerts, " | "))
        sidebarRefs.alertText:Show()
    else
        sidebarRefs.alertText:SetText("")
    end

    local panelH = 28 + (rowIdx * 20) + (#groupAlerts > 0 and 18 or 0)
    sidebarRefs.panel:SetHeight(max(60, panelH))
    sidebarRefs.panel:Show()
end



function MPI.AddTag(guid, tag)
    if not guid or not tag or tag == "" then return end
    if not db or not db.profiles[guid] then return end
    if not db.profiles[guid].tags then db.profiles[guid].tags = {} end
    for _, existing in ipairs(db.profiles[guid].tags) do
        if existing == tag then return end
    end
    table.insert(db.profiles[guid].tags, 1, tag)
    while #db.profiles[guid].tags > 5 do
        table.remove(db.profiles[guid].tags)
    end
end

function MPI.RemoveTag(guid, tag)
    if not guid or not tag then return end
    if not db or not db.profiles[guid] or not db.profiles[guid].tags then return end
    for i = #db.profiles[guid].tags, 1, -1 do
        if db.profiles[guid].tags[i] == tag then
            table.remove(db.profiles[guid].tags, i)
            return
        end
    end
end

function MPI.SetNote(guid, text)
    if not guid or not db or not db.profiles[guid] then return end
    db.profiles[guid].note = text or ""
end

local function OnSlashCommand(msg)
    if not db then
        print("|cff00ccffMPI:|r Not initialized yet.")
        return
    end

    msg = (msg or ""):lower():trim()

    if msg == "" then
        local p = db.personal
        local myGUID = UnitGUID("player")
        local myProf = myGUID and db.profiles[myGUID]
        local score = p.avgMPI or 0
        local tier, r, g, b = MPI.GetScoreTier(score)

        print("|cff00ccffMPI Rating:|r " .. score .. " (" .. tier .. ")")
        print("|cff888888MPI scores how you play (mechanics, survival, output, kicks, consistency)|r")
        print("|cff888888not just what keys you've completed. Dimension breakdown:|r")
        local function dimStr(label, val)
            if val >= 75 then return "|cff59e073" .. label .. " " .. val .. "|r"
            elseif val >= 50 then return "|cfff2cc33" .. label .. " " .. val .. "|r"
            elseif val >= 25 then return "|cffff8c42" .. label .. " " .. val .. "|r"
            else return "|cffff5959" .. label .. " " .. val .. "|r" end
        end
        local myGUID0 = UnitGUID("player")
        local myProf0 = myGUID0 and db.profiles[myGUID0]
        if myProf0 then
            print("  " .. dimStr("AWR", myProf0.awarenessScore or 0) .. "  " .. dimStr("SRV", myProf0.survivalScore or 0) .. "  " .. dimStr("OUT", myProf0.outputScore or 0) .. "  " .. dimStr("UTL", myProf0.utilityScore or 0) .. "  " .. dimStr("CON", myProf0.consistencyScore or 0))
        end
        print("|cff00ccffRuns:|r " .. (p.totalRuns or 0) .. " total | " .. (p.timedRuns or 0) .. " timed")
        print("|cff00ccffThis week:|r " .. (p.weeklyRuns or 0) .. " runs | Avg " .. (p.weeklyAvgMPI or 0))
        if myProf then
            print("|cff00ccffTrend:|r " .. (myProf.trend or "stable") .. " | Best: " .. (myProf.bestMPI or 0))
            if myProf.peakLevel and myProf.peakLevel ~= "" then
                print("|cff00ccffPeak level:|r " .. myProf.peakLevel)
            end
            if myProf.byRole then
                for roleName, roleData in pairs(myProf.byRole) do
                    if roleData.runs and roleData.runs > 0 then
                        local rLabel = roleName == "TANK" and "Tank" or roleName == "HEALER" and "Healer" or "DPS"
                        print("|cff00ccff  " .. rLabel .. ":|r " .. (roleData.avg or 0) .. " avg (" .. roleData.runs .. " runs)")
                    end
                end
            end
        end
        local profCount = 0
        for _ in pairs(db.profiles) do profCount = profCount + 1 end
        print("|cff00ccffProfiles:|r " .. profCount .. " players tracked")

    elseif msg == "toggle" then
        db.settings.enabled = not db.settings.enabled
        print("|cff00ccffMPI:|r " .. (db.settings.enabled and "Enabled" or "Disabled"))

    elseif msg == "reset" then
        print("|cff00ccffMPI:|r Type |cffff8800/mpi reset confirm|r to delete all MPI data.")

    elseif msg == "reset confirm" then
        _G.MidnightUI_MPI = nil
        MPI.EnsureDB()
        print("|cff00ccffMPI:|r All data cleared.")

    elseif msg == "trace" or msg:sub(1, 6) == "trace " then
        local n = tonumber(msg:sub(7)) or 30
        if n < 1 then n = 1 end
        if n > 300 then n = 300 end
        local buf = MPI._trace or {}
        if #buf == 0 then
            print("|cff00ccffMPI trace:|r empty — no debug entries captured yet.")
        else
            local startIdx = max(1, #buf - n + 1)
            print(string.format("|cff00ccffMPI trace:|r last %d of %d entries", min(n, #buf), #buf))
            for i = startIdx, #buf do
                print("  " .. buf[i])
            end
        end

    elseif msg == "status" then
        local keyActive = MPI._activeRun and true or false
        local cleuArmed = eventFrame and eventFrame:IsEventRegistered("COMBAT_LOG_EVENT_UNFILTERED") or false
        local liveOK = MPI._liveCombat and true or false
        local cStatus = (MPI.GetCompanionStatus and MPI.GetCompanionStatus()) or "missing"
        local fileLogOn  = (type(LoggingCombat) == "function") and (LoggingCombat() and true or false) or false
        local fileLogOwn = MPI._combatLogOwnedByUs and true or false
        local autoCfg    = not (db and db.settings and db.settings.combatLogAutoForKeys == false)
        print("|cff00ccffMPI status:|r")
        print(string.format("  API ready: %s", MPI._apiAvailable and "yes" or "no"))
        print(string.format("  Active key: %s%s",
            keyActive and "yes" or "no",
            keyActive and string.format(" (+%d %s)", MPI._activeRun.keyLevel or 0, MPI._activeRun.dungeonName or "?") or ""))
        print(string.format("  In-addon CLEU: %s", cleuArmed and "armed" or "|cffff6666OFF (blocked by taint)|r"))
        print(string.format("  File combat log: %s%s (auto-toggle %s)",
            fileLogOn and "|cff59e073ON|r" or "off",
            fileLogOwn and " [MPI-owned]" or "",
            autoCfg and "enabled" or "disabled"))
        print(string.format("  Live state: %s", liveOK and "ready" or "idle (no active key)"))
        print(string.format("  Companion: %s", cStatus))
        print(string.format("  Trace buffer: %d entries", #(MPI._trace or {})))
        MPI.DebugLog(string.format("status: keyActive=%s cleuArmed=%s liveOK=%s fileLog=%s owned=%s companion=%s",
            tostring(keyActive), tostring(cleuArmed), tostring(liveOK),
            tostring(fileLogOn), tostring(fileLogOwn), cStatus))

    elseif msg == "combatlog" or msg == "combatlog on" or msg == "combatlog off" or msg == "combatlog toggle" then
        if not db or not db.settings then
            print("|cff00ccffMPI:|r settings not initialised yet — reload and try again.")
            return
        end
        local current = db.settings.combatLogAutoForKeys
        if current == nil then current = true end
        local newVal
        if msg == "combatlog on" then
            newVal = true
        elseif msg == "combatlog off" then
            newVal = false
        elseif msg == "combatlog toggle" then
            newVal = not current
        else
            -- status query
            print(string.format("|cff00ccffMPI combatlog:|r auto-toggle is currently %s.",
                current and "|cff59e073ON|r" or "|cffff6666OFF|r"))
            print("  /mpi combatlog on    -- auto-enable /combatlog at key start")
            print("  /mpi combatlog off   -- leave /combatlog alone (you can still turn it on manually)")
            print("  /mpi combatlog toggle")
            return
        end
        db.settings.combatLogAutoForKeys = newVal
        print(string.format("|cff00ccffMPI:|r auto-combatlog for keys is now %s.",
            newVal and "|cff59e073ON|r" or "|cffff6666OFF|r"))
        if not newVal and MPI._combatLogOwnedByUs and type(LoggingCombat) == "function" then
            pcall(LoggingCombat, false)
            print("  (turned off current logging; any live key will stop writing to WoWCombatLog.txt)")
            if db and db.personal then db.personal.combatLogOwnedByUs = false end
            MPI._combatLogOwnedByUs = false
        end

    elseif msg == "harvest" then
        if not C_DamageMeter or not C_DamageMeter.GetCombatSessionFromType then
            print("|cff00ccffMPI:|r C_DamageMeter API is not available on this client.")
            return
        end
        local types = {
            { Enum.DamageMeterType.DamageDone,          "damageDone" },
            { Enum.DamageMeterType.DamageTaken,          "damageTaken" },
            { Enum.DamageMeterType.AvoidableDamageTaken, "avoidableDamage" },
            { Enum.DamageMeterType.HealingDone,          "healingDone" },
            { Enum.DamageMeterType.Interrupts,           "interrupts" },
            { Enum.DamageMeterType.Dispels,              "dispels" },
            { Enum.DamageMeterType.Deaths,               "deaths" },
        }
        local perPlayer = {}
        for _, t in ipairs(types) do
            local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType,
                Enum.DamageMeterSessionType.Overall, t[1])
            if ok and session and session.combatSources then
                for _, source in ipairs(session.combatSources) do
                    if source.name and source.totalAmount then
                        if not perPlayer[source.name] then perPlayer[source.name] = {} end
                        perPlayer[source.name][t[2]] = source.totalAmount
                    end
                end
            end
        end
        local n = 0
        for _ in pairs(perPlayer) do n = n + 1 end
        print(string.format("|cff00ccffMPI harvest:|r damage-meter returned %d player sources", n))
        if n == 0 then
            print("  (C_DamageMeter has no data yet — engage a mob to populate)")
        else
            for name, stats in pairs(perPlayer) do
                print(string.format("  %s  dmg=%d  taken=%d  heal=%d  avoid=%d  int=%d  dsp=%d  deaths=%d",
                    name,
                    floor(stats.damageDone or 0), floor(stats.damageTaken or 0),
                    floor(stats.healingDone or 0), floor(stats.avoidableDamage or 0),
                    stats.interrupts or 0, stats.dispels or 0, stats.deaths or 0))
            end
        end
        MPI.DebugLog(string.format("harvest command: %d player sources from C_DamageMeter", n))

    elseif msg == "export" then
        if not MPI.BuildExportData then
            print("|cff00ccffMPI:|r BuildExportData not available.")
            return
        end
        MPI.BuildExportData()
        if db and db.export then
            print(string.format("|cff00ccffMPI export:|r observerName=%s profiles=%d runs=%d exportedAt=%s",
                tostring(db.export.observerName),
                db.export.profileCount or 0,
                #(db.export.runHistory or {}),
                date("%H:%M:%S", db.export.exportedAt or 0)))
            print("  /reload or /logout to flush this to disk for the Companion to pick up.")
        else
            print("|cff00ccffMPI:|r No export cache was written (db missing).")
        end

    elseif msg:sub(1, 4) == "tag " then
        local rest = msg:sub(5)
        local playerName, tag = rest:match("^(%S+)%s+(.+)$")
        if playerName and tag then
            local guid = db._nameIndex[playerName]
            if not guid then
                for name, g in pairs(db._nameIndex) do
                    if name:lower() == playerName:lower() then guid = g; break end
                end
            end
            if guid then
                MPI.AddTag(guid, tag)
                print("|cff00ccffMPI:|r Tagged " .. playerName .. " with: " .. tag)
            else
                print("|cff00ccffMPI:|r Player '" .. playerName .. "' not found in your MPI data.")
            end
        else
            print("|cff00ccffUsage:|r /mpi tag PlayerName your tag text")
        end

    else
        print("|cff00ccffMPI Commands:|r")
        print("  /mpi : Show your MPI stats and dimension breakdown")
        print("  /mpi toggle : Enable or disable MPI")
        print("  /mpi reset : Clear all MPI data")
        print("  /mpi tag Name text : Add a personal tag to a player")
        print("  /mpi status : Show tracking state (API, combat capture, companion)")
        print("  /mpi trace [N] : Print the last N (default 30) debug entries")
        print("  /mpi combatlog : Show the auto-combatlog setting; `on` | `off` | `toggle` to change")
        print("  /mpi harvest : Force a damage-meter read and print per-player stats now")
        print("  /mpi export : Rebuild the companion export cache and print a summary")
        print("|cff888888MPI measures how you play across 5 dimensions using combat data.|r")
        print("|cff888888It is not the same as IO score, which tracks key completion.|r")
    end
end

SLASH_MPI1 = "/mpi"
SlashCmdList["MPI"] = OnSlashCommand


function MPI.OnApplicantListUpdated(sidebarFrame)
    if not db or not db.settings.enabled then return end

    local hasListing = C_LFGList.HasActiveEntryInfo and SafeCall(C_LFGList.HasActiveEntryInfo)
    if not hasListing then
        if MPI._applicantPanel then MPI._applicantPanel:Hide() end
        return
    end

    local applicants = SafeCall(C_LFGList.GetApplicants)
    if not applicants then return end

    MPI.UpdateApplicantPanel(sidebarFrame, applicants)
end



local applicantRefs = {}

function MPI.BuildApplicantPanel(sidebarFrame)
    if MPI._applicantPanel or not sidebarFrame then return end

    local panel = CreateFrame("Frame", nil, sidebarFrame, "BackdropTemplate")
    panel:SetHeight(40)
    panel:SetPoint("TOPLEFT", sidebarFrame, "TOPLEFT", 16, -16)
    panel:SetPoint("TOPRIGHT", sidebarFrame, "TOPRIGHT", -16, -16)
    panel:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    panel:SetBackdropColor(0.06, 0.06, 0.08, 0.75)
    panel:SetBackdropBorderColor(0.20, 0.20, 0.25, 0.5)

    local frost = panel:CreateTexture(nil, "ARTWORK")
    frost:SetHeight(1)
    frost:SetPoint("TOPLEFT", panel, "TOPLEFT", 1, -1)
    frost:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -1, -1)
    frost:SetColorTexture(0.4, 0.4, 0.5, 0.15)

    local header = panel:CreateFontString(nil, "OVERLAY")
    TrySetFont(header, BODY_FONT, 10, "")
    header:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -8)
    header:SetText("APPLICANTS")
    header:SetTextColor(0.55, 0.55, 0.55)
    applicantRefs.header = header

    local countBadge = panel:CreateFontString(nil, "OVERLAY")
    TrySetFont(countBadge, BODY_FONT, 9, "")
    countBadge:SetPoint("LEFT", header, "RIGHT", 4, 0)
    countBadge:SetTextColor(0.7, 0.7, 0.7)
    applicantRefs.countBadge = countBadge

    applicantRefs.rows = {}
    for i = 1, 8 do
        local row = CreateFrame("Frame", nil, panel)
        row:SetSize(196, 24)
        row:SetPoint("TOPLEFT", panel, "TOPLEFT", 10, -24 - (i - 1) * 26)

        local roleIcon = row:CreateTexture(nil, "ARTWORK")
        roleIcon:SetSize(14, 14)
        roleIcon:SetPoint("LEFT", row, "LEFT", 0, 0)
        row._roleIcon = roleIcon

        local nameFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(nameFs, BODY_FONT, 10, "")
        nameFs:SetPoint("LEFT", roleIcon, "RIGHT", 4, 0)
        nameFs:SetWidth(100)
        nameFs:SetJustifyH("LEFT")
        nameFs:SetWordWrap(false)
        row._name = nameFs

        local mpiDot = CreateFrame("Frame", nil, row)
        mpiDot:SetSize(10, 10)
        mpiDot:SetPoint("LEFT", nameFs, "RIGHT", 4, 0)
        local dotTex = mpiDot:CreateTexture(nil, "OVERLAY")
        dotTex:SetAllPoints()
        dotTex:SetColorTexture(1, 1, 1)
        mpiDot._tex = dotTex

        local dotBorder = mpiDot:CreateTexture(nil, "BORDER")
        dotBorder:SetAllPoints()
        dotBorder:SetColorTexture(1, 1, 1, 0.5)
        mpiDot._border = dotBorder
        dotBorder:Hide()

        mpiDot:EnableMouse(true)
        mpiDot:SetScript("OnEnter", function(self)
            if self._guid and self._prof then
                ShowMPITooltip(self, self._guid, self._prof, self._dungeonMapID)
            end
        end)
        mpiDot:SetScript("OnLeave", function() GameTooltip:Hide() end)
        row._mpiDot = mpiDot

        local scoreFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(scoreFs, TITLE_FONT, 10, "")
        scoreFs:SetPoint("LEFT", mpiDot, "RIGHT", 4, 0)
        row._score = scoreFs

        local masteryFs = row:CreateFontString(nil, "OVERLAY")
        TrySetFont(masteryFs, BODY_FONT, 8, "")
        masteryFs:SetPoint("LEFT", scoreFs, "RIGHT", 4, 0)
        row._mastery = masteryFs

        applicantRefs.rows[i] = row
    end

    MPI._applicantPanel = panel
end

function MPI.UpdateApplicantPanel(sidebarFrame, applicants)
    if not sidebarFrame then return end

    if not MPI._applicantPanel then
        MPI.BuildApplicantPanel(sidebarFrame)
    end
    if not MPI._applicantPanel then return end

    if not applicants or #applicants == 0 then
        MPI._applicantPanel:Hide()
        return
    end

    local listedMapID = nil
    if C_ChallengeMode and C_ChallengeMode.GetActiveChallengeMapID then
        listedMapID = SafeCall(C_ChallengeMode.GetActiveChallengeMapID)
    end

    local rowIdx = 0
    for _, applicantID in ipairs(applicants) do
        local appOk, appInfo = pcall(C_LFGList.GetApplicantInfo, applicantID)
        if appOk and appInfo and appInfo.applicationStatus == "applied" then
            local numMembers = appInfo.numMembers or 1
            for mi = 1, numMembers do
                if rowIdx >= 8 then break end
                local mOk, mName, mClass, mClassLocal, mLocSpec
                    = pcall(C_LFGList.GetApplicantMemberInfo, applicantID, mi)
                if mOk and mName then
                    rowIdx = rowIdx + 1
                    local row = applicantRefs.rows[rowIdx]
                    if row then
                        local cr, cg, cb = GetClassColor(mClass)
                        row._name:SetText(mName)
                        row._name:SetTextColor(cr, cg, cb)

                        local isTank = appInfo.assignedRole == "TANK"
                        local isHealer = appInfo.assignedRole == "HEALER"
                        if row._roleIcon.SetAtlas then
                            row._roleIcon:SetAtlas(isTank and "roleicon-tank" or isHealer and "roleicon-healer" or "roleicon-dps")
                        end

                        local guid, prof = MPI.LookupByName(mName)
                        local dot = row._mpiDot
                        if prof then
                            local score = prof.avgMPI or 0
                            local _, sr, sg, sb = MPI.GetScoreTier(score)
                            ApplyTrustVisual(dot, prof.source or "network", sr, sg, sb)
                            dot._guid = guid
                            dot._prof = prof
                            dot._dungeonMapID = listedMapID
                            dot:Show()

                            row._score:SetText(tostring(score))
                            -- Provisional in grey, Emerging+/Confirmed/Trusted in white.
                            if (prof._tier or "provisional") == "provisional" then
                                row._score:SetTextColor(0.55, 0.55, 0.55)
                            else
                                row._score:SetTextColor(1, 1, 1)
                            end

                            if listedMapID and prof.dungeonMastery and prof.dungeonMastery[listedMapID] then
                                local ml = MASTERY_LEVELS[prof.dungeonMastery[listedMapID].level]
                                if ml then
                                    row._mastery:SetText(ml.label)
                                    row._mastery:SetTextColor(0.6, 0.6, 0.6)
                                    row._mastery:Show()
                                else
                                    row._mastery:Hide()
                                end
                            else
                                row._mastery:Hide()
                            end
                        else
                            dot:Hide()
                            row._score:SetText("")
                            row._mastery:Hide()
                        end

                        row:Show()
                    end
                end
            end
        end
        if rowIdx >= 8 then break end
    end

    for i = rowIdx + 1, 8 do
        if applicantRefs.rows[i] then applicantRefs.rows[i]:Hide() end
    end

    applicantRefs.countBadge:SetText("(" .. rowIdx .. ")")

    local panelH = 28 + (rowIdx * 26)
    MPI._applicantPanel:SetHeight(max(40, panelH))
    MPI._applicantPanel:Show()
end



local COMPANION_DOWNLOAD_URL = "https://mpi.atyzi.com"
local COMPANION_STALE_SEC    = 172800

function MPI.GetCompanionStatus()
    local sync = _G.MidnightUI_MPI_Sync
    if not sync or not sync.lastSync then return "missing" end
    local age = time() - (sync.lastSync or 0)
    if age > COMPANION_STALE_SEC then return "stale" end
    return "active"
end

local function ShowCompanionURLDialog()
    if type(_G.MidnightUI_OpenURL) == "function" then
        _G.MidnightUI_OpenURL(COMPANION_DOWNLOAD_URL)
    end
end

local companionBannerRef = nil

function MPI.BuildCompanionBanner(parentFrame)
    if companionBannerRef or not parentFrame then return end

    local banner = CreateFrame("Frame", nil, parentFrame, "BackdropTemplate")
    banner:SetHeight(42)
    banner:SetPoint("BOTTOMLEFT", parentFrame, "BOTTOMLEFT", 0, 0)
    banner:SetPoint("BOTTOMRIGHT", parentFrame, "BOTTOMRIGHT", 0, 0)
    banner:SetFrameLevel(parentFrame:GetFrameLevel() + 10)
    banner:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    banner:SetBackdropColor(0.35, 0.08, 0.08, 0.90)
    banner:SetBackdropBorderColor(0.60, 0.15, 0.15, 0.70)

    local accent = banner:CreateTexture(nil, "ARTWORK")
    accent:SetHeight(1)
    accent:SetPoint("TOPLEFT", banner, "TOPLEFT", 1, -1)
    accent:SetPoint("TOPRIGHT", banner, "TOPRIGHT", -1, -1)
    accent:SetColorTexture(0.80, 0.25, 0.25, 0.40)

    local closeBtn = CreateFrame("Button", nil, banner)
    closeBtn:SetSize(16, 16)
    closeBtn:SetPoint("LEFT", banner, "LEFT", 10, 0)

    local closeText = closeBtn:CreateFontString(nil, "OVERLAY")
    TrySetFont(closeText, TITLE_FONT, 14, "OUTLINE")
    closeText:SetPoint("CENTER")
    closeText:SetText("x")
    closeText:SetTextColor(1.0, 0.35, 0.35)

    closeBtn:SetScript("OnClick", function()
        if db and db.settings then
            db.settings.companionBannerDismissed = true
        end
        banner:Hide()
    end)
    closeBtn:SetScript("OnEnter", function() closeText:SetTextColor(1.0, 0.60, 0.60) end)
    closeBtn:SetScript("OnLeave", function() closeText:SetTextColor(1.0, 0.35, 0.35) end)

    local msg = banner:CreateFontString(nil, "OVERLAY")
    TrySetFont(msg, BODY_FONT, 10, "")
    msg:SetPoint("LEFT", closeBtn, "RIGHT", 8, 0)
    msg:SetPoint("RIGHT", banner, "RIGHT", -105, 0)
    msg:SetTextColor(0.90, 0.75, 0.75)
    msg:SetWordWrap(true)
    banner._msg = msg

    local btn = CreateFrame("Button", nil, banner, "BackdropTemplate")
    btn:SetSize(90, 20)
    btn:SetPoint("RIGHT", banner, "RIGHT", -8, 0)
    btn:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    btn:SetBackdropColor(0.15, 0.15, 0.17, 0.90)
    btn:SetBackdropBorderColor(0.35, 0.88, 0.45, 0.50)

    local btnText = btn:CreateFontString(nil, "OVERLAY")
    TrySetFont(btnText, BODY_FONT, 9, "")
    btnText:SetPoint("CENTER")
    btnText:SetText("Get Companion")
    btnText:SetTextColor(0.35, 0.88, 0.45)

    btn:SetScript("OnClick", function() ShowCompanionURLDialog() end)
    btn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(0.25, 0.25, 0.28, 0.90)
        GameTooltip:SetOwner(self, "ANCHOR_TOP")
        GameTooltip:AddLine("MPI Companion App")
        GameTooltip:AddLine("Syncs community scores so you can see how", 0.7, 0.7, 0.7, true)
        GameTooltip:AddLine("players actually play before you invite them.", 0.7, 0.7, 0.7, true)
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0.15, 0.15, 0.17, 0.90)
        GameTooltip:Hide()
    end)

    companionBannerRef = banner
end

function MPI.UpdateCompanionBanner(parentFrame)
    if not db or not db.settings.enabled then return end

    local status = MPI.GetCompanionStatus()
    if status == "active" then
        if companionBannerRef then companionBannerRef:Hide() end
        return
    end

    if db.settings.companionBannerDismissed then
        if companionBannerRef then companionBannerRef:Hide() end
        return
    end

    if not companionBannerRef then
        MPI.BuildCompanionBanner(parentFrame)
    end
    if not companionBannerRef then return end

    if status == "missing" then
        companionBannerRef._msg:SetText("See how every applicant plays before you invite. Get the MPI Companion from mpi.atyzi.com to unlock community scores in Group Finder.")
    else
        companionBannerRef._msg:SetText("MPI Companion has not synced recently. Community scores may be outdated.")
        companionBannerRef:SetBackdropColor(0.30, 0.20, 0.05, 0.90)
        companionBannerRef:SetBackdropBorderColor(0.55, 0.40, 0.10, 0.70)
    end

    companionBannerRef:Show()
end

function MPI.BuildCompanionSettingsButton(gearBtn)
    if not gearBtn or MPI._companionBtn then return end

    local btn = CreateFrame("Button", nil, gearBtn:GetParent())
    btn:SetSize(24, 24)
    btn:SetPoint("RIGHT", gearBtn, "LEFT", -4, 0)

    local icon = btn:CreateTexture(nil, "OVERLAY")
    icon:SetAllPoints()
    icon:SetTexture("Interface\\Icons\\INV_Misc_EngGizmos_swissArmy")
    icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    icon:SetVertexColor(0.5, 0.5, 0.5, 0.70)

    local status = MPI.GetCompanionStatus()
    if status == "missing" then
        icon:SetVertexColor(1.0, 0.40, 0.40, 0.80)
    elseif status == "stale" then
        icon:SetVertexColor(0.95, 0.75, 0.20, 0.80)
    else
        icon:SetVertexColor(0.35, 0.88, 0.45, 0.80)
    end

    btn:SetScript("OnClick", function() ShowCompanionURLDialog() end)
    btn:SetScript("OnEnter", function(self)
        icon:SetVertexColor(1, 1, 1, 1)
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:AddLine("MPI Companion App")
        if status == "missing" then
            GameTooltip:AddLine("Not installed. Click to get the download link.", 1.0, 0.40, 0.40)
        elseif status == "stale" then
            GameTooltip:AddLine("Has not synced recently. Click for details.", 0.95, 0.75, 0.20)
        else
            local sync = _G.MidnightUI_MPI_Sync
            local profiles = sync and sync.profileCount or 0
            GameTooltip:AddLine("Active — " .. profiles .. " community profiles synced", 0.35, 0.88, 0.45)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function()
        local s = MPI.GetCompanionStatus()
        if s == "missing" then icon:SetVertexColor(1.0, 0.40, 0.40, 0.80)
        elseif s == "stale" then icon:SetVertexColor(0.95, 0.75, 0.20, 0.80)
        else icon:SetVertexColor(0.35, 0.88, 0.45, 0.80) end
        GameTooltip:Hide()
    end)

    MPI._companionBtn = btn
end



local EXPORT_VERSION = 3
local EXPORT_MAX_RUNS = 50

function MPI.BuildExportData()
    if not db then return end
    local myGUID = UnitGUID("player")
    if not myGUID then return end

    local regionID = 1
    if GetCurrentRegion then
        local ok, r = pcall(GetCurrentRegion)
        if ok and r then regionID = r end
    end
    local regionMap = { [1] = "us", [2] = "kr", [3] = "eu", [4] = "tw", [5] = "cn" }
    local region = regionMap[regionID] or "us"

    local myName = UnitName("player")
    local _, myRealm = UnitFullName("player")
    local observerName = myName and myRealm and (myName .. "-" .. myRealm) or myName or "Unknown"

    local observations = {}
    local count = 0
    for guid, prof in pairs(db.profiles) do
        if prof.source == "local" and guid ~= myGUID and prof.name then
            observations[prof.name] = {
                class           = prof.class or "",
                role            = prof.primaryRole or "DAMAGER",
                avgMPI          = prof.avgMPI or 0,
                awareness       = prof.awarenessScore or 0,
                survival        = prof.survivalScore or 0,
                output          = prof.outputScore or 0,
                utility         = prof.utilityScore or 0,
                consistency     = prof.consistencyScore or 0,
                trend           = prof.trend or "stable",
                badges          = prof.badges or {},
                runsTracked     = prof.runsTracked or 0,
                abandonedRuns   = prof.abandonedRuns or 0,
                lastSeen        = prof.lastSeen or 0,
            }
            count = count + 1
        end
    end

    -- Index the previous export's runHistory by (timestamp, mapID, keyLevel)
    -- so we can preserve a run's "me" fields when we can't identify the owner
    -- on the current character (e.g. an alt did the run). Without this, any
    -- logout on a different character zeros the old export before the
    -- Companion can upload it, and server-side INSERT IGNORE makes the
    -- corruption permanent.
    local prevHistoryByKey = {}
    if db.export and db.export.runHistory then
        for _, prev in ipairs(db.export.runHistory) do
            local k = tostring(prev.timestamp or 0) .. ":" ..
                      tostring(prev.dungeonMapID or 0) .. ":" ..
                      tostring(prev.keyLevel or 0)
            prevHistoryByKey[k] = prev
        end
    end

    local runHistory = {}
    if db.runs then
        for i = 1, min(#db.runs, EXPORT_MAX_RUNS) do
            local run = db.runs[i]
            if not run then break end

            -- Owner identification priority:
            --   1. run.ownerGUID stamped at run completion (new runs post-fix)
            --   2. Live UnitGUID match (same-character re-export)
            --   3. Name match against run.ownerName or full/short current name
            local ownerGUID = run.ownerGUID
            local myData = ownerGUID and (run.players or {})[ownerGUID] or nil
            if not myData then
                for guid, pData in pairs(run.players or {}) do
                    if guid == myGUID then
                        ownerGUID = guid
                        myData = pData
                        break
                    end
                end
            end
            if not myData then
                local shortName = UnitName("player")
                for guid, pData in pairs(run.players or {}) do
                    local n = pData.name or ""
                    if n == observerName or n == run.ownerName or n == shortName then
                        ownerGUID = guid
                        myData = pData
                        break
                    end
                    local head = n:match("^([^%-]+)")
                    if head and shortName and head == shortName then
                        ownerGUID = guid
                        myData = pData
                        break
                    end
                end
            end

            local groupPlayers = {}
            for guid, pData in pairs(run.players or {}) do
                groupPlayers[#groupPlayers + 1] = {
                    name            = pData.name or "Unknown",
                    class           = pData.class or "",
                    role            = pData.role or "DAMAGER",
                    weightRole      = pData.weightRole or pData.role or "DAMAGER",
                    specIcon        = pData.specIcon or 0,
                    damageDone      = pData.damageDone or 0,
                    damageTaken     = pData.damageTaken or 0,
                    avoidableDamage = pData.avoidableDamage or 0,
                    healingDone     = pData.healingDone or 0,
                    dispels         = pData.dispels or 0,
                    interrupts      = pData.interrupts or 0,
                    deaths          = pData.deaths or 0,
                    mpiScore        = pData.mpiScore or 0,
                    awarenessScore  = pData.awarenessScore or 0,
                    survivalScore   = pData.survivalScore or 0,
                    outputScore     = pData.outputScore or 0,
                    utilityScore    = pData.utilityScore or 0,
                    consistencyScore = pData.consistencyScore or 0,
                    badges          = pData.badges or {},
                    isLocalPlayer   = (ownerGUID ~= nil and guid == ownerGUID),
                }
            end

            -- Fall back to the previously-exported values for this run if we
            -- still couldn't identify the owner on this character. Prevents
            -- a cross-character logout from wiping correct "me" fields.
            local prev = prevHistoryByKey[
                tostring(run.timestamp or 0) .. ":" ..
                tostring(run.dungeonMapID or 0) .. ":" ..
                tostring(run.keyLevel or 0)
            ]

            local function pick(fromMe, prevVal, zero)
                if myData and fromMe ~= nil then return fromMe end
                if prev and prevVal ~= nil then return prevVal end
                return zero
            end

            runHistory[#runHistory + 1] = {
                timestamp    = run.timestamp or 0,
                dungeonMapID = run.dungeonMapID or 0,
                dungeonName  = run.dungeonName or "Unknown",
                keyLevel     = run.keyLevel or 0,
                affixes      = run.affixes or {},
                result       = run.result or "depleted",
                stars        = run.stars or 0,
                durationSec  = run.durationSec or 0,
                timeLimitSec = run.timeLimitSec or 0,
                wowPatch     = run.wowPatch or (prev and prev.wowPatch) or nil,
                players      = groupPlayers,
                myScore      = pick(myData and myData.mpiScore, prev and prev.myScore, 0),
                myAwareness  = pick(myData and myData.awarenessScore, prev and prev.myAwareness, 0),
                mySurvival   = pick(myData and myData.survivalScore, prev and prev.mySurvival, 0),
                myOutput     = pick(myData and myData.outputScore, prev and prev.myOutput, 0),
                myUtility    = pick(myData and myData.utilityScore, prev and prev.myUtility, 0),
                myConsistency = pick(myData and myData.consistencyScore, prev and prev.myConsistency, 0),
                myBadges     = pick(myData and myData.badges, prev and prev.myBadges, {}),
                myRole       = pick(myData and (myData.weightRole or myData.role),
                                    prev and prev.myRole, "DAMAGER"),
                myDeaths     = pick(myData and myData.deaths, prev and prev.myDeaths, 0),
                myInterrupts = pick(myData and myData.interrupts, prev and prev.myInterrupts, 0),
                myDispels    = pick(myData and myData.dispels, prev and prev.myDispels, 0),
                myDamageDone = pick(myData and myData.damageDone, prev and prev.myDamageDone, 0),
                myDamageTaken = pick(myData and myData.damageTaken, prev and prev.myDamageTaken, 0),
                myAvoidableDamage = pick(myData and myData.avoidableDamage, prev and prev.myAvoidableDamage, 0),
                myHealingDone = pick(myData and myData.healingDone, prev and prev.myHealingDone, 0),
                encounters   = run.encounters or {},
                detail       = run.detail or {},
            }
        end
    end

    db.export = {
        version      = EXPORT_VERSION,
        region       = region,
        observerName = observerName,
        exportedAt   = time(),
        profileCount = count,
        observations = observations,
        runHistory   = runHistory,
    }

    MPI.DebugLog(string.format("BuildExportData: observerName=%s profiles=%d runs=%d exportedAt=%d",
        tostring(observerName), count, #runHistory, db.export.exportedAt))
end



local MPI_DB_FORMAT   = 2   -- v2: tier bits at 62-63 (backward compatible — v1 decodes as provisional)
local RECORD_BYTES    = 8
local communityDB     = nil
local communityMeta   = nil

local CLASS_BY_INDEX = {
    [1]  = "WARRIOR",     [2]  = "PALADIN",    [3]  = "HUNTER",
    [4]  = "ROGUE",       [5]  = "PRIEST",     [6]  = "DEATHKNIGHT",
    [7]  = "SHAMAN",      [8]  = "MAGE",       [9]  = "WARLOCK",
    [10] = "MONK",        [11] = "DRUID",      [12] = "DEMONHUNTER",
    [13] = "EVOKER",
}

local ROLE_BY_INDEX = { [0] = "DAMAGER", [1] = "TANK", [2] = "HEALER" }

local BADGE_BITS = { "AK", "CR", "TP", "IM", "IW", "DE" }

-- Binary DB format v2 encodes a 2-bit confidence tier at bits 62-63.
-- Older files (v1) have those bits set to 0 which maps to "provisional" —
-- safe default until the community DB regenerates.
local TIER_BY_INDEX = { [0] = "provisional", [1] = "emerging", [2] = "confirmed", [3] = "trusted" }

local function ReadBits(data, bitOff, bitLen)
    local byteIdx = floor(bitOff / 8) + 1
    local bitShift = bitOff % 8
    local b0 = data:byte(byteIdx) or 0
    local b1 = data:byte(byteIdx + 1) or 0
    local raw = b0 + b1 * 256
    return floor(raw / (2 ^ bitShift)) % (2 ^ bitLen)
end

local function DecodeRecord(data, offset)
    if not data or #data < offset + RECORD_BYTES - 1 then return nil end

    local chunk = data:sub(offset, offset + RECORD_BYTES - 1)
    local bit = 0

    local compositeMPI  = ReadBits(chunk, bit, 7);  bit = bit + 7
    local awareness     = ReadBits(chunk, bit, 7);  bit = bit + 7
    local survival      = ReadBits(chunk, bit, 7);  bit = bit + 7
    local output        = ReadBits(chunk, bit, 7);  bit = bit + 7
    local utility       = ReadBits(chunk, bit, 7);  bit = bit + 7
    local consistency   = ReadBits(chunk, bit, 7);  bit = bit + 7
    local trendIdx      = ReadBits(chunk, bit, 2);  bit = bit + 2
    local classIdx      = ReadBits(chunk, bit, 4);  bit = bit + 4
    local roleIdx       = ReadBits(chunk, bit, 2);  bit = bit + 2
    local observerCount = ReadBits(chunk, bit, 6);  bit = bit + 6
    local badgeBits     = ReadBits(chunk, bit, 6);  bit = bit + 6
    local tierIdx       = ReadBits(chunk, bit, 2)

    local trend = trendIdx == 1 and "up" or trendIdx == 2 and "down" or "stable"

    local badges = {}
    for i = 1, #BADGE_BITS do
        if floor(badgeBits / (2 ^ (i - 1))) % 2 == 1 then
            badges[#badges + 1] = BADGE_BITS[i]
        end
    end

    return {
        avgMPI          = min(100, compositeMPI),
        awarenessScore  = min(100, awareness),
        survivalScore   = min(100, survival),
        outputScore     = min(100, output),
        utilityScore    = min(100, utility),
        consistencyScore = min(100, consistency),
        trend           = trend,
        class           = CLASS_BY_INDEX[classIdx] or "",
        primaryRole     = ROLE_BY_INDEX[roleIdx] or "DAMAGER",
        _observerCount  = observerCount,
        _tier           = TIER_BY_INDEX[tierIdx] or "provisional",
        badges          = badges,
        runsTracked     = 0,
        abandonedRuns   = 0,
        source          = "community",
    }
end

local function BinarySearchName(names, target)
    if not names or #names == 0 or not target then return nil end
    local low, high = 1, #names
    local targetLower = target:lower()
    while low <= high do
        local mid = floor((low + high) / 2)
        local cmp = names[mid]:lower()
        if cmp == targetLower then
            return mid
        elseif cmp < targetLower then
            low = mid + 1
        else
            high = mid - 1
        end
    end
    return nil
end

-- ============================================================
-- SECTION: Community Database + Export
-- READS: _G.MidnightUI_MPI_DB (binary per-realm data),
--   _G.MidnightUI_MPI_DBMeta (region counts + version)
-- WRITES: communityDB, communityMeta local vars
-- MPI.LoadCommunityDB() — called on ADDON_LOADED after
--   db_mpi_*.lua files are available in _G.
-- MPI.CommunityLookup(name, realm) → decoded profile or nil:
--   finds realm entry in communityDB[region], locates the
--   name in the names[] array, decodes the corresponding
--   packed binary record from the data string.
-- MPI.BuildExportData() — serializes SavedVar for the
--   MPI Companion App; written to MidnightUI_MPI.exportCache
--   on PLAYER_LOGOUT so companion can read it at next login.
-- ============================================================
function MPI.LoadCommunityDB()
    communityDB = _G.MidnightUI_MPI_DB
    communityMeta = _G.MidnightUI_MPI_DBMeta
end

function MPI.CommunityLookup(name, realm)
    if not communityDB or not name then return nil end

    local playerName, playerRealm = name:match("^([^%-]+)%-(.+)$")
    if not playerName then
        playerName = name
        playerRealm = realm
    end
    if not playerName or not playerRealm then return nil end

    local regionID = 1
    if GetCurrentRegion then
        local ok, r = pcall(GetCurrentRegion)
        if ok and r then regionID = r end
    end
    local regionMap = { [1] = "us", [2] = "kr", [3] = "eu", [4] = "tw", [5] = "cn" }
    local regionKey = regionMap[regionID] or "us"

    local regionData = communityDB[regionKey]
    if not regionData or not regionData.realms or not regionData.data then return nil end
    if regionData.data == "" then return nil end

    local realmData = regionData.realms[playerRealm]
    if not realmData or not realmData.names or #realmData.names == 0 then return nil end

    local nameIdx = BinarySearchName(realmData.names, playerName)
    if not nameIdx then return nil end

    local byteOffset = (realmData.offset or 0) + (nameIdx - 1) * RECORD_BYTES + 1
    local profile = DecodeRecord(regionData.data, byteOffset)

    if profile then
        profile.name = name
        profile.lastSeen = communityMeta and communityMeta.generated or 0
    end

    return profile
end



eventFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "ADDON_LOADED" then
        OnAddonLoaded(_, ...)
    elseif event == "PLAYER_ENTERING_WORLD" then
        OnPlayerEnteringWorld()
    elseif event == "CHALLENGE_MODE_START" then
        OnChallengeModeStart()
    elseif event == "CHALLENGE_MODE_COMPLETED" then
        C_Timer.After(2, function()
            OnChallengeModeCompleted()
        end)
    elseif event == "CHALLENGE_MODE_RESET" then
        MPI.DebugLog("=== CHALLENGE_MODE_RESET fired (key abandoned/reset) ===")
        -- Note: /combatlog stays on through a key reset. The player is
        -- almost always still inside the dungeon, often about to insert
        -- a fresh key — turning logging off here would re-trigger the
        -- "START line never written" race. PLAYER_ENTERING_WORLD turns
        -- logging off when they actually leave the instance.
        -- Drop any in-flight run state; the scoring pipeline only runs on
        -- COMPLETED, so abandoned keys never produced a run record.
        MPI._activeRun = nil
        MPI._encounterLog = {}
        MPI._leavers = {}
        MPI._rosterSnapshot = {}
        MPI._surrendered = false
        if ClearPersistedActiveRun then ClearPersistedActiveRun() end
        if StopLiveCombatTracking then StopLiveCombatTracking() end
    elseif event == "ENCOUNTER_START" then
        OnEncounterStart(...)
    elseif event == "ENCOUNTER_END" then
        OnEncounterEnd(...)
    elseif event == "GROUP_ROSTER_UPDATE" then
        OnGroupRosterUpdate()
    elseif event == "PLAYER_LOGOUT" then
        MPI.BuildExportData()
        if db then db._nameIndex = nil end
    end
end)


local blizzOverlayInstalled = false

local function UseBlizzardGroupFinder()
    local s = _G.MidnightUISettings
    return s and s.General and s.General.useBlizzardGroupFinder == true
end

local function GetScrollBoxFrames(scrollBox)
    if not scrollBox then return nil end
    if scrollBox.GetFrames then
        return { scrollBox:GetFrames() }
    elseif scrollBox.GetLayoutChildren then
        return scrollBox:GetLayoutChildren()
    end
    return nil
end

local function GetResultIDFromRow(row)
    local resultID = row.resultID
    if not resultID and row.GetElementData then
        resultID = row:GetElementData()
    end
    if type(resultID) == "table" then
        resultID = resultID.resultID or resultID[1]
    end
    return type(resultID) == "number" and resultID or nil
end

local function BlizzRow_UpdateMPI(row, resultID)
    if not resultID then
        if row._mpiDot then row._mpiDot:Hide() end
        return
    end
    local ok, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
    if not ok or not info then
        if row._mpiDot then row._mpiDot:Hide() end
        return
    end
    MPI.UpdateListingRowMPI(row, info)
end

local function BlizzApplicant_UpdateMPI(row, applicantID)
    if not applicantID then
        if row._mpiDot then row._mpiDot:Hide() end
        return
    end
    local mOk, _, _, _, mName = pcall(C_LFGList.GetApplicantMemberInfo, applicantID, 1)
    if not mOk or not mName then
        if row._mpiDot then row._mpiDot:Hide() end
        return
    end
    MPI.UpdateListingRowMPI(row, { leaderName = mName })
end

local function BlizzOverlay_RefreshVisibleRows()
    if not UseBlizzardGroupFinder() then return end
    if not db or not db.settings.enabled then return end

    local searchPanel = _G.LFGListFrame and _G.LFGListFrame.SearchPanel
    if not searchPanel then return end

    local frames = GetScrollBoxFrames(searchPanel.ScrollBox)
    if not frames then return end

    for _, row in ipairs(frames) do
        BlizzRow_UpdateMPI(row, GetResultIDFromRow(row))
    end
end

local function InstallBlizzOverlay()
    if blizzOverlayInstalled then return end
    if not UseBlizzardGroupFinder() then return end
    if not _G.LFGListFrame then return end
    blizzOverlayInstalled = true

    if type(_G.LFGListSearchEntry_Update) == "function" then
        hooksecurefunc("LFGListSearchEntry_Update", function(row)
            if not UseBlizzardGroupFinder() then return end
            local resultID = row and row.resultID
            if not resultID and row and row.GetElementData then
                local data = row:GetElementData()
                if type(data) == "number" then
                    resultID = data
                elseif type(data) == "table" then
                    resultID = data.resultID
                end
            end
            BlizzRow_UpdateMPI(row, resultID)
        end)
    end

    local searchPanel = _G.LFGListFrame.SearchPanel
    if searchPanel and searchPanel.ScrollBox and searchPanel.ScrollBox.Update then
        hooksecurefunc(searchPanel.ScrollBox, "Update", function()
            if not UseBlizzardGroupFinder() then return end
            C_Timer.After(0, BlizzOverlay_RefreshVisibleRows)
        end)
    end

    if appViewer and appViewer.ScrollBox and appViewer.ScrollBox.Update then
        log("BLIZZ_OVERLAY hooking ApplicationViewer.ScrollBox.Update")
        hooksecurefunc(appViewer.ScrollBox, "Update", function()
            if not UseBlizzardGroupFinder() then return end
            local frames = GetScrollBoxFrames(appViewer.ScrollBox)
            if not frames then return end
            for _, row in ipairs(frames) do
                local appID = row.applicantID
                if not appID and row.GetElementData then
                    appID = row:GetElementData()
                    if type(appID) == "table" then appID = appID.applicantID or appID[1] end
                end
                if type(appID) == "number" then
                    BlizzApplicant_UpdateMPI(row, appID)
                end
            end
        end)
    end

    local blizzEvf = CreateFrame("Frame")
    blizzEvf:RegisterEvent("LFG_LIST_SEARCH_RESULTS_RECEIVED")
    blizzEvf:RegisterEvent("LFG_LIST_APPLICANT_LIST_UPDATED")
    blizzEvf:SetScript("OnEvent", function()
        if not UseBlizzardGroupFinder() then return end
        C_Timer.After(0.05, BlizzOverlay_RefreshVisibleRows)
    end)

    -- Preserve Blizzard's default tooltip layout (dungeon name, playstyle,
    -- role icons, class/spec, Best for Dungeon/Run, leader's M+ score) and
    -- just splice an "MPI Score" line in right after the Leader row. We
    -- capture every line, clear, then re-emit in order -- inserting our
    -- line after the one matching the leader. If we can't find the leader
    -- line we leave Blizzard's tooltip completely alone.
    if type(_G.LFGListUtil_SetSearchEntryTooltip) == "function" then
        hooksecurefunc("LFGListUtil_SetSearchEntryTooltip", function(tooltip, resultID)
            if not UseBlizzardGroupFinder() then return end
            if not db or not db.settings.enabled then return end
            if not tooltip or not resultID or not tooltip.GetName then return end

            local ok, info = pcall(C_LFGList.GetSearchResultInfo, resultID)
            if not ok or not info or not info.leaderName then return end

            local _, prof = MPI.LookupByName(info.leaderName)
            if not prof then return end

            local score = prof.avgMPI or 0
            local _, r, g, b = MPI.GetScoreTier(score)
            local scoreValue = tostring(score)

            local tipName = tooltip:GetName()
            if not tipName then return end

            local numLines = tooltip:NumLines()
            if numLines == 0 then return end

            local leaderTag = _G.LFG_LIST_TOOLTIP_LEADER
            local leaderMatch
            if type(leaderTag) == "string" then
                leaderMatch = leaderTag:gsub("%%s", info.leaderName)
            end

            local lines = {}
            local leaderIdx
            for i = 1, numLines do
                local L = _G[tipName .. "TextLeft" .. i]
                local R = _G[tipName .. "TextRight" .. i]
                local entry = {}
                if L then
                    entry.lt = L:GetText()
                    entry.lr, entry.lg, entry.lb = L:GetTextColor()
                    entry.wrap = L.CanWordWrap and L:CanWordWrap() or false
                end
                if R and R:GetText() and R:GetText() ~= "" then
                    entry.rt = R:GetText()
                    entry.rr, entry.rg, entry.rb = R:GetTextColor()
                end
                lines[i] = entry

                if not leaderIdx and entry.lt then
                    if leaderMatch and entry.lt:find(leaderMatch, 1, true) then
                        leaderIdx = i
                    elseif entry.lt:find(info.leaderName, 1, true) then
                        leaderIdx = i
                    end
                end
            end

            if not leaderIdx or leaderIdx < 1 then return end

            tooltip:ClearLines()
            for i = 1, numLines do
                local e = lines[i]
                if i == 1 then
                    tooltip:SetText(e.lt or "", e.lr or 1, e.lg or 1, e.lb or 1)
                    if e.rt then
                        local R1 = _G[tipName .. "TextRight1"]
                        if R1 then
                            R1:SetText(e.rt)
                            R1:SetTextColor(e.rr or 1, e.rg or 1, e.rb or 1)
                            R1:Show()
                        end
                    end
                else
                    if e.rt then
                        tooltip:AddDoubleLine(e.lt or "", e.rt,
                            e.lr or 1, e.lg or 1, e.lb or 1,
                            e.rr or 1, e.rg or 1, e.rb or 1)
                    else
                        tooltip:AddLine(e.lt or "", e.lr or 1, e.lg or 1, e.lb or 1, e.wrap)
                    end
                end
                if i == leaderIdx then
                    local leaderLineR = e.lr or 1
                    local leaderLineG = e.lg or 0.82
                    local leaderLineB = e.lb or 0
                    tooltip:AddDoubleLine("MPI Score", scoreValue,
                        leaderLineR, leaderLineG, leaderLineB,
                        r or 1, g or 1, b or 1)
                end
            end
            tooltip:Show()
        end)
    end
end

local blizzOverlayEvf = CreateFrame("Frame")
blizzOverlayEvf:RegisterEvent("ADDON_LOADED")
blizzOverlayEvf:RegisterEvent("PLAYER_LOGIN")
blizzOverlayEvf:SetScript("OnEvent", function(_, event, addon)
    if event == "ADDON_LOADED" then
        if addon == "Blizzard_LookingForGroupUI" or addon == "Blizzard_GroupFinder" then
            C_Timer.After(0.1, InstallBlizzOverlay)
        end
    elseif event == "PLAYER_LOGIN" then
        if _G.LFGListFrame then
            C_Timer.After(0.1, InstallBlizzOverlay)
        end
    end
end)

_G.MidnightPI = {
    UpdateDungeonHeroDashboard = function(parent)
        MPI.UpdateDungeonHeroDashboard(parent)
    end,
    UpdatePremadeHeroPill = function(R)
        MPI.UpdatePremadeHeroPill(R)
    end,
    UpdateListingRowMPI = function(row, info)
        MPI.UpdateListingRowMPI(row, info)
    end,
    UpdateSidebarTeamMPI = function(sidebar, resultID)
        MPI.UpdateSidebarTeamMPI(sidebar, resultID)
    end,

    GetPlayerScore = function()
        if not db then return 0 end
        -- Prefer the website-overlay average so the player's own
        -- score everywhere it's surfaced (HUD pill, Group Finder hero,
        -- guild panel headers, /mpi rating) matches the dashboard.
        -- Falls back to local avgMPI when overlay isn't trusted yet.
        local score = MPI.GetEffectiveSelfScore and MPI.GetEffectiveSelfScore() or (db.personal.avgMPI or 0)
        return score
    end,
    GetProfileByGUID = function(guid)
        if not db or not guid then return nil end
        return db.profiles[guid]
    end,
    GetProfileByName = function(name)
        if not db or not name then return nil end
        local _, prof = MPI.LookupByName(name)
        return prof
    end,
    IsReady = function()
        return MPI._ready
    end,
    AddTag = function(guid, tag) MPI.AddTag(guid, tag) end,
    RemoveTag = function(guid, tag) MPI.RemoveTag(guid, tag) end,
    SetNote = function(guid, text) MPI.SetNote(guid, text) end,

    OnApplicantListUpdated = function(sidebar)
        MPI.OnApplicantListUpdated(sidebar)
    end,

    GetScoreTier = function(score)
        return MPI.GetScoreTier(score)
    end,
    GetBarColor = function(value)
        return MPI.GetBarColor(value)
    end,

    CommunityLookup = function(name, realm)
        return MPI.CommunityLookup(name, realm)
    end,

    UpdateCompanionBanner = function(parent)
        MPI.UpdateCompanionBanner(parent)
    end,
    BuildCompanionSettingsButton = function(gearBtn)
        MPI.BuildCompanionSettingsButton(gearBtn)
    end,
    GetCompanionStatus = function()
        return MPI.GetCompanionStatus()
    end,

    -- Spec-aware role + score helpers used by SeasonProgress.lua and any
    -- other consumer that needs to surface the active spec's score outside
    -- this file. The file-local MPI table isn't reachable from sibling
    -- files; these wrappers expose the bits SP needs.
    GetActiveRole = function()
        return MPI.GetActiveRole()
    end,
    GetActiveRoleScore = function()
        return MPI.GetActiveRoleScore()
    end,
    GetRoleTooltipExtras = function(role)
        return MPI.GetRoleTooltipExtras(role)
    end,
    GetScoreTier = function(score)
        return MPI.GetScoreTier(score)
    end,
}

-- Sibling files (SeasonProgress.lua, etc.) reference _G.MPI for the
-- helpers above. Mirror the public API table onto _G.MPI so existing
-- "_G.MPI and _G.MPI.GetBarColor" guards resolve to real functions
-- instead of silently no-op'ing into the legacy v1 fallback path.
_G.MPI = _G.MidnightPI
