-- ============================================================
-- FILE: SeasonProgress.lua
-- PURPOSE: Player-facing "Season Progress" window opened from
--   the Custom Premade Group Finder. Visualizes the player's
--   Mythic+ season progression as something that visibly fills
--   up — a Season Journey bar (rating → next tier), per-dungeon
--   key-level fills, and weekly vault chests that light up as
--   timed runs are banked.
-- HOW IT WORKS:
--   1. Globals MidnightUI_SeasonProgress_Toggle / Show / Hide
--      are looked up at click time by the Group Finder button.
--   2. EnsurePanel() lazy-builds the window once. Refresh()
--      runs on every show so values are live without /reload.
--   3. Two data layers:
--      - Blizzard APIs  → rating, current keystone, season
--                         bests per map, run history, affixes,
--                         vault thresholds.
--      - MidnightUI_MPI → composite MPI score, per-dimension
--                         averages, weekly tally. The Companion
--                         keeps these in sync with mpi.atyzi.com,
--                         and a small sync chip surfaces health.
-- DEPENDS ON:
--   C_PlayerInfo.GetPlayerMythicPlusRatingSummary
--   C_MythicPlus  (GetRunHistory, GetSeasonBestForMap,
--                  GetSeasonBestAffixScoreInfoForMap,
--                  GetCurrentAffixes, GetRewardLevelFromKeystoneLevel,
--                  GetOwnedKeystone* fallbacks)
--   C_ChallengeMode (GetMapTable, GetMapUIInfo, GetAffixInfo)
--   _G.MidnightUI_MPI / _G.MPI helpers — optional overlay
--   _G.MidnightUI_MPI_Sync — Companion handshake metadata
-- EXPORTS:
--   _G.MidnightUI_SeasonProgress_Toggle / Show / Hide
-- OWNED SAVEDVARIABLES: none
-- EVENTS REGISTERED: none (lazy refresh on show)
-- FRAMES CREATED: MidnightUI_SeasonProgressPanel (lazy)
-- GOTCHAS:
--   * GroupFinder.lua loads BEFORE this file; the toggle is
--     looked up at click time, so the order is fine.
--   * Blizzard M+ APIs return nil right after login until the
--     server has shipped the data — every read is pcall'd and
--     "loading" UI shows when nothing came back.
--   * Dungeon background textures vary in aspect ratio. We
--     SetTexCoord to a centered horizontal strip and dim with
--     a vertical gradient so labels stay legible.
--   * The leading-edge pulse on the Journey bar uses an
--     AnimationGroup on a separate texture so the fill itself
--     keeps a stable width.
-- ============================================================

local W8 = "Interface\\Buttons\\WHITE8X8"
local FONT = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\FRIZQT__.TTF")) or "Fonts\\FRIZQT__.TTF"

local CreateFrame, UIParent = CreateFrame, UIParent
local UnitName, UnitGUID, UnitClass, UnitLevel = UnitName, UnitGUID, UnitClass, UnitLevel
local GetAverageItemLevel = GetAverageItemLevel
local GetSpecialization, GetSpecializationInfo = GetSpecialization, GetSpecializationInfo
local RAID_CLASS_COLORS, CLASS_ICON_TCOORDS = RAID_CLASS_COLORS, CLASS_ICON_TCOORDS
local C_PlayerInfo, C_MythicPlus, C_ChallengeMode, C_Container =
      C_PlayerInfo, C_MythicPlus, C_ChallengeMode, C_Container
local C_Timer = C_Timer
local time, date, floor, ceil, max, min = time, date, floor, ceil, max, min
local pairs, ipairs, type, pcall, tostring, tonumber, table, tinsert, unpack =
      pairs, ipairs, type, pcall, tostring, tonumber, table, tinsert, unpack

local SP = _G.MidnightUI_SeasonProgress or {}
_G.MidnightUI_SeasonProgress = SP

SP._refs = SP._refs or {}
SP._dungeonCards = {}
SP._dimRows = {}
SP._vaultSlots = {}
SP._affixPills = {}
SP._tierPips = {}

-- ── Design tokens ────────────────────────────────────────
-- One palette, one type scale, one spacing grid.

local PAL = {
    surface     = { 0.045, 0.045, 0.060, 0.98 },
    panel       = { 0.075, 0.075, 0.100, 0.96 },
    panelHi     = { 0.105, 0.105, 0.135, 0.96 },
    well        = { 0.040, 0.040, 0.055, 0.92 },
    border      = { 0.180, 0.170, 0.230, 0.70 },
    borderHi    = { 0.620, 0.520, 0.980, 0.55 },
    accent      = { 0.620, 0.520, 0.980 },
    text1       = { 0.96, 0.96, 0.99 },
    text2       = { 0.74, 0.74, 0.82 },
    text3       = { 0.50, 0.50, 0.58 },
    text4       = { 0.36, 0.36, 0.42 },
    good        = { 0.55, 0.85, 0.60 },
    warn        = { 0.95, 0.78, 0.36 },
    bad         = { 0.95, 0.50, 0.50 },
    -- A single-hue rating ramp (warming → legend). Used
    -- where a key-level tier is colored (per-dungeon cards,
    -- vault chests). Distinct from the MPI tier ramp below.
    tWarm       = { 0.55, 0.55, 0.62 },
    tEntry      = { 0.85, 0.65, 0.50 },
    tRising     = { 0.95, 0.78, 0.36 },
    tSolid      = { 0.55, 0.85, 0.60 },
    tStrong     = { 0.45, 0.72, 1.00 },
    tElite      = { 1.00, 0.84, 0.30 },
    tLegend     = { 0.85, 0.55, 1.00 },

    -- WEBSITE MPI tier ramp — must mirror mpi.atyzi.com's
    -- MPI.tier() function exactly. Cutoffs 90/75/60/40 with
    -- letters S/A/B/C/D and the dashboard's hex colors:
    --   S #e8c050   A #d4c08a   B #9aa0b8   C #b89a7a   D #e87171
    -- Used by the Season Journey bar so the addon's tier and
    -- the website's tier are the same indicator, not two.
    tS          = { 0.910, 0.753, 0.314 },
    tA          = { 0.831, 0.753, 0.541 },
    tB          = { 0.604, 0.627, 0.722 },
    tC          = { 0.722, 0.604, 0.478 },
    tD          = { 0.910, 0.443, 0.443 },
}

local F_DISPLAY  = 36   -- big rating number
local F_HEAD     = 22   -- player name
local F_SUB      = 14   -- tier name, dungeon key
local F_TITLE    = 13   -- card title
local F_BODY     = 12
local F_CAPTION  = 11
local F_MICRO    =  9   -- section labels (uppercase)

local S4, S6, S8, S10, S12, S14, S16, S20 = 4, 6, 8, 10, 12, 14, 16, 20

local CFG = {
    WIDTH   = 880,
    HEIGHT  = 792,
    PAD     = S20,
    GAP     = S16,
    HEADER  = 56,
    HERO_H  = 200,
    DGN_H   = 244,
    BOT_H   = 220,
    STRATA  = "DIALOG",
}

-- MPI tier ramp — IDENTICAL to mpi.atyzi.com's MPI.tier().
-- Each entry: { minScore, color, label }. Walked top-down.
-- Score scale is 0–100 (MPI Score), NOT M+ Rating.
local TIERS = {
    { 90, PAL.tS, "S" },
    { 75, PAL.tA, "A" },
    { 60, PAL.tB, "B" },
    { 40, PAL.tC, "C" },
    {  0, PAL.tD, "D" },
}
local TIER_MAX = 100  -- MPI score ceiling

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

local VAULT_THRESHOLDS = { 1, 4, 8 }
local KEY_FILL_CEIL    = 20  -- per-dungeon bar saturates at +20

-- ── Tiny helpers ─────────────────────────────────────────

local function SetFont(fs, size, flags)
    if not fs or not fs.SetFont then return end
    pcall(fs.SetFont, fs, FONT, size, flags or "")
end

local function Color(fs, c)
    if fs and c then fs:SetTextColor(c[1], c[2], c[3], c[4] or 1) end
end

local function Bg(tex, c)
    if tex and c then tex:SetColorTexture(c[1], c[2], c[3], c[4] or 1) end
end

local function ClassRGB(token)
    local c = token and RAID_CLASS_COLORS and RAID_CLASS_COLORS[token]
    if c then return c.r, c.g, c.b end
    return 0.85, 0.85, 0.85
end

local function Gradient(tex, orient, r1, g1, b1, a1, r2, g2, b2, a2)
    if not tex then return end
    if tex.SetGradient and CreateColor then
        pcall(tex.SetGradient, tex, orient, CreateColor(r1, g1, b1, a1), CreateColor(r2, g2, b2, a2))
    else
        tex:SetColorTexture(r1, g1, b1, (a1 + a2) * 0.5)
    end
end

local function ScoreTier(score)
    score = score or 0
    for _, t in ipairs(TIERS) do
        if score >= t[1] then return t[1], t[2], t[3] end
    end
    return 0, PAL.tWarm, "WARMING"
end

local function NextTier(score)
    score = score or 0
    -- TIERS is descending; walk to find the first floor strictly above.
    local nextFloor, nextColor, nextLabel = nil, nil, nil
    for i = #TIERS, 1, -1 do
        if TIERS[i][1] > score then
            nextFloor, nextColor, nextLabel = TIERS[i][1], TIERS[i][2], TIERS[i][3]
            break
        end
    end
    return nextFloor, nextColor, nextLabel
end

-- Color used for a specific key level. Buckets mirror the
-- rating-tier intent so a +12 reads as "STRONG-tier push".
local function KeyColor(level)
    level = level or 0
    if level >= 18 then return PAL.tLegend end
    if level >= 15 then return PAL.tElite  end
    if level >= 12 then return PAL.tStrong end
    if level >= 10 then return PAL.tSolid  end
    if level >=  7 then return PAL.tRising end
    if level >   0 then return PAL.tEntry  end
    return PAL.tWarm
end

local function FormatRelative(ts)
    if not ts or ts <= 0 then return "" end
    local diff = time() - ts
    if diff < 60      then return "just now"          end
    if diff < 3600    then return floor(diff/60)   .. "m ago" end
    if diff < 86400   then return floor(diff/3600) .. "h ago" end
    if diff < 86400*2 then return "yesterday"        end
    return floor(diff/86400) .. "d ago"
end

-- ── Component constructors ──────────────────────────────

local function MakePanel(parent, opts)
    opts = opts or {}
    local p = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    p:SetBackdrop({
        bgFile   = W8,
        edgeFile = W8,
        edgeSize = 1,
        insets   = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    local bg = opts.bg or PAL.panel
    local bd = opts.bd or PAL.border
    p:SetBackdropColor(bg[1], bg[2], bg[3], bg[4] or 1)
    p:SetBackdropBorderColor(bd[1], bd[2], bd[3], bd[4] or 1)
    return p
end

local function MakeText(parent, text, size, flags, color)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    SetFont(fs, size or F_BODY, flags or "")
    Color(fs, color or PAL.text1)
    if text then fs:SetText(text) end
    return fs
end

local function MakeSectionLabel(parent, text)
    local fs = MakeText(parent, text, F_MICRO, "OUTLINE", PAL.text3)
    return fs
end

-- Styled section header used at the top of Vault, Dimensions,
-- and Dungeon Bests panels. Renders an accent stripe + brighter
-- label + 1px divider line beneath, all anchored S16 from the
-- panel edges. Returns the meta FontString (right-aligned), so
-- callers can SetText on it for the per-section meta line.
local function StyleSectionHeader(panel, labelText)
    local accent = panel:CreateTexture(nil, "OVERLAY")
    accent:SetSize(3, 12)
    accent:SetPoint("LEFT", panel, "TOPLEFT", S16, -S12 - 6)
    accent:SetTexture(W8)
    accent:SetColorTexture(PAL.accent[1], PAL.accent[2], PAL.accent[3], 1)

    local label = MakeText(panel, labelText, F_CAPTION, "OUTLINE", PAL.text1)
    label:SetPoint("LEFT", accent, "RIGHT", S8, 0)

    local meta = MakeText(panel, "", F_CAPTION, "OUTLINE", PAL.text2)
    meta:SetPoint("RIGHT", panel, "TOPRIGHT", -S16, -S12 - 6)
    meta:SetJustifyH("RIGHT")

    local divider = panel:CreateTexture(nil, "OVERLAY")
    divider:SetHeight(1)
    divider:SetPoint("LEFT",  panel, "TOPLEFT",  S16, -S12 - 18)
    divider:SetPoint("RIGHT", panel, "TOPRIGHT", -S16, -S12 - 18)
    divider:SetTexture(W8)
    divider:SetColorTexture(PAL.border[1], PAL.border[2], PAL.border[3], 0.6)

    return meta, label, accent, divider
end

-- A small "pill" — tiny inset chip used for affixes and
-- meta lines. Returns the frame so caller can position it.
local function MakePill(parent, text, color, fillAlpha)
    local p = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    p:SetBackdrop({
        bgFile   = W8,
        edgeFile = W8,
        edgeSize = 1,
        insets   = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    local c = color or PAL.text2
    p:SetBackdropColor(c[1], c[2], c[3], fillAlpha or 0.16)
    p:SetBackdropBorderColor(c[1], c[2], c[3], 0.55)
    p:SetHeight(20)
    local fs = MakeText(p, text, F_CAPTION, "OUTLINE", c)
    fs:SetPoint("LEFT",  p, "LEFT",  S8, 0)
    fs:SetPoint("RIGHT", p, "RIGHT", -S8, 0)
    fs:SetJustifyH("CENTER")
    p._fs = fs
    p:SetWidth(max(40, fs:GetStringWidth() + S16))
    return p
end

-- ── Data gathering ───────────────────────────────────────

local function GatherData()
    local d = {
        myName  = UnitName("player") or "Unknown",
        myLevel = UnitLevel("player") or 0,
    }
    local _, classToken = UnitClass("player")
    d.classToken = classToken

    local specIdx = GetSpecialization and GetSpecialization()
    if specIdx then
        local ok, _, specName = pcall(GetSpecializationInfo, specIdx)
        if ok and specName then d.specName = specName end
    end
    if GetAverageItemLevel then
        -- Use the FIRST return value (overall / "actual" item
        -- level) so we match the character pane header. The
        -- second return is "equipped only" — close to it but
        -- a couple points lower for most players.
        local ok, overall = pcall(GetAverageItemLevel)
        if ok and type(overall) == "number" and overall > 0 then
            d.ilvl = floor(overall + 0.5)
        end
    end

    -- Rating summary is the canonical source for both the
    -- composite season score and the per-map best lookup.
    local bestByMap = {}
    if C_PlayerInfo and C_PlayerInfo.GetPlayerMythicPlusRatingSummary then
        local ok, summary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, "player")
        if ok and summary then
            d.mpScore = summary.currentSeasonScore or 0
            if type(summary.runs) == "table" then
                for _, r in ipairs(summary.runs) do
                    local mid = r.challengeModeID or r.mapChallengeModeID
                    if mid then
                        bestByMap[mid] = {
                            level    = r.bestRunLevel or r.level or 0,
                            score    = r.mapScore or r.score or 0,
                            duration = r.bestRunDurationMS and (r.bestRunDurationMS / 1000) or r.durationSec,
                            finished = r.finishedSuccess,
                        }
                    end
                end
            end
        end
    end
    d.mpScore = d.mpScore or 0

    -- Current weekly affixes.
    d.affixes = {}
    if C_MythicPlus and C_MythicPlus.GetCurrentAffixes then
        local ok, list = pcall(C_MythicPlus.GetCurrentAffixes)
        if ok and type(list) == "table" then
            for _, a in ipairs(list) do
                local name, fileID = "?", nil
                if C_ChallengeMode and C_ChallengeMode.GetAffixInfo then
                    local ok2, n, _, fid = pcall(C_ChallengeMode.GetAffixInfo, a.id)
                    if ok2 then name = n or name; fileID = fid end
                end
                d.affixes[#d.affixes + 1] = { id = a.id, name = name, file = fileID }
            end
        end
    end

    -- Owned keystone (bag scan + API fallbacks).
    do
        local mapID, level
        if C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerItemLink then
            for bag = 0, 5 do
                local okN, slots = pcall(C_Container.GetContainerNumSlots, bag)
                if okN and (slots or 0) > 0 then
                    for slot = 1, slots do
                        local okL, link = pcall(C_Container.GetContainerItemLink, bag, slot)
                        if okL and type(link) == "string" then
                            local mID, lvl = link:match("|Hkeystone:%d+:(%d+):(%d+):")
                            if not mID then
                                mID, lvl = link:match("|Hitem:180653:[^|]-:(%d+):(%d+):")
                            end
                            if mID and lvl then
                                mapID, level = tonumber(mID), tonumber(lvl)
                                break
                            end
                        end
                    end
                end
                if mapID and level then break end
            end
        end
        if (not mapID or not level) and C_MythicPlus then
            if C_MythicPlus.GetOwnedKeystoneInfo then
                local ok, m, l = pcall(C_MythicPlus.GetOwnedKeystoneInfo)
                if ok then mapID, level = mapID or m, level or l end
            end
            if (not mapID) and C_MythicPlus.GetOwnedKeystoneChallengeMapID then
                local ok, m = pcall(C_MythicPlus.GetOwnedKeystoneChallengeMapID)
                if ok then mapID = m end
            end
            if (not level) and C_MythicPlus.GetOwnedKeystoneLevel then
                local ok, l = pcall(C_MythicPlus.GetOwnedKeystoneLevel)
                if ok then level = l end
            end
        end
        if mapID and level and mapID > 0 and level > 0 then
            d.keystoneMapID = mapID
            d.keystoneLevel = level
            if C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then
                local ok, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
                if ok and n then d.keystoneName = n end
            end
        end
    end

    -- Per-map dungeon list.
    d.dungeons = {}
    if C_ChallengeMode and C_ChallengeMode.GetMapTable then
        local ok, maps = pcall(C_ChallengeMode.GetMapTable)
        if ok and type(maps) == "table" then
            for _, mapID in ipairs(maps) do
                local entry = { mapID = mapID, name = "?", best = 0, score = 0 }
                if C_ChallengeMode.GetMapUIInfo then
                    local ok2, name, _, timeLimit, texture, bgTex = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
                    if ok2 then
                        if type(name) == "string" then entry.name = name end
                        entry.timeLimit = timeLimit
                        entry.texture, entry.bgTex = texture, bgTex
                    end
                end
                local b = bestByMap[mapID]
                if b then
                    entry.best = b.level or 0
                    entry.score = b.score or 0
                    entry.duration = b.duration
                    if b.finished == false then entry.overTime = true end
                end
                if entry.best == 0 and C_MythicPlus.GetSeasonBestForMap then
                    local ok3, info = pcall(C_MythicPlus.GetSeasonBestForMap, mapID)
                    if ok3 and type(info) == "table" then
                        entry.best  = info.level or info.bestRunLevel or 0
                        entry.score = info.score or info.mapScore or 0
                        entry.duration = info.durationSec or (info.bestRunDurationMS and info.bestRunDurationMS / 1000)
                        if info.finishedSuccess == false or info.overTime then entry.overTime = true end
                    end
                end
                if entry.best == 0 and C_MythicPlus.GetSeasonBestAffixScoreInfoForMap then
                    local ok4, list = pcall(C_MythicPlus.GetSeasonBestAffixScoreInfoForMap, mapID)
                    if ok4 and type(list) == "table" then
                        for _, a in ipairs(list) do
                            local lvl = a.level or 0
                            local sc  = a.score or 0
                            if lvl > entry.best or (lvl == entry.best and sc > entry.score) then
                                entry.best = lvl; entry.score = sc
                                entry.overTime = a.overTime
                                entry.duration = a.durationSec
                            end
                        end
                    end
                end
                d.dungeons[#d.dungeons + 1] = entry
            end
        end
    end
    table.sort(d.dungeons, function(a, b)
        local ab, bb = tonumber(a.best) or 0, tonumber(b.best) or 0
        if ab ~= bb then return ab > bb end
        return (tonumber(a.score) or 0) > (tonumber(b.score) or 0)
    end)

    -- Run history → vault chest computation.
    d.runs = {}
    if C_MythicPlus and C_MythicPlus.GetRunHistory then
        local ok, runs = pcall(C_MythicPlus.GetRunHistory, true, true)
        if not (ok and type(runs) == "table" and #runs > 0) then
            ok, runs = pcall(C_MythicPlus.GetRunHistory, false, true)
        end
        if ok and type(runs) == "table" then
            for _, r in ipairs(runs) do d.runs[#d.runs + 1] = r end
            -- Sort newest first. Recent WoW builds return
            -- completionDate as a date table, so direct comparison
            -- breaks; coerce via time() and fall through to 0.
            local function sortKey(r)
                if type(r.runStartedAtTime) == "number" then return r.runStartedAtTime end
                if type(r.completionDate) == "number" then return r.completionDate end
                if type(r.completionDate) == "table" and time then
                    local okT, t = pcall(time, r.completionDate)
                    if okT and type(t) == "number" then return t end
                end
                return 0
            end
            pcall(table.sort, d.runs, function(a, b)
                return sortKey(a) > sortKey(b)
            end)
        end
    end

    d.vault = { timed = 0, slots = {} }
    do
        local timedLevels = {}
        for _, r in ipairs(d.runs) do
            local lvl = tonumber(r.level) or 0
            if r.completed and lvl > 0 then
                timedLevels[#timedLevels + 1] = lvl
            end
        end
        table.sort(timedLevels, function(a, b) return a > b end)
        d.vault.timed = #timedLevels
        for i, threshold in ipairs(VAULT_THRESHOLDS) do
            local s = { threshold = threshold, unlocked = (#timedLevels >= threshold) }
            if s.unlocked then
                s.level = timedLevels[threshold]
                if s.level and C_MythicPlus and C_MythicPlus.GetRewardLevelFromKeystoneLevel then
                    local okR, ilvl = pcall(C_MythicPlus.GetRewardLevelFromKeystoneLevel, s.level)
                    if okR and ilvl and ilvl > 0 then s.ilvl = ilvl end
                end
            end
            d.vault.slots[i] = s
        end
    end

    -- Great Vault state from C_WeeklyRewards. The three on-screen
    -- vault tiles render directly from this — Raid, Dungeon, World.
    d.greatVault = nil
    if C_WeeklyRewards and C_WeeklyRewards.GetActivities then
        local et = (Enum and Enum.WeeklyRewardChestThresholdType) or {}
        local categories = {
            { key = "dungeon", label = "DUNGEON", id = et.Activities or 1 },
            { key = "raid",    label = "RAID",    id = et.Raid       or 3 },
            { key = "world",   label = "WORLD",   id = et.World      or 6 },
        }
        local gv = {}
        for _, cat in ipairs(categories) do
            local okC, list = pcall(C_WeeklyRewards.GetActivities, cat.id)
            local entry = { key = cat.key, label = cat.label, slots = {}, unlocked = 0, total = 0, bestLevel = 0 }
            if okC and type(list) == "table" then
                entry.total = #list
                for _, a in ipairs(list) do
                    local progress = tonumber(a.progress) or 0
                    local threshold = tonumber(a.threshold) or 0
                    local level = tonumber(a.level) or 0
                    local unlocked = (threshold > 0 and progress >= threshold)
                    entry.slots[#entry.slots + 1] = {
                        progress = progress, threshold = threshold,
                        level = level, unlocked = unlocked,
                    }
                    if unlocked then
                        entry.unlocked = entry.unlocked + 1
                        if level > entry.bestLevel then entry.bestLevel = level end
                    end
                end
            end
            gv[cat.key] = entry
        end
        d.greatVault = gv
    end

    -- MidnightUI MPI overlay (optional; degrades gracefully).
    d.mpi = nil
    if _G.MidnightUI_MPI then
        local db = _G.MidnightUI_MPI
        local myGUID = UnitGUID("player")
        if _G.MPI and _G.MPI.UpdatePersonalStats then pcall(_G.MPI.UpdatePersonalStats) end

        -- Read per-dim averages directly from the player's own
        -- profile record. UpdateProfiles maintains these the same
        -- way the rest of the addon's MPI surfaces (Group Finder
        -- sidebar, run-complete popup) read them, and the same
        -- way mpi.atyzi.com computes its profile bars (mean of
        -- per-run dim values), so all three sources agree.
        -- Recomputing on the fly was returning 0 because some
        -- in-memory runs lack the per-dim fields the profile
        -- aggregator already handles defensively.
        local prof = (myGUID and db.profiles and db.profiles[myGUID]) or {}
        local localRunCount = (db.runs and #db.runs) or 0

        local dims = {
            awarenessScore   = prof.awarenessScore   or 0,
            survivalScore    = prof.survivalScore    or 0,
            outputScore      = prof.outputScore      or 0,
            utilityScore     = prof.utilityScore     or 0,
            consistencyScore = prof.consistencyScore or 0,
        }

        local p = db.personal or {}
        d.mpi = {
            sampledCount    = prof.runsTracked or 0,
            localRunCount   = localRunCount,
            dims            = dims,
            avgMPI          = p.avgMPI or 0,
            verifiedPeak    = p.verifiedPeak or 0,
            totalRuns       = p.totalRuns or 0,
            timedRuns       = p.timedRuns or 0,
            weeklyRuns      = p.weeklyRuns or 0,
            weeklyTimedRuns = p.weeklyTimedRuns or 0,
            weeklyAvgMPI    = p.weeklyAvgMPI or 0,
        }
    end

    -- Companion sync chip metadata. Status is derived directly from
    -- MidnightUI_MPI_Sync.lastSync (the global written by the
    -- Companion). The MPI module's GetCompanionStatus helper is a
    -- file-local in MidnightPI.lua and isn't exposed via _G, so we
    -- can't call it from here — checking the timestamp ourselves
    -- gives the same result and works regardless of load order.
    local COMPANION_STALE_SEC = 172800  -- 48h, matches MidnightPI.lua
    d.companion = { status = "missing" }
    local sync = _G.MidnightUI_MPI_Sync
    if type(sync) == "table" then
        d.companion.lastSync = sync.lastSync
        d.companion.version  = sync.companionVersion
        local ts = tonumber(sync.lastSync)
        if ts and ts > 0 and time then
            local okT, now = pcall(time)
            if okT and type(now) == "number" then
                local age = now - ts
                if age <= COMPANION_STALE_SEC then
                    d.companion.status = "active"
                else
                    d.companion.status = "stale"
                end
            end
        end
    end

    -- Website overlay. The Companion polls /php/companion_overlay.php
    -- and writes db/mpi_web_overlay.lua, which sets MidnightUI_MPI_Web
    -- as a small table of aggregate values. When present, these are
    -- preferred over the local-runs aggregation for the dim panel —
    -- they match the player's profile page on mpi.atyzi.com exactly.
    --
    -- Identity guard: the overlay file ships with the addon and gets
    -- rewritten by the Companion. If the addon folder is copied
    -- between accounts, the file can carry someone else's stats. Two
    -- independent signals each clear the overlay:
    --
    --   (a) MidnightUI_MPI.profiles has any entry with runsTracked>0.
    --       Account-scoped SavedVariables, can't follow a folder copy.
    --   (b) MidnightUI_MPI_Sync.lastSync is within the last 24h. The
    --       Companion derives its contributor ID from the WoW account
    --       folder name, so a recent sync proves a Companion is
    --       actively running for THIS install. A copied-then-stale
    --       overlay file would age out of this window before the
    --       fallback kicks in (a Companion linked to THIS account
    --       would have already rewritten it).
    --
    -- The fallback covers the legitimate "fresh install / wiped
    -- SavedVariables" case — without it the panel rejects the overlay
    -- forever on a brand new account, even with a working Companion.
    d.webOverlay = nil
    local function localAccountHasTrackedMPI()
        local db = _G.MidnightUI_MPI
        if type(db) ~= "table" or type(db.profiles) ~= "table" then return false end
        for _, prof in pairs(db.profiles) do
            if type(prof) == "table" and (tonumber(prof.runsTracked) or 0) > 0 then
                return true
            end
        end
        return false
    end
    local function companionSyncIsFresh()
        local sync = _G.MidnightUI_MPI_Sync
        if type(sync) ~= "table" then return false end
        local ts = tonumber(sync.lastSync)
        if not ts or ts <= 0 then return false end
        local nowOk, now = pcall(time)
        if not nowOk or type(now) ~= "number" then return false end
        return (now - ts) < 86400  -- 24h
    end
    -- Resolve which role's score we're displaying. MPI.GetActiveRoleScore
    -- handles the source priority for us:
    --   1. Web overlay's roles[] array (Companion v2.1+)
    --   2. Local SavedVar runs for the current spec
    -- Both paths emit the same role-payload shape, so we map onto the
    -- legacy webOverlay struct the rest of the file already consumes.
    local w = _G.MidnightUI_MPI_Web
    local webIsTrustworthy = type(w) == "table" and (w.totalRuns or 0) > 0
        and (localAccountHasTrackedMPI() or companionSyncIsFresh())

    local active, fallback
    -- MPI is file-local in MidnightPI.lua; reach the wrapper through
    -- the public API table _G.MidnightPI (mirrored as _G.MPI for
    -- legacy callers in this file).
    local mpiApi = _G.MidnightPI or _G.MPI
    if mpiApi and mpiApi.GetActiveRoleScore then
        active, fallback = mpiApi.GetActiveRoleScore()
    end

    -- The hero rating + dim breakdown both reflect the player's CURRENT
    -- spec. When they swap into a spec they haven't run keys in yet, the
    -- panel goes empty rather than showing a stale "fallback" role —
    -- otherwise switching specs leaves the score unchanged and the
    -- player can't tell anything updated.
    --
    -- We still capture the fallback role separately so the empty-state
    -- copy can hint "most played: Tank · 19" if we want to surface it.
    local activeRole = mpiApi and mpiApi.GetActiveRole and mpiApi.GetActiveRole() or nil

    if active then
        d.webOverlay = {
            avgMPI         = active.overallMPI     or 0,
            avgAwareness   = active.avgAwareness   or 0,
            avgSurvival    = active.avgSurvival    or 0,
            avgOutput      = active.avgOutput      or 0,
            avgUtility     = active.avgUtility     or 0,
            avgConsistency = active.avgConsistency or 0,
            bestMPI        = active.bestMPI        or 0,
            highestKey     = active.highestKey     or 0,
            totalRuns      = active.runs   or 0,
            timedRuns      = active.timed  or 0,
            computedAt     = (webIsTrustworthy and w.computedAt) or 0,
            activeRole     = active.role,
            activeRoleMatchesSpec = true,
            verifiedPeak   = active.verifiedPeak   or 0,
            peakIsVerified = active.peakIsVerified == true,
            rolesAll       = (webIsTrustworthy and w.roles) or nil,
            seasonKey      = (webIsTrustworthy and w.season and w.season.key) or nil,
            sourceIsLocal  = active._source == "local",
        }
    end

    -- For the empty state below: which spec is the player in, and what
    -- role do we have data for if any?
    d.activeRole       = activeRole
    d.fallbackOverlay  = fallback and {
        role       = fallback.role,
        overallMPI = fallback.overallMPI or 0,
    } or nil

    -- Hero MPI Score is role-aware. d.webOverlay is now only populated
    -- when the active spec has runs; if the player's in a fresh spec,
    -- d.overallMpi stays 0 so the panel shows the empty state instead
    -- of borrowing another role's score (which used to come from the
    -- pooled-roles d.mpi.avgMPI fallback).
    if d.webOverlay and (d.webOverlay.avgMPI or 0) > 0 then
        d.overallMpi = d.webOverlay.avgMPI
    else
        d.overallMpi = 0
    end

    return d
end

-- ── Frame construction ───────────────────────────────────

function SP.EnsurePanel()
    local R = SP._refs
    if R.panel then return R.panel end

    local frameName = "MidnightUI_SeasonProgressPanel"
    local f = CreateFrame("Frame", frameName, UIParent, "BackdropTemplate")
    f:SetSize(CFG.WIDTH, CFG.HEIGHT)
    f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    f:SetFrameStrata(CFG.STRATA)
    f:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    f:SetBackdropColor(unpack(PAL.surface))
    -- Neutral dark outer frame. Earlier this used PAL.borderHi
    -- (saturated purple) which competed with the class-tinted
    -- header line directly below it — two colored bands stacked
    -- at the top edge looked messy. The accent color stays
    -- inside the panel as small section-header stripes.
    f:SetBackdropBorderColor(0.10, 0.10, 0.13, 0.95)
    f:SetMovable(true)
    f:SetClampedToScreen(true)
    f:Hide()
    R.panel = f

    -- Manual ESC handler. We deliberately do NOT register in
    -- UISpecialFrames — that list iterates and hides EVERY
    -- entry on a single ESC press, which would also close the
    -- Group Finder window behind us. Instead, swallow ESC at
    -- the keyboard layer and pass every other key through.
    f:EnableKeyboard(false)
    f:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            self:SetPropagateKeyboardInput(false)
            SP.Hide()
        else
            self:SetPropagateKeyboardInput(true)
        end
    end)

    -- soft drop shadow
    for i = 1, 5 do
        local s = f:CreateTexture(nil, "BACKGROUND", nil, -i)
        s:SetPoint("TOPLEFT",     f, "TOPLEFT",     -i,  i)
        s:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT",  i, -i)
        s:SetColorTexture(0, 0, 0, 0.22 - i * 0.04)
    end

    -- ─── HEADER ──────────────────────────────────────────
    local header = CreateFrame("Frame", nil, f)
    header:SetHeight(CFG.HEADER)
    header:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 0)
    header:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    R.header = header

    local hbg = header:CreateTexture(nil, "BACKGROUND")
    hbg:SetAllPoints()
    Bg(hbg, { 0.06, 0.06, 0.10, 1 })

    local hgrad = header:CreateTexture(nil, "BACKGROUND", nil, 1)
    hgrad:SetAllPoints()
    hgrad:SetTexture(W8)
    hgrad:SetVertexColor(PAL.accent[1], PAL.accent[2], PAL.accent[3], 0.05)
    R.headerGrad = hgrad

    local hline = header:CreateTexture(nil, "OVERLAY", nil, 2)
    hline:SetHeight(1)
    hline:SetPoint("BOTTOMLEFT",  header, "BOTTOMLEFT",  0, 0)
    hline:SetPoint("BOTTOMRIGHT", header, "BOTTOMRIGHT", 0, 0)
    R.headerLine = hline

    local titleFs = MakeText(header, "SEASON PROGRESS", F_TITLE, "OUTLINE", PAL.accent)
    titleFs:SetPoint("LEFT", header, "LEFT", CFG.PAD, 0)
    titleFs:SetTextColor(PAL.accent[1], PAL.accent[2], PAL.accent[3], 0.95)

    local subtitleFs = MakeText(header, "Mythic+ Season at a glance", F_CAPTION, "", PAL.text3)
    subtitleFs:SetPoint("LEFT", titleFs, "RIGHT", S12, 0)

    local closeBtn = CreateFrame("Button", nil, header)
    closeBtn:SetSize(28, 28)
    closeBtn:SetPoint("RIGHT", header, "RIGHT", -S12, 0)
    local closeFs = MakeText(closeBtn, "X", F_HEAD - 4, "OUTLINE", PAL.text1)
    closeFs:SetPoint("CENTER")
    closeBtn:SetScript("OnEnter", function() Color(closeFs, PAL.bad) end)
    closeBtn:SetScript("OnLeave", function() Color(closeFs, PAL.text1) end)
    closeBtn:SetScript("OnClick", function() SP.Hide() end)

    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function() f:StartMoving() end)
    header:SetScript("OnDragStop",  function() f:StopMovingOrSizing() end)

    -- ─── BODY ────────────────────────────────────────────
    local body = CreateFrame("Frame", nil, f)
    body:SetPoint("TOPLEFT",     header, "BOTTOMLEFT",   CFG.PAD,  -CFG.PAD)
    body:SetPoint("BOTTOMRIGHT", f,      "BOTTOMRIGHT", -CFG.PAD,   CFG.PAD)
    R.body = body

    local INNER_W = CFG.WIDTH - 2 * CFG.PAD

    -- ─── HERO ───────────────────────────────────────────
    local hero = MakePanel(body, { bg = PAL.panelHi })
    hero:SetSize(INNER_W, CFG.HERO_H)
    hero:SetPoint("TOPLEFT", body, "TOPLEFT", 0, 0)
    R.hero = hero

    local heroGrad = hero:CreateTexture(nil, "BACKGROUND", nil, 2)
    heroGrad:SetPoint("TOPLEFT",     hero, "TOPLEFT",      1, -1)
    heroGrad:SetPoint("BOTTOMRIGHT", hero, "BOTTOMRIGHT", -1,  1)
    heroGrad:SetTexture(W8)
    heroGrad:SetVertexColor(PAL.panel[1], PAL.panel[2], PAL.panel[3], 1)
    R.heroGrad = heroGrad

    -- Portrait
    local PORTRAIT = 56
    local portraitFrame = CreateFrame("Frame", nil, hero)
    portraitFrame:SetSize(PORTRAIT, PORTRAIT)
    portraitFrame:SetPoint("TOPLEFT", hero, "TOPLEFT", S20, -S20)

    local portraitBg = portraitFrame:CreateTexture(nil, "BACKGROUND")
    portraitBg:SetAllPoints()
    R.portraitBg = portraitBg

    local portrait = portraitFrame:CreateTexture(nil, "ARTWORK")
    portrait:SetSize(PORTRAIT - 6, PORTRAIT - 6)
    portrait:SetPoint("CENTER")
    portrait:SetTexture("Interface\\WORLDSTATEFRAME\\Icons-Classes")
    portrait:SetTexCoord(0, 0.25, 0, 0.25)
    R.portrait = portrait

    -- Name + meta
    local nameFs = MakeText(hero, nil, F_HEAD, "OUTLINE", PAL.text1)
    nameFs:SetPoint("BOTTOMLEFT", portraitFrame, "RIGHT", S12, 4)
    R.heroName = nameFs

    local subFs = MakeText(hero, nil, F_CAPTION, "", PAL.text2)
    subFs:SetPoint("TOPLEFT", portraitFrame, "RIGHT", S12, -4)
    R.heroSub = subFs

    -- M+ Rating, sitting under the spec/ilvl line on the left.
    -- Lives here (not next to the MPI Score on the right) so the
    -- right side stays focused on the unified MPI tier system —
    -- and so the player's two key identifiers (name + rating) are
    -- visually grouped together.
    local mpRatingFs = MakeText(hero, "", F_CAPTION, "OUTLINE", PAL.text1)
    mpRatingFs:SetPoint("TOPLEFT", subFs, "BOTTOMLEFT", 0, -S6)
    R.mpRatingFs = mpRatingFs

    -- Rating block (right side)
    local RATING_W = 220
    local ratingBlock = CreateFrame("Frame", nil, hero)
    ratingBlock:SetSize(RATING_W, 78)
    ratingBlock:SetPoint("TOPRIGHT", hero, "TOPRIGHT", -S20, -S16)

    -- Hero rating block.
    -- Primary: MPI Score (0-100) — same metric the website tier
    -- system is built on, same metric the journey bar tracks.
    -- One score, one tier letter, no parallel systems.
    -- Secondary: M+ Rating shown small underneath as informational
    -- (it's still what other players see in-game).
    local ratingLbl = MakeSectionLabel(ratingBlock, "MPI SCORE")
    ratingLbl:SetPoint("TOPRIGHT", ratingBlock, "TOPRIGHT", 0, 0)

    local ratingFs = MakeText(ratingBlock, "—", F_DISPLAY, "OUTLINE", PAL.text4)
    ratingFs:SetPoint("TOPRIGHT", ratingLbl, "BOTTOMRIGHT", 0, -2)
    ratingFs:SetJustifyH("RIGHT")
    R.ratingFs = ratingFs

    local tierFs = MakeText(ratingBlock, "—", F_SUB, "OUTLINE", PAL.text3)
    tierFs:SetPoint("TOPRIGHT", ratingFs, "BOTTOMRIGHT", 0, -2)
    R.tierFs = tierFs

    -- Affix pill row (top-right under rating)
    local affixRow = CreateFrame("Frame", nil, hero)
    affixRow:SetHeight(26)
    affixRow:SetPoint("TOPRIGHT", ratingBlock, "BOTTOMRIGHT", 0, -S10)
    affixRow:SetWidth(380)
    R.affixRow = affixRow
    SP._affixPills = {}

    -- ─── SEASON JOURNEY BAR ────────────────────────────
    -- Centerpiece. Sits across the bottom half of the hero,
    -- spans the full inner width with tier checkpoints and
    -- a glowing leading edge.
    local journey = CreateFrame("Frame", nil, hero)
    journey:SetHeight(44)
    journey:SetPoint("BOTTOMLEFT",  hero, "BOTTOMLEFT",  S20, S20 + 24)
    journey:SetPoint("BOTTOMRIGHT", hero, "BOTTOMRIGHT", -S20, S20 + 24)
    R.journey = journey

    -- Hairline divider above the journey bar — visually separates
    -- the identity zone (portrait/name/rating) from the progress
    -- zone, so the hero reads as two stacked tiers.
    local heroDivider = hero:CreateTexture(nil, "OVERLAY")
    heroDivider:SetHeight(1)
    heroDivider:SetPoint("BOTTOMLEFT",  journey, "TOPLEFT",  0, S10)
    heroDivider:SetPoint("BOTTOMRIGHT", journey, "TOPRIGHT", 0, S10)
    heroDivider:SetTexture(W8)
    heroDivider:SetColorTexture(PAL.border[1], PAL.border[2], PAL.border[3], 0.45)

    local journeyLbl = MakeSectionLabel(journey, "SEASON JOURNEY")
    journeyLbl:SetPoint("TOPLEFT", journey, "TOPLEFT", 0, 0)

    local journeyMeta = MakeText(journey, "", F_CAPTION, "", PAL.text3)
    journeyMeta:SetPoint("TOPRIGHT", journey, "TOPRIGHT", 0, -1)
    journeyMeta:SetJustifyH("RIGHT")
    R.journeyMeta = journeyMeta

    -- bar itself
    local bar = CreateFrame("Frame", nil, journey, "BackdropTemplate")
    bar:SetHeight(12)
    bar:SetPoint("LEFT",  journey, "LEFT",  0, -8)
    bar:SetPoint("RIGHT", journey, "RIGHT", 0, -8)
    bar:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    bar:SetBackdropColor(0.04, 0.04, 0.06, 1)
    bar:SetBackdropBorderColor(PAL.border[1], PAL.border[2], PAL.border[3], 0.8)
    R.journeyBar = bar

    local barFill = bar:CreateTexture(nil, "ARTWORK")
    barFill:SetHeight(10)
    barFill:SetPoint("LEFT", bar, "LEFT", 1, 0)
    barFill:SetTexture(W8)
    R.journeyFill = barFill

    -- pulsing leading edge — a thin vertical cursor that
    -- sits INSIDE the bar at the right edge of the fill.
    -- Right-to-right anchor + height clipped to the bar's
    -- inner area keeps it from poking out top/bottom/right.
    local pulse = bar:CreateTexture(nil, "OVERLAY")
    pulse:SetSize(3, 8)
    pulse:SetPoint("RIGHT", barFill, "RIGHT", 0, 0)
    pulse:SetTexture(W8)
    pulse:SetBlendMode("ADD")
    pulse:SetAlpha(0.0)
    R.journeyPulse = pulse

    local ag = pulse:CreateAnimationGroup()
    ag:SetLooping("REPEAT")
    local fadeIn  = ag:CreateAnimation("Alpha")
    fadeIn:SetFromAlpha(0.0); fadeIn:SetToAlpha(0.85)
    fadeIn:SetDuration(0.85); fadeIn:SetOrder(1)
    local fadeOut = ag:CreateAnimation("Alpha")
    fadeOut:SetFromAlpha(0.85); fadeOut:SetToAlpha(0.0)
    fadeOut:SetDuration(0.85); fadeOut:SetOrder(2)
    R.journeyAnim = ag

    -- Tier checkpoint pips below the bar.
    SP._tierPips = {}
    local pipsRow = CreateFrame("Frame", nil, journey)
    pipsRow:SetHeight(14)
    pipsRow:SetPoint("TOPLEFT",  bar, "BOTTOMLEFT",  0, -2)
    pipsRow:SetPoint("TOPRIGHT", bar, "BOTTOMRIGHT", 0, -2)
    R.pipsRow = pipsRow
    -- Pips are recreated on Refresh so they sit at correct
    -- proportional positions for whatever bar width the user
    -- ends up with. Build them once now using current width;
    -- Refresh re-anchors using the live width.
    for i = 2, #TIERS do  -- skip the 0 floor
        local pip = pipsRow:CreateTexture(nil, "ARTWORK")
        pip:SetSize(2, 6)
        pip:SetTexture(W8)
        pip:SetColorTexture(PAL.text3[1], PAL.text3[2], PAL.text3[3], 0.7)
        pip:SetPoint("TOP", pipsRow, "TOPLEFT", 0, 0)

        local lbl = MakeText(pipsRow, TIERS[i][3], F_MICRO, "OUTLINE", PAL.text4)
        lbl:SetPoint("TOP", pip, "BOTTOM", 0, -1)

        SP._tierPips[#SP._tierPips + 1] = { pip = pip, lbl = lbl, floor = TIERS[i][1] }
    end

    -- ─── DUNGEON GRID ───────────────────────────────────
    local dgnSection = MakePanel(body, { bg = PAL.panel })
    dgnSection:SetSize(INNER_W, CFG.DGN_H)
    dgnSection:SetPoint("TOPLEFT", hero, "BOTTOMLEFT", 0, -CFG.GAP)
    R.dgnSection = dgnSection

    local dgnHint = StyleSectionHeader(dgnSection, "DUNGEON BESTS")
    R.dgnHint = dgnHint

    local dgnEmpty = MakeText(dgnSection,
        "Season dungeon list not loaded yet — try /reload after entering the world.",
        F_BODY, "", PAL.text3)
    dgnEmpty:SetPoint("CENTER", dgnSection, "CENTER", 0, 0)
    dgnEmpty:Hide()
    R.dgnEmpty = dgnEmpty

    SP._dungeonCards = {}
    local DGN_TOP_PAD = 44   -- clears the styled-header divider above
    local DGN_INNER_PAD = S16
    local DGN_GAP = S12
    local CARDS_W = INNER_W - 2 * DGN_INNER_PAD
    local CARD_W = floor((CARDS_W - 3 * DGN_GAP) / 4)
    local CARD_H = floor((CFG.DGN_H - DGN_TOP_PAD - DGN_GAP - S12) / 2)
    for i = 1, 8 do
        local col = (i - 1) % 4
        local row = floor((i - 1) / 4)
        local card = CreateFrame("Frame", nil, dgnSection, "BackdropTemplate")
        card:SetSize(CARD_W, CARD_H)
        card:SetPoint("TOPLEFT", dgnSection, "TOPLEFT",
            DGN_INNER_PAD + col * (CARD_W + DGN_GAP),
            -DGN_TOP_PAD - row * (CARD_H + DGN_GAP))
        card:SetBackdrop({
            bgFile = W8, edgeFile = W8, edgeSize = 1,
            insets = { left = 1, right = 1, top = 1, bottom = 1 },
        })
        card:SetBackdropColor(unpack(PAL.well))
        card:SetBackdropBorderColor(unpack(PAL.border))

        -- Card splits visually into TWO zones:
        --   Left ~3/4 — dungeon background art with a soft top-down
        --                vignette. Holds the name + key level + fill bar.
        --   Right ~1/4 — solid black panel. Holds the result text on top
        --                with the score number directly beneath it.
        local ART_FRAC   = 0.75
        local artRightX  = floor(CARD_W * ART_FRAC)         -- art ends here
        local fadeStartX = floor(CARD_W * (ART_FRAC - 0.20))-- soft fade begins ~20% before the cut

        -- Background art uses the "ui-ej-midnight" Blizzard
        -- atlas — same on every card for visual consistency
        -- and on-brand for MidnightUI. Atlas handles its own
        -- texture coordinates, so no SetTexCoord here.
        local art = card:CreateTexture(nil, "BACKGROUND", nil, 1)
        art:SetPoint("TOPLEFT",     card, "TOPLEFT",      1, -1)
        art:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -1,  1)
        art:SetAtlas("ui-ej-midnight", false)
        art:SetAlpha(0.85)
        card._art = art

        -- Vertical vignette over the WHOLE card. Top darkened more
        -- than bottom (under WoW's vertical-gradient convention,
        -- color1 = top, color2 = bottom) so the dungeon name has
        -- contrast and the bottom scenery stays visible.
        local fade = card:CreateTexture(nil, "BACKGROUND", nil, 2)
        fade:SetPoint("TOPLEFT",     card, "TOPLEFT",      1, -1)
        fade:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -1,  1)
        fade:SetTexture(W8)
        Gradient(fade, "VERTICAL", 0, 0, 0, 0.55, 0, 0, 0, 0.20)

        -- horizontal blend zone — fades the art smoothly into the
        -- black right panel so the cut doesn't look harsh.
        local blend = card:CreateTexture(nil, "BACKGROUND", nil, 3)
        blend:SetPoint("TOPLEFT",     card, "TOPLEFT",   fadeStartX, -1)
        blend:SetPoint("BOTTOMLEFT",  card, "BOTTOMLEFT",fadeStartX,  1)
        blend:SetWidth(artRightX - fadeStartX)
        blend:SetTexture(W8)
        Gradient(blend, "HORIZONTAL", 0, 0, 0, 0, 0, 0, 0, 1)

        -- solid black right zone
        local blackRight = card:CreateTexture(nil, "BACKGROUND", nil, 3)
        blackRight:SetPoint("TOPLEFT",     card, "TOPLEFT",     artRightX,  -1)
        blackRight:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -1, 1)
        blackRight:SetTexture(W8)
        blackRight:SetColorTexture(0, 0, 0, 1)

        -- dungeon name (top of art zone)
        local nm = MakeText(card, nil, F_CAPTION, "OUTLINE", PAL.text1)
        nm:SetPoint("TOPLEFT", card, "TOPLEFT", S10, -S8)
        nm:SetWidth(artRightX - S10 - S6)
        nm:SetJustifyH("LEFT")
        nm:SetWordWrap(false)
        card._name = nm

        -- key level (huge, bottom-left of art zone)
        local lvl = MakeText(card, "--", F_HEAD + 6, "OUTLINE", PAL.text4)
        lvl:SetPoint("BOTTOMLEFT", card, "BOTTOMLEFT", S10, S16)
        card._lvl = lvl

        -- right-zone content: result text on top, score beneath.
        -- Both centered horizontally inside the black right panel.
        local rightZoneW = CARD_W - artRightX
        local res = MakeText(card, "", F_MICRO, "OUTLINE", PAL.text3)
        res:SetPoint("TOP", card, "TOPRIGHT", -rightZoneW * 0.5, -S12)
        res:SetWidth(rightZoneW - S6)
        res:SetJustifyH("CENTER")
        card._res = res

        local scoreVal = MakeText(card, "--", F_TITLE + 2, "OUTLINE", PAL.text4)
        -- Negative Y offset puts the score visibly BELOW the result
        -- text. Previous (0, S4) was placing it 4px above the bottom
        -- of `res`, which overlapped them — they only looked stacked
        -- because the text rendered around the overlap. Real spacing now.
        scoreVal:SetPoint("TOP", res, "BOTTOM", 0, -S10)
        scoreVal:SetWidth(rightZoneW - S6)
        scoreVal:SetJustifyH("CENTER")
        card._score = scoreVal

        -- per-card key fill bar (the per-dungeon "fill"). Slightly
        -- taller than before (6px) so it reads at a glance, with a
        -- darker well so the fill pops in tier color.
        local fillBg = card:CreateTexture(nil, "ARTWORK")
        fillBg:SetHeight(6)
        fillBg:SetPoint("BOTTOMLEFT",  card, "BOTTOMLEFT",  1, 1)
        fillBg:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -1, 1)
        Bg(fillBg, { 0.06, 0.06, 0.08, 0.95 })
        card._fillBg = fillBg

        local fill = card:CreateTexture(nil, "OVERLAY", nil, 1)
        fill:SetHeight(6)
        fill:SetPoint("BOTTOMLEFT", fillBg, "BOTTOMLEFT", 0, 0)
        fill:SetWidth(0)
        fill:SetTexture(W8)
        card._fill = fill

        card:Hide()
        SP._dungeonCards[i] = card
    end

    -- ─── BOTTOM ROW (Vault | Dimensions) ────────────────
    local SIDE_W = floor((INNER_W - CFG.GAP) / 2)

    -- Weekly Vault (left)
    local vault = MakePanel(body, { bg = PAL.panel })
    vault:SetSize(SIDE_W, CFG.BOT_H)
    vault:SetPoint("TOPLEFT", dgnSection, "BOTTOMLEFT", 0, -CFG.GAP)
    R.vault = vault

    R.vaultMeta = StyleSectionHeader(vault, "WEEKLY VAULT")

    -- 3×3 Great Vault grid: rows = M+ / Raid / World.
    -- Columns = the three reward slots within each category.
    -- Left label column carries the row name + N/3 unlocked counter.
    SP._vaultGrid = { rows = {} }
    local PAD_L      = S12
    local LABEL_W    = 60
    local LABEL_GAP  = S6
    local CELL_GAP   = S6
    local ROW_GAP    = S6
    local GRID_TOP   = 36
    local GRID_BOT   = S12
    local rowsArea   = CFG.BOT_H - GRID_TOP - GRID_BOT
    local ROW_H      = floor((rowsArea - 2 * ROW_GAP) / 3)
    local cellsAreaW = SIDE_W - 2 * PAD_L - LABEL_W - LABEL_GAP - 2 * CELL_GAP
    local CELL_W     = floor(cellsAreaW / 3)

    local ROW_DEFS = {
        { key = "dungeon", label = "M+",     accent = PAL.tStrong },
        { key = "raid",    label = "RAID",   accent = PAL.tElite  },
        { key = "world",   label = "WORLD",  accent = PAL.tSolid  },
    }

    for r, def in ipairs(ROW_DEFS) do
        local rowFrame = CreateFrame("Frame", nil, vault)
        rowFrame:SetHeight(ROW_H)
        rowFrame:SetPoint("TOPLEFT",  vault, "TOPLEFT",  PAD_L,  -GRID_TOP - (r - 1) * (ROW_H + ROW_GAP))
        rowFrame:SetPoint("TOPRIGHT", vault, "TOPRIGHT", -PAD_L, -GRID_TOP - (r - 1) * (ROW_H + ROW_GAP))

        -- Left label block (category name + count)
        local labelBox = CreateFrame("Frame", nil, rowFrame)
        labelBox:SetSize(LABEL_W, ROW_H)
        labelBox:SetPoint("TOPLEFT", rowFrame, "TOPLEFT", 0, 0)

        local labelName = MakeText(labelBox, def.label, F_TITLE, "OUTLINE", PAL.text2)
        labelName:SetPoint("LEFT", labelBox, "LEFT", 0, 6)
        labelName:SetJustifyH("LEFT")

        local labelCount = MakeText(labelBox, "0 / 3", F_CAPTION, "", PAL.text4)
        labelCount:SetPoint("LEFT", labelBox, "LEFT", 0, -8)
        labelCount:SetJustifyH("LEFT")

        local rowEntry = {
            key = def.key,
            accent = def.accent,
            frame = rowFrame,
            labelName = labelName,
            labelCount = labelCount,
            cells = {},
        }

        for c = 1, 3 do
            local cell = CreateFrame("Frame", nil, rowFrame, "BackdropTemplate")
            cell:SetSize(CELL_W, ROW_H)
            cell:SetPoint("TOPLEFT", rowFrame, "TOPLEFT",
                LABEL_W + LABEL_GAP + (c - 1) * (CELL_W + CELL_GAP), 0)
            cell:SetBackdrop({
                bgFile = W8, edgeFile = W8, edgeSize = 1,
                insets = { left = 1, right = 1, top = 1, bottom = 1 },
            })
            cell:SetBackdropColor(unpack(PAL.well))
            cell:SetBackdropBorderColor(unpack(PAL.border))

            local glow = cell:CreateTexture(nil, "BACKGROUND", nil, 1)
            glow:SetPoint("TOPLEFT",     cell, "TOPLEFT",      1, -1)
            glow:SetPoint("BOTTOMRIGHT", cell, "BOTTOMRIGHT", -1,  1)
            glow:SetTexture(W8)
            glow:SetBlendMode("ADD")
            glow:SetAlpha(0)

            local big = MakeText(cell, "—", F_TITLE + 4, "OUTLINE", PAL.text4)
            big:SetPoint("CENTER", cell, "CENTER", 0, 6)
            big:SetWidth(CELL_W - 8)
            big:SetJustifyH("CENTER")
            big:SetJustifyV("MIDDLE")
            big:SetWordWrap(false)

            local sub = MakeText(cell, "", F_MICRO, "", PAL.text4)
            sub:SetPoint("TOP", big, "BOTTOM", 0, -1)
            sub:SetWidth(CELL_W - 8)
            sub:SetJustifyH("CENTER")
            sub:SetJustifyV("MIDDLE")
            sub:SetWordWrap(false)

            rowEntry.cells[c] = { frame = cell, glow = glow, big = big, sub = sub }
        end

        SP._vaultGrid.rows[r] = rowEntry
    end

    -- Legacy compatibility — keep the old reference table empty so
    -- any unmodified call site doesn't blow up.
    SP._vaultSlots = {}
    SP._vaultPips = {}


    -- Dimensions (right)
    local dims = MakePanel(body, { bg = PAL.panel })
    dims:SetSize(INNER_W - SIDE_W - CFG.GAP, CFG.BOT_H)
    dims:SetPoint("TOPLEFT", vault, "TOPRIGHT", CFG.GAP, 0)
    R.dims = dims

    R.dimsMeta = StyleSectionHeader(dims, "MIDNIGHTUI DIMENSIONS")

    -- Centered empty-state message that replaces the bars when
    -- there's nothing to show. Two-line: a headline and a hint.
    local dimsEmpty = CreateFrame("Frame", nil, dims)
    dimsEmpty:SetPoint("TOPLEFT",     dims, "TOPLEFT",      S16, -44)
    dimsEmpty:SetPoint("BOTTOMRIGHT", dims, "BOTTOMRIGHT", -S16, 40)
    dimsEmpty:Hide()
    R.dimsEmpty = dimsEmpty

    local emptyHead = MakeText(dimsEmpty, "", F_BODY, "OUTLINE", PAL.text2)
    emptyHead:SetPoint("CENTER", dimsEmpty, "CENTER", 0, 8)
    emptyHead:SetJustifyH("CENTER")
    R.dimsEmptyHead = emptyHead

    local emptyHint = MakeText(dimsEmpty, "", F_CAPTION, "", PAL.text3)
    emptyHint:SetPoint("TOP",   emptyHead, "BOTTOM", 0, -4)
    emptyHint:SetPoint("LEFT",  dimsEmpty, "LEFT",  0, 0)
    emptyHint:SetPoint("RIGHT", dimsEmpty, "RIGHT", 0, 0)
    emptyHint:SetJustifyH("CENTER")
    R.dimsEmptyHint = emptyHint

    SP._dimRows = {}
    local DIM_TOP = 44   -- bumped to clear the new styled-header divider
    local DIM_ROW_H = 32
    local DIM_BAR_H = 14
    for i, def in ipairs(DIM_DEFS) do
        local row = CreateFrame("Frame", nil, dims)
        row:SetHeight(DIM_ROW_H)
        row:SetPoint("TOPLEFT",  dims, "TOPLEFT",  S16, -DIM_TOP - (i - 1) * DIM_ROW_H)
        row:SetPoint("RIGHT",    dims, "RIGHT",   -S16, 0)

        local lbl = MakeText(row, def.label, F_CAPTION, "OUTLINE", PAL.text2)
        lbl:SetPoint("LEFT", row, "LEFT", 0, 1)
        lbl:SetWidth(120)
        lbl:SetJustifyH("LEFT")

        local barBg = row:CreateTexture(nil, "BACKGROUND")
        barBg:SetHeight(DIM_BAR_H)
        barBg:SetPoint("LEFT",  lbl, "RIGHT", S8, 0)
        barBg:SetPoint("RIGHT", row, "RIGHT", -52, 0)
        Bg(barBg, { 0.06, 0.06, 0.08, 0.95 })

        local barFill = row:CreateTexture(nil, "ARTWORK")
        barFill:SetHeight(DIM_BAR_H)
        barFill:SetPoint("LEFT", barBg, "LEFT", 0, 0)
        barFill:SetWidth(0)
        barFill:SetTexture(W8)

        local val = MakeText(row, "--", F_SUB, "OUTLINE", PAL.text4)
        val:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        val:SetWidth(46)
        val:SetJustifyH("RIGHT")

        SP._dimRows[i] = { key = def.key, lbl = lbl, barBg = barBg, barFill = barFill, val = val }
    end

    -- Companion sync chip (bottom of dims panel)
    local sync = CreateFrame("Frame", nil, dims, "BackdropTemplate")
    sync:SetHeight(22)
    sync:SetPoint("BOTTOMLEFT",  dims, "BOTTOMLEFT",  S16, S12)
    sync:SetPoint("BOTTOMRIGHT", dims, "BOTTOMRIGHT", -S16, S12)
    sync:SetBackdrop({
        bgFile = W8, edgeFile = W8, edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    sync:SetBackdropColor(0.04, 0.04, 0.06, 0.7)
    sync:SetBackdropBorderColor(PAL.border[1], PAL.border[2], PAL.border[3], 0.8)
    R.syncBlock = sync

    local syncDot = sync:CreateTexture(nil, "OVERLAY")
    syncDot:SetSize(6, 6)
    syncDot:SetPoint("LEFT", sync, "LEFT", S8, 0)
    syncDot:SetTexture(W8)
    R.syncDot = syncDot

    local syncFs = MakeText(sync, "", F_MICRO, "OUTLINE", PAL.text2)
    syncFs:SetPoint("LEFT",  syncDot, "RIGHT", S6, 0)
    syncFs:SetPoint("RIGHT", sync,    "RIGHT", -S8, 0)
    syncFs:SetJustifyH("LEFT")
    syncFs:SetWordWrap(false)
    R.syncFs = syncFs

    f:SetScript("OnShow", function(self)
        self:EnableKeyboard(true)
        SP.Refresh()
    end)
    f:SetScript("OnHide", function(self)
        self:EnableKeyboard(false)
    end)
    return f
end

-- ── Refresh ─────────────────────────────────────────────

local function ApplyTierPips(R, score)
    -- Re-anchor pip positions using the live bar width and
    -- color them based on whether the player has passed the
    -- threshold yet ("ignited" once reached).
    local bar = R.journeyBar
    if not bar then return end
    local width = bar:GetWidth()
    if width <= 0 then
        -- Widget widths haven't computed yet (race on first show).
        -- Defer one frame and retry so the markers actually anchor.
        if C_Timer and C_Timer.After then
            C_Timer.After(0.05, function()
                if bar:GetWidth() > 0 then ApplyTierPips(R, score) end
            end)
        end
        return
    end
    for _, p in ipairs(SP._tierPips) do
        local frac = min(1, p.floor / TIER_MAX)
        local x = frac * width
        p.pip:ClearAllPoints()
        p.pip:SetPoint("TOP", R.pipsRow, "TOPLEFT", x - 1, 0)
        p.lbl:ClearAllPoints()
        p.lbl:SetPoint("TOP", p.pip, "BOTTOM", 0, -1)
        if (score or 0) >= p.floor then
            p.pip:SetColorTexture(PAL.text2[1], PAL.text2[2], PAL.text2[3], 0.95)
            Color(p.lbl, PAL.text2)
        else
            p.pip:SetColorTexture(PAL.text4[1], PAL.text4[2], PAL.text4[3], 0.85)
            Color(p.lbl, PAL.text4)
        end
    end
end

local function ApplyAffixPills(R, affixes)
    -- Recreate as needed; reuse what exists.
    for _, p in ipairs(SP._affixPills) do p:Hide() end
    if not affixes or #affixes == 0 then return end
    local x = 0
    local row = R.affixRow
    for i, a in ipairs(affixes) do
        local pill = SP._affixPills[i]
        if not pill then
            pill = CreateFrame("Frame", nil, row, "BackdropTemplate")
            pill:SetBackdrop({
                bgFile = W8, edgeFile = W8, edgeSize = 1,
                insets = { left = 1, right = 1, top = 1, bottom = 1 },
            })
            pill:SetHeight(24)
            pill:SetBackdropColor(0.06, 0.06, 0.09, 0.95)
            pill:SetBackdropBorderColor(PAL.text2[1], PAL.text2[2], PAL.text2[3], 0.45)

            local ic = pill:CreateTexture(nil, "ARTWORK")
            ic:SetSize(16, 16)
            ic:SetPoint("LEFT", pill, "LEFT", S6, 0)
            ic:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            pill._icon = ic

            local fs = MakeText(pill, "", F_CAPTION, "OUTLINE", PAL.text1)
            fs:SetPoint("LEFT",  ic,   "RIGHT", S6, 0)
            fs:SetPoint("RIGHT", pill, "RIGHT", -S8, 0)
            fs:SetJustifyH("LEFT")
            pill._fs = fs

            SP._affixPills[i] = pill
        end
        pill._fs:SetText(a.name or "?")
        pill._icon:SetTexture(a.file or "Interface\\Icons\\INV_Misc_QuestionMark")
        local w = pill._fs:GetStringWidth() + 16 + S6 + S6 + S8
        pill:SetWidth(max(72, w))
        pill:ClearAllPoints()
        pill:SetPoint("TOPRIGHT", row, "TOPRIGHT", -x, 0)
        pill:Show()
        x = x + pill:GetWidth() + S6
    end
end

function SP.Refresh()
    local R = SP._refs
    if not R.panel then return end

    local d = GatherData()
    local cR, cG, cB = ClassRGB(d.classToken)

    -- HEADER
    Gradient(R.headerGrad, "HORIZONTAL", cR, cG, cB, 0.18, cR, cG, cB, 0.0)
    -- Subtle class-tint hairline. Was previously alpha 0.85 which,
    -- against the now-neutral outer frame, read as a loud second
    -- border. Dropping to 0.30 keeps the personalization cue but
    -- lets the outer frame breathe.
    Bg(R.headerLine, { cR, cG, cB, 0.30 })

    -- HERO — class-tinted vertical wash
    Gradient(R.heroGrad, "VERTICAL", cR, cG, cB, 0.10, 0.04, 0.04, 0.06, 0.0)

    -- portrait
    Bg(R.portraitBg, { cR * 0.55, cG * 0.55, cB * 0.55, 0.95 })
    if d.classToken and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[d.classToken] then
        R.portrait:SetTexCoord(unpack(CLASS_ICON_TCOORDS[d.classToken]))
    end

    R.heroName:SetText(("|cff%02x%02x%02x%s|r"):format(cR * 255, cG * 255, cB * 255, d.myName))
    local sub = {}
    if d.specName then sub[#sub + 1] = d.specName end
    if d.ilvl then sub[#sub + 1] = "ilvl " .. d.ilvl end
    if d.myLevel and d.myLevel > 0 then sub[#sub + 1] = "Lvl " .. d.myLevel end
    R.heroSub:SetText(table.concat(sub, "  ·  "))

    -- RATING — MPI Score primary, M+ Rating as a small secondary line.
    -- The legacy "5 timed runs to rank" gate was removed: the role-aware
    -- model already gates on Verified Peak (≥2 timed at the same key
    -- level) so single-run data can't masquerade as a stable score.
    -- Adding a second 5-run gate on top diverged from the dashboard +
    -- in-game pill (which both happily show the role's actual number).
    local mpi = d.overallMpi or 0
    local _, tierColor, tierName = ScoreTier(mpi)

    if mpi > 0 then
        R.ratingFs:SetText(tostring(floor(mpi + 0.5)))
        Color(R.ratingFs, tierColor)
        R.tierFs:SetText(tierName)
        Color(R.tierFs, tierColor)
    else
        R.ratingFs:SetText("—")
        Color(R.ratingFs, PAL.text4)
        local activeRole = d.activeRole
        local roleWord = activeRole == "TANK" and "TANK"
                      or activeRole == "HEALER" and "HEALER"
                      or activeRole == "AUG" and "AUG"
                      or activeRole and "DPS"
                      or nil
        R.tierFs:SetText(roleWord and ("NO " .. roleWord .. " RUNS YET") or "NO DATA YET")
        Color(R.tierFs, PAL.text3)
    end
    local isUnranked = mpi <= 0

    if d.mpScore and d.mpScore > 0 then
        R.mpRatingFs:SetText(("M+ Rating  %d"):format(d.mpScore))
        Color(R.mpRatingFs, PAL.text3)
    else
        R.mpRatingFs:SetText("")
    end

    -- AFFIXES
    ApplyAffixPills(R, d.affixes)

    -- JOURNEY BAR — tracks the same MPI Score the hero shows.
    -- When unranked (< 5 timed runs), the bar stays completely
    -- empty: no fill, no pulse, no tier color. The bar only
    -- "comes alive" once the player crosses the rank threshold.
    local barW = R.journeyBar:GetWidth()
    if barW <= 0 then barW = CFG.WIDTH - 2 * CFG.PAD - 2 * S20 end
    local frac = isUnranked and 0 or min(1, max(0, mpi / TIER_MAX))
    local fillW = (frac > 0) and max(2, barW * frac - 2) or 0
    R.journeyFill:SetWidth(fillW)
    Gradient(R.journeyFill, "HORIZONTAL",
        tierColor[1] * 0.55, tierColor[2] * 0.55, tierColor[3] * 0.55, 0.95,
        tierColor[1],        tierColor[2],        tierColor[3],        1.0)
    R.journeyPulse:SetColorTexture(tierColor[1], tierColor[2], tierColor[3], 1)
    if not isUnranked and mpi > 0 then
        R.journeyAnim:Play()
    else
        R.journeyAnim:Stop()
        R.journeyPulse:SetAlpha(0)
    end

    if isUnranked then
        local need = RUNS_TO_RANK - timedRuns
        R.journeyMeta:SetText(("%d timed %s until tier unlocks"):format(
            need, need == 1 and "key" or "keys"))
        Color(R.journeyMeta, PAL.text3)
    else
        local nextFloor, _, nextLabel = NextTier(mpi)
        if nextFloor then
            local need = nextFloor - mpi
            R.journeyMeta:SetText(("%.0f to %s"):format(need, nextLabel))
            Color(R.journeyMeta, PAL.text2)
        else
            R.journeyMeta:SetText("S TIER")
            Color(R.journeyMeta, tierColor)
        end
    end
    -- Tier checkpoints stay dim while unranked (pass 0 so none ignite).
    ApplyTierPips(R, isUnranked and 0 or mpi)

    -- DUNGEON GRID
    local visibleCount = 0
    for _, dg in ipairs(d.dungeons or {}) do
        if (dg.best or 0) > 0 or (dg.score or 0) > 0 then visibleCount = visibleCount + 1 end
    end
    if not d.dungeons or #d.dungeons == 0 then
        R.dgnEmpty:Show()
        R.dgnHint:SetText("")
        for _, c in ipairs(SP._dungeonCards) do c:Hide() end
    else
        R.dgnEmpty:Hide()
        if visibleCount > 0 then
            R.dgnHint:SetText(("%d / %d completed"):format(visibleCount, #d.dungeons))
            Color(R.dgnHint, PAL.text2)
        else
            R.dgnHint:SetText("ZERO RUNS BANKED")
            Color(R.dgnHint, PAL.text3)
        end

        for i, card in ipairs(SP._dungeonCards) do
            local dg = d.dungeons[i]
            if dg then
                card._name:SetText(dg.name or "Unknown")


                local lvl = dg.best or 0
                local kc  = KeyColor(lvl)
                if lvl > 0 then
                    card._lvl:SetText("+" .. lvl)
                    Color(card._lvl, kc)
                    card:SetBackdropBorderColor(kc[1] * 0.40, kc[2] * 0.40, kc[3] * 0.40, 0.65)
                    if dg.overTime then
                        card._res:SetText("OVER TIME")
                        Color(card._res, PAL.warn)
                    else
                        card._res:SetText("TIMED")
                        Color(card._res, PAL.good)
                    end
                else
                    card._lvl:SetText("--")
                    Color(card._lvl, PAL.text4)
                    card:SetBackdropBorderColor(unpack(PAL.border))
                    card._res:SetText("NO RUNS")
                    Color(card._res, PAL.text4)
                end

                if (dg.score or 0) > 0 then
                    card._score:SetText(tostring(floor(dg.score + 0.5)))
                    Color(card._score, PAL.text1)
                else
                    card._score:SetText("--")
                    Color(card._score, PAL.text4)
                end

                -- per-card key fill (best level / KEY_FILL_CEIL)
                local fillFrac = min(1, lvl / KEY_FILL_CEIL)
                local bgW = card._fillBg:GetWidth()
                if bgW <= 0 then bgW = card:GetWidth() - 2 end
                card._fill:SetWidth(max(0, bgW * fillFrac))
                if lvl > 0 then
                    card._fill:SetColorTexture(kc[1], kc[2], kc[3], 0.95)
                else
                    card._fill:SetColorTexture(PAL.border[1], PAL.border[2], PAL.border[3], 0.4)
                end

                card:Show()
            else
                card:Hide()
            end
        end
    end

    -- VAULT — 3×3 grid: rows are M+ / Raid / World, columns
    -- are the three reward slots inside each category. Pure
    -- C_WeeklyRewards, no M+ keystone-threshold proxy.
    local function formatRaidDifficulty(diffId)
        if not diffId or diffId == 0 then return nil end
        if _G.GetDifficultyInfo then
            local ok, name = pcall(_G.GetDifficultyInfo, diffId)
            if ok and type(name) == "string" and name ~= "" then return name end
        end
        if     diffId == 17 then return "Raid Finder"
        elseif diffId == 14 then return "Normal"
        elseif diffId == 15 then return "Heroic"
        elseif diffId == 16 then return "Mythic"
        end
        return tostring(diffId)
    end
    local THRESHOLD_UNIT = { dungeon = "RUNS", raid = "BOSSES", world = "QUESTS" }
    local gv = d.greatVault
    local totalUnlocked, totalSlots = 0, 0
    if gv then
        for _, k in ipairs({"dungeon", "raid", "world"}) do
            local cat = gv[k]
            if cat then
                totalUnlocked = totalUnlocked + (cat.unlocked or 0)
                totalSlots    = totalSlots    + (cat.total or 0)
            end
        end
    end
    if totalSlots > 0 then
        R.vaultMeta:SetText(("%d / %d UNLOCKED"):format(totalUnlocked, totalSlots))
        Color(R.vaultMeta, totalUnlocked > 0 and PAL.text2 or PAL.text3)
    else
        R.vaultMeta:SetText("NO DATA")
        Color(R.vaultMeta, PAL.text3)
    end

    for _, row in ipairs(SP._vaultGrid.rows) do
        local cat = gv and gv[row.key] or nil
        local accent = row.accent
        local total = (cat and cat.total) or 3
        local unlocked = (cat and cat.unlocked) or 0
        row.labelCount:SetText(("%d / %d"):format(unlocked, total))
        Color(row.labelCount, unlocked > 0 and PAL.text2 or PAL.text4)
        Color(row.labelName, unlocked > 0 and accent or PAL.text3)

        for c, cellEntry in ipairs(row.cells) do
            local slotData = cat and cat.slots and cat.slots[c]
            local cell = cellEntry.frame
            local glow = cellEntry.glow
            local big = cellEntry.big
            local sub = cellEntry.sub

            if slotData and slotData.unlocked then
                cell:SetBackdropColor(accent[1] * 0.10, accent[2] * 0.10, accent[3] * 0.10, 0.95)
                cell:SetBackdropBorderColor(accent[1] * 0.65, accent[2] * 0.65, accent[3] * 0.65, 0.95)
                glow:SetColorTexture(accent[1], accent[2], accent[3], 0.18)
                glow:SetAlpha(1)

                local primary, secondary
                if row.key == "dungeon" then
                    primary = (slotData.level or 0) > 0 and ("+" .. slotData.level) or "✓"
                    secondary = "KEYSTONE"
                elseif row.key == "raid" then
                    primary = formatRaidDifficulty(slotData.level) or "✓"
                    secondary = "RAID"
                else
                    primary = (slotData.level or 0) > 0 and ("Tier " .. slotData.level) or "✓"
                    secondary = "WORLD"
                end
                big:SetText(primary)
                Color(big, accent)
                sub:SetText(secondary)
                Color(sub, PAL.text3)
            elseif slotData then
                cell:SetBackdropColor(unpack(PAL.well))
                cell:SetBackdropBorderColor(PAL.border[1], PAL.border[2], PAL.border[3], 0.7)
                glow:SetAlpha(0)
                local thr = tonumber(slotData.threshold) or 0
                big:SetText(thr > 0 and tostring(thr) or "—")
                Color(big, PAL.text3)
                sub:SetText(THRESHOLD_UNIT[row.key] or "NEEDED")
                Color(sub, PAL.text4)
            else
                cell:SetBackdropColor(unpack(PAL.well))
                cell:SetBackdropBorderColor(unpack(PAL.border))
                glow:SetAlpha(0)
                big:SetText("—")
                Color(big, PAL.text4)
                sub:SetText("")
            end
        end
    end


    -- DIMENSIONS
    -- Role-aware: only render dims when the active spec has runs.
    -- The legacy local-pooled fallback (d.mpi.dims) was dropped because
    -- it pools every role together, so a Tank in a fresh DPS spec would
    -- see Tank's dims labeled as DPS. Empty state instead.
    local web        = d.webOverlay
    local localDims  = nil   -- legacy; kept as nil so old guards short-circuit
    local localCount = 0
    local useWeb     = web ~= nil
    local useLocal   = false
    local hasData    = useWeb

    if hasData then
        R.dimsEmpty:Hide()
        for _, dr in ipairs(SP._dimRows) do
            dr.barBg:Show(); dr.barFill:Show(); dr.lbl:Show(); dr.val:Show()
        end

        if useWeb then
            local rel = (web.computedAt and web.computedAt > 0)
                and FormatRelative(web.computedAt) or "just now"
            R.dimsMeta:SetText(("WEBSITE  ·  %d RUNS  ·  %s"):format(web.totalRuns, rel))
        else
            R.dimsMeta:SetText(("LOCAL  ·  %d RUN%s"):format(
                localCount, localCount == 1 and "" or "S"))
        end
        Color(R.dimsMeta, PAL.text2)

        -- Map each dim row's key to the right source value.
        local valueFor = {
            awarenessScore   = useWeb and web.avgAwareness   or (localDims and localDims.awarenessScore)   or 0,
            survivalScore    = useWeb and web.avgSurvival    or (localDims and localDims.survivalScore)    or 0,
            outputScore      = useWeb and web.avgOutput      or (localDims and localDims.outputScore)      or 0,
            utilityScore     = useWeb and web.avgUtility     or (localDims and localDims.utilityScore)     or 0,
            consistencyScore = useWeb and web.avgConsistency or (localDims and localDims.consistencyScore) or 0,
        }

        for _, dr in ipairs(SP._dimRows) do
            local v = floor((valueFor[dr.key] or 0) + 0.5)
            local pct = max(0, min(100, v)) / 100
            local bgW = dr.barBg:GetWidth()
            if bgW <= 0 then bgW = 200 end
            dr.barFill:SetWidth(max(1, bgW * pct))
            local cR2, cG2, cB2 = 0.55, 0.55, 0.62
            if _G.MPI and _G.MPI.GetBarColor then
                local r1, g1, b1 = _G.MPI.GetBarColor(v)
                cR2, cG2, cB2 = r1, g1, b1
            end
            dr.barFill:SetColorTexture(cR2, cG2, cB2, 0.95)
            dr.val:SetText(tostring(v))
            dr.val:SetTextColor(cR2, cG2, cB2, 1)
        end
    else
        R.dimsMeta:SetText("NO DATA YET")
        Color(R.dimsMeta, PAL.text3)
        for _, dr in ipairs(SP._dimRows) do
            dr.barBg:Hide(); dr.barFill:Hide(); dr.lbl:Hide(); dr.val:Hide()
        end
        R.dimsEmptyHead:SetText("Dimension scores not available")
        R.dimsEmptyHint:SetText(
            "Install the MPI Companion to sync your website averages,\n"
            .. "or finish a Mythic+ key with the addon active to score locally.")
        R.dimsEmpty:Show()
    end

    -- COMPANION SYNC CHIP
    local st         = d.companion.status
    local hasOverlay = d.webOverlay ~= nil
    local dotC, copy

    if hasOverlay then
        -- Happy path: bars are populated from website data, no
        -- need for a redundant confirmation chip at the bottom
        -- of the panel. Hide the chip entirely. The chip only
        -- reappears for problem states (idle / missing).
        if R.syncBlock then R.syncBlock:Hide() end
        return
    end
    if R.syncBlock then R.syncBlock:Show() end

    if st == "active" then
        local rel = d.companion.lastSync and FormatRelative(d.companion.lastSync) or "just now"
        local ver = d.companion.version and ("  ·  v" .. d.companion.version) or ""
        -- Companion is alive but no overlay yet — usually means the
        -- installed Companion build doesn't include the overlay
        -- feature, or the first poll hasn't happened.
        dotC = PAL.warn
        copy = ("MPI Companion linked  ·  waiting for website overlay%s"):format(ver)
    elseif st == "stale" then
        dotC = PAL.warn
        local rel = d.companion.lastSync and FormatRelative(d.companion.lastSync) or "a while ago"
        copy = ("MPI Companion idle  ·  last sync %s"):format(rel)
    else
        dotC = PAL.text4
        copy = "MPI Companion not detected  ·  install it to sync your website scores"
    end

    R.syncDot:SetColorTexture(dotC[1], dotC[2], dotC[3], 1)
    R.syncFs:SetText(copy)
    Color(R.syncFs, PAL.text2)
end

-- ── Show / Hide / Toggle ────────────────────────────────

function SP.Show()
    local f = SP.EnsurePanel()
    f:Show()
    f:Raise()
    -- Defer one frame so widget widths are real before
    -- proportional anchoring (tier pips, fills) is computed.
    if C_Timer and C_Timer.After then
        C_Timer.After(0, SP.Refresh)
    end
end

function SP.Hide()
    if SP._refs.panel then SP._refs.panel:Hide() end
end

function SP.Toggle()
    if SP._refs.panel and SP._refs.panel:IsShown() then
        SP.Hide()
    else
        SP.Show()
    end
end

_G.MidnightUI_SeasonProgress_Show   = SP.Show
_G.MidnightUI_SeasonProgress_Toggle = SP.Toggle
_G.MidnightUI_SeasonProgress_Hide   = SP.Hide

-- ── Auto-refresh on spec change ────────────────────────────────
-- Player swapping specs (Outlaw → Subtlety, Bear → Resto, etc.) flips the
-- active role and therefore the score we should be displaying. Re-pull
-- when that happens, but only if the panel is actually visible — no need
-- to thrash data assembly when the user can't see it.
local specEvFrame = CreateFrame("Frame")
specEvFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
specEvFrame:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED")
specEvFrame:SetScript("OnEvent", function(_, event, unit)
    -- PLAYER_SPECIALIZATION_CHANGED fires for every group member;
    -- only react when it's the player themselves.
    if event == "PLAYER_SPECIALIZATION_CHANGED" and unit and unit ~= "player" then
        return
    end
    if SP._refs and SP._refs.panel and SP._refs.panel:IsShown() then
        if C_Timer and C_Timer.After then
            -- Defer one frame: GetSpecialization() reads a touch stale on
            -- the same tick the event fires.
            C_Timer.After(0, SP.Refresh)
        else
            SP.Refresh()
        end
    end
end)
