-- ============================================================
-- Asylum Compagnon — Stats.lua
-- Statistiques Mythique+ par joueur (uniquement ceux qui ont l'addon) :
-- compte, PENDANT une clé, le nombre de CD offensifs / défensifs / externes,
-- de Bloodlust, de Battle Rez, de potions, et de kicks (réussis / ratés).
-- Chaque client compte SES propres actions (fiable) et diffuse un résumé
-- (message « MS » = compteurs) ; le DÉTAIL des sorts off/déf/ext part à la fin
-- (message « MSD »). Chaque client agrège. À la fin du donjon les compteurs se
-- figent, un popup en tableau s'affiche, et le run est archivé (menu « Stats M+ »).
--
-- Alerte Bloodlust : quand quelqu'un lance un Lust, un message « BLA » est
-- diffusé → tout le monde voit un gros message + entend un son (Potion + Burst).
-- ============================================================

local ADDON_NAME, NS = ...

local ST = {}
NS.Stats = ST

local FONT  = "Fonts\\FRIZQT__.TTF"
local WHITE = "Interface\\Buttons\\WHITE8x8"

-- ---------- État courant ----------
local run          -- { active, name, level, startedAt, endedAt } ou nil
local selfCounts   -- compteurs du joueur pour le run en cours
local selfSpells   -- { off={[sid]=n}, def=..., ext=... } sorts propres
local peerCounts   -- [key] = compteurs reçus (MS)
local peerSpells   -- [key] = { off=..., def=..., ext=... } reçus (MSD)
local dirty
local ticker

local FIELDS = { "kh", "km", "off", "def", "ext", "bl", "br", "pot" }
local function newCounts()
    return { kh = 0, km = 0, off = 0, def = 0, ext = 0, bl = 0, br = 0, pot = 0 }
end
local function newSpells() return { off = {}, def = {}, ext = {} } end

local function myName() return UnitName("player") or "?" end
local function myClass() local _, c = UnitClass("player"); return c end
local function nameOf(key) return (key and key:match("^([^%-]+)")) or key or "?" end
local function classOf(key)
    local p = NS.db and NS.db.peers and NS.db.peers[key]
    return p and p.class
end
local function isMe(sender) return sender and (sender:match("^([^%-]+)") == myName()) end

function ST.IsRunning() return run and run.active or false end

-- ---------- Enregistrement (détections locales) ----------
local function bump(field, n)
    if not (run and run.active) or not selfCounts then return end
    selfCounts[field] = (selfCounts[field] or 0) + (n or 1)
    dirty = true
end
-- catType = off/def/ext/lust/res/pot ; sid = spellID (pour le détail off/def/ext)
function ST.RecordSelfCast(catType, sid)
    if catType == "off" or catType == "def" or catType == "ext" then
        bump(catType)
        if sid and selfSpells and selfSpells[catType] then
            selfSpells[catType][sid] = (selfSpells[catType][sid] or 0) + 1
        end
    elseif catType == "lust" then bump("bl")
    elseif catType == "res"  then bump("br")
    elseif catType == "pot"  then bump("pot")
    end
end
function ST.RecordSelfPotion() bump("pot") end
function ST.RecordSelfKick(result)
    if result == "miss" then bump("km") else bump("kh") end
end

-- ---------- Sérialisation / diffusion ----------
local function serialize(c)
    local t = {}
    for i, f in ipairs(FIELDS) do t[i] = tostring(math.floor(c[f] or 0)) end
    return table.concat(t, ",")
end
local function deserialize(s)
    local c, i = newCounts(), 1
    for v in tostring(s):gmatch("([^,]+)") do
        local f = FIELDS[i]; if f then c[f] = tonumber(v) or 0 end
        i = i + 1
    end
    return c
end
-- détail des sorts : « o:sid*n,sid;d:sid;e:sid*n » (off/def/ext)
local function serializeDetail(sp)
    local out = {}
    for _, key in ipairs({ "off", "def", "ext" }) do
        local parts = {}
        if sp and sp[key] then
            for sid, n in pairs(sp[key]) do
                parts[#parts + 1] = (n > 1) and (sid .. "*" .. n) or tostring(sid)
            end
        end
        out[#out + 1] = key:sub(1, 1) .. ":" .. table.concat(parts, ",")
    end
    return table.concat(out, ";")
end
local function deserializeDetail(s)
    local sp = newSpells()
    local map = { o = "off", d = "def", e = "ext" }
    for chunk in tostring(s):gmatch("([^;]+)") do
        local k, list = chunk:match("^(%a):(.*)$")
        local cat = k and map[k]
        if cat then
            for tok in list:gmatch("([^,]+)") do
                local sid, n = tok:match("^(%d+)%*(%d+)$")
                if not sid then sid = tok:match("^(%d+)$"); n = 1 end
                if sid then sp[cat][tonumber(sid)] = tonumber(n) or 1 end
            end
        end
    end
    return sp
end

local function broadcast()
    if selfCounts and NS.Sync and NS.Sync.BroadcastStats then
        NS.Sync.BroadcastStats(serialize(selfCounts))
    end
    dirty = false
end
local function broadcastDetail()
    if selfSpells and NS.Sync and NS.Sync.BroadcastStatsDetail then
        local s = serializeDetail(selfSpells)
        if #s > 240 then s = s:sub(1, 240) end
        NS.Sync.BroadcastStatsDetail(s)
    end
end

function ST.OnPeerStats(sender, payload)
    if not sender or isMe(sender) then return end
    peerCounts = peerCounts or {}
    peerCounts[sender] = deserialize(payload)
end
function ST.OnPeerStatsDetail(sender, payload)
    if not sender or isMe(sender) then return end
    peerSpells = peerSpells or {}
    peerSpells[sender] = deserializeDetail(payload)
end

-- ---------- Lignes (self + pairs) ----------
local function buildRows()
    local rows = {}
    rows[#rows + 1] = { name = myName(), class = myClass(), me = true,
        counts = selfCounts or newCounts(), spells = selfSpells or newSpells() }
    if peerCounts then
        local keys = {}
        for k in pairs(peerCounts) do keys[#keys + 1] = k end
        table.sort(keys, function(a, b) return nameOf(a) < nameOf(b) end)
        for _, k in ipairs(keys) do
            rows[#rows + 1] = { name = nameOf(k), class = classOf(k),
                counts = peerCounts[k], spells = (peerSpells and peerSpells[k]) or newSpells() }
        end
    end
    return rows
end

-- ---------- Historique ----------
-- Rétention : on ne garde que 7 jours de runs (liste courte et lisible).
local RETAIN_DAYS = 7
local function pruneHistory(h)
    if type(h) ~= "table" then return end
    local cutoff = time() - RETAIN_DAYS * 24 * 3600
    for i = #h, 1, -1 do
        if (h[i].ts or 0) < cutoff then table.remove(h, i) end
    end
end
-- Composition COMPLÈTE du groupe au moment du run — NOM + classe de chaque
-- joueur (pas seulement ceux qui ont l'addon). Soi d'abord, puis les coéquipiers
-- (sous-groupe en raid). Affichée en noms colorés par classe.
local function captureComp()
    local _, myClass = UnitClass("player")
    local comp = { { name = UnitName("player") or "?", class = myClass } }
    for _, u in ipairs(NS.MyGroupUnits and NS.MyGroupUnits() or {}) do
        local nm = UnitName(u)
        local _, ct = UnitClass(u)
        if nm then comp[#comp + 1] = { name = nm, class = ct } end
    end
    return comp
end

local function ensureHistory()
    if not NS.db then return nil end
    NS.db.mplusHistory = NS.db.mplusHistory or {}
    pruneHistory(NS.db.mplusHistory)  -- garde 7 jours
    return NS.db.mplusHistory
end
local function copyMap(m) local o = {}; if m then for k, v in pairs(m) do o[k] = v end end; return o end
local function snapshotRows(rows)
    local out = {}
    for _, r in ipairs(rows) do
        local c = {}
        for _, f in ipairs(FIELDS) do c[f] = r.counts[f] or 0 end
        local sp = { off = copyMap(r.spells and r.spells.off), def = copyMap(r.spells and r.spells.def),
            ext = copyMap(r.spells and r.spells.ext) }
        out[#out + 1] = { name = r.name, class = r.class, me = r.me, counts = c, spells = sp }
    end
    return out
end
local function saveRun()
    local h = ensureHistory(); if not h then return end
    local _, myClass = UnitClass("player")
    local entry = {
        name = (run and run.name) or (select(1, GetInstanceInfo())) or "Donjon",
        level = (run and run.level) or 0,
        ts = time(), date = date("%d/%m %H:%M"),
        char = NS.UnitKey and NS.UnitKey("player"),   -- « Nom-Royaume » du perso
        charName = UnitName("player"), charClass = myClass,
        comp = captureComp(),                          -- classes des 5 joueurs
        rows = snapshotRows(buildRows()),
    }
    table.insert(h, 1, entry)
    pruneHistory(h)
    return entry
end
function ST.GetHistory() return ensureHistory() or {} end

-- Perso courant (clé « Nom-Royaume », nom, classe)
function ST.CurrentChar()
    return (NS.UnitKey and NS.UnitKey("player")), UnitName("player"), (select(2, UnitClass("player")))
end

-- Personnages ayant des runs (pour le filtre), perso COURANT toujours en tête
-- (même sans run). Renvoie { {key, name, class}, … }.
function ST.HistoryChars()
    local curKey, curName, curClass = ST.CurrentChar()
    local seen, out = {}, {}
    if curKey then seen[curKey] = true; out[#out + 1] = { key = curKey, name = curName, class = curClass } end
    for _, e in ipairs(ensureHistory() or {}) do
        if e.char and not seen[e.char] then
            seen[e.char] = true
            out[#out + 1] = { key = e.char,
                name = e.charName or (e.char:match("^([^%-]+)")) or e.char, class = e.charClass }
        end
    end
    return out
end

-- Runs d'un perso (défaut = perso courant). Les entrées d'avant 0.29 (sans
-- champ char) ne sont rattachées à personne → non affichées (elles s'effacent
-- toutes seules en 7 jours).
function ST.GetHistoryFor(charKey)
    charKey = charKey or (NS.UnitKey and NS.UnitKey("player"))
    local out = {}
    for _, e in ipairs(ensureHistory() or {}) do
        if e.char == charKey then out[#out + 1] = e end
    end
    return out
end

-- Composition colorée du run : les NOMS des 5 joueurs, chacun dans la couleur
-- de sa classe (ex. « Yukiiza Pinkpøwer Thrangul Tøükâ Hphastos »).
local CLASS_ABBR = {  -- repli pour d'anciennes entrées (compo = classes seules)
    DEATHKNIGHT = "DK", DEMONHUNTER = "DH", DRUID = "Dru", EVOKER = "Évo",
    HUNTER = "Chas", MAGE = "Mage", MONK = "Moine", PALADIN = "Pal",
    PRIEST = "Prê", ROGUE = "Vol", SHAMAN = "Cham", WARLOCK = "Démo", WARRIOR = "Guer",
}
function ST.RunComp(entry)
    local list = entry and entry.comp
    if not list or #list == 0 then return "" end
    local parts = {}
    for _, m in ipairs(list) do
        local nm, ct
        if type(m) == "table" then nm, ct = m.name, m.class else ct = m end  -- vieux format
        nm = nm or (ct and CLASS_ABBR[ct]) or "?"
        local c = ct and RAID_CLASS_COLORS and RAID_CLASS_COLORS[ct]
        parts[#parts + 1] = ("|c%s%s|r"):format((c and c.colorStr) or "ffb0b0b0", nm)
    end
    return table.concat(parts, " ")
end

-- Aperçu LIVE de la clé en cours (pour le menu « Stats M+ ») : même forme
-- qu'une entrée d'historique, reconstruite à la volée depuis les compteurs
-- actuels (soi + pairs). nil s'il n'y a pas de clé active.
function ST.GetLiveEntry()
    if not (run and run.active) then return nil end
    return {
        name = run.name, level = run.level, live = true,
        char = NS.UnitKey and NS.UnitKey("player"),  -- appartient au perso courant
        comp = captureComp(),
        date = NS.L("clé en cours"),
        rows = snapshotRows(buildRows()),
    }
end

-- ---------- Détection du run M+ ----------
local function readRunInfo()
    local name, level
    if C_ChallengeMode then
        local ok, mapID = pcall(C_ChallengeMode.GetActiveChallengeMapID)
        if ok and mapID and C_ChallengeMode.GetMapUIInfo then
            local ok2, n = pcall(C_ChallengeMode.GetMapUIInfo, mapID)
            if ok2 then name = n end
        end
        if C_ChallengeMode.GetActiveKeystoneInfo then
            local ok3, lvl = pcall(C_ChallengeMode.GetActiveKeystoneInfo)
            if ok3 then level = lvl end
        end
    end
    if not name then name = select(1, GetInstanceInfo()) end
    return name or "Donjon", level or 0
end
local function startRun()
    local name, level = readRunInfo()
    run = { active = true, name = name, level = level, startedAt = GetTime() }
    selfCounts = newCounts(); selfSpells = newSpells()
    peerCounts = {}; peerSpells = {}
    dirty = false
    if ticker then ticker:Cancel() end
    -- ne diffuser que si les compteurs ont bougé depuis le dernier envoi
    -- (le flag « dirty » existait mais n'était jamais lu : un « MS » partait
    -- toutes les 3 s même sans aucune action pendant tout le donjon)
    if C_Timer and C_Timer.NewTicker then
        ticker = C_Timer.NewTicker(3, function() if dirty then broadcast() end end)
    end
    if C_Timer and C_Timer.After then C_Timer.After(1.0, broadcast) end
end
local function endRun()
    if not (run and run.active) then return end
    run.active = false; run.endedAt = GetTime()
    if ticker then ticker:Cancel(); ticker = nil end
    broadcast(); broadcastDetail()  -- résumé + détail final
    if C_Timer and C_Timer.After then
        C_Timer.After(1.6, function()
            local entry = saveRun()
            if entry and not (NS.db and NS.db.statsPopup == false) then ST.ShowPopup(entry) end
            if NS.RefreshStatsPage then NS.RefreshStatsPage() end
        end)
    else
        local entry = saveRun()
        if entry and not (NS.db and NS.db.statsPopup == false) then ST.ShowPopup(entry) end
    end
end

local ev = CreateFrame("Frame")
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
if C_ChallengeMode then
    ev:RegisterEvent("CHALLENGE_MODE_START")
    ev:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    ev:RegisterEvent("CHALLENGE_MODE_RESET")
end
ev:SetScript("OnEvent", function(_, event)
    if event == "CHALLENGE_MODE_START" then
        startRun()
    elseif event == "CHALLENGE_MODE_RESET" then
        startRun()
    elseif event == "CHALLENGE_MODE_COMPLETED" then
        endRun()
    elseif event == "PLAYER_ENTERING_WORLD" then
        local isActive = false
        local ok, r = pcall(function()
            return C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive
                and C_ChallengeMode.IsChallengeModeActive()
        end)
        if ok then isActive = r and true or false end
        if isActive and not (run and run.active) then
            startRun()
        elseif not isActive and run and run.active then
            run.active = false
            if ticker then ticker:Cancel(); ticker = nil end
        end
    end
end)

-- ============================================================
-- Tableau partagé (popup de fin + page « Stats M+ »)
-- ============================================================
local COLX = { kick = 182, off = 250, def = 298, ext = 344, br = 386, bl = 424, pot = 464 }
local CATHEX = { kick = "f2d159", off = "ed6b4d", def = "5a99f2", ext = "8dd972",
    br = "ff9cf2", bl = "d98df2", pot = "59d0f2" }
local HCOL = { kick = { 0.95, 0.82, 0.35 }, off = { 0.93, 0.42, 0.30 }, def = { 0.35, 0.60, 0.95 },
    ext = { 0.55, 0.85, 0.45 }, br = { 1, 0.61, 0.95 }, bl = { 0.85, 0.55, 0.95 }, pot = { 0.35, 0.82, 0.95 } }
local HLABEL = { kick = "Kick", off = "Off", def = "Déf", ext = "Ext", br = "BR", bl = "BL", pot = "Pot" }
local ALLCOLS = { "kick", "off", "def", "ext", "br", "bl", "pot" }
local HOVERCOLS = { "kick", "off", "def", "ext" }

local function valText(col, counts)
    local n = (col == "kick") and ((counts.kh or 0) + (counts.km or 0)) or (counts[col] or 0)
    if n == 0 then return "|cff44474f–|r" end
    return ("|cff%s%d|r"):format(CATHEX[col], n)
end
local function makeFS(parent, size, just)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont(FONT, size or 12, "OUTLINE")
    fs:SetJustifyH(just or "CENTER")
    return fs
end
local function cellLeave() GameTooltip:Hide() end
local function cellEnter(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    if self.mode == "kick" then
        GameTooltip:AddLine(("Kicks — %s"):format(self.who or ""), 0.95, 0.82, 0.35)
        GameTooltip:AddDoubleLine(NS.L("Réussis"), "×" .. (self.kh or 0), 0.5, 0.82, 0.45, 0.5, 0.82, 0.45)
        GameTooltip:AddDoubleLine(NS.L("Ratés"), "×" .. (self.km or 0), 0.9, 0.35, 0.35, 0.9, 0.35, 0.35)
        GameTooltip:Show()
        return
    end
    GameTooltip:AddLine(("%s — %s"):format(self.catLabel or "", self.who or ""),
        self.cr or 1, self.cg or 1, self.cb or 1)
    local list = {}
    if self.spells then for sid, n in pairs(self.spells) do list[#list + 1] = { sid = sid, n = n } end end
    table.sort(list, function(a, b) return a.n > b.n end)
    if #list == 0 then
        GameTooltip:AddLine("—", 0.5, 0.5, 0.5)
    else
        for _, e in ipairs(list) do
            local nm = (NS.SpellName and NS.SpellName(e.sid)) or ("#" .. tostring(e.sid))
            GameTooltip:AddDoubleLine(nm, "×" .. e.n, 0.86, 0.87, 0.92, 0.62, 0.64, 0.72)
        end
    end
    GameTooltip:Show()
end

-- rend le tableau dans `parent` à partir de (ox, yTop) ; renvoie le Y du bas
function ST.RenderTable(parent, entry, ox, yTop, tableW)
    ox = ox or 18
    parent._pool = parent._pool or { rows = {} }
    local pool = parent._pool
    if not pool.header then
        pool.header = {}
        for _, key in ipairs(ALLCOLS) do
            local fs = makeFS(parent, 11, "CENTER")
            fs:SetTextColor(HCOL[key][1], HCOL[key][2], HCOL[key][3])
            fs:SetText(HLABEL[key])
            pool.header[key] = fs
        end
        pool.hline = parent:CreateTexture(nil, "ARTWORK")
        pool.hline:SetColorTexture(1, 1, 1, 0.10); pool.hline:SetHeight(1)
    end
    for _, key in ipairs(ALLCOLS) do
        pool.header[key]:ClearAllPoints()
        pool.header[key]:SetPoint("CENTER", parent, "TOPLEFT", ox + COLX[key], yTop)
    end
    pool.hline:ClearAllPoints()
    pool.hline:SetPoint("TOPLEFT", parent, "TOPLEFT", ox - 2, yTop - 12)
    pool.hline:SetPoint("TOPRIGHT", parent, "TOPLEFT", ox + (tableW or 470), yTop - 12)
    pool.hline:Show()

    for _, r in ipairs(pool.rows) do
        r.name:Hide()
        for _, fs in pairs(r.vals) do fs:Hide() end
        for _, b in pairs(r.hovers) do b:Hide() end
    end

    local y = yTop - 30
    for i, rd in ipairs(entry.rows or {}) do
        local r = pool.rows[i]
        if not r then
            r = { vals = {}, hovers = {} }
            r.name = makeFS(parent, 13, "LEFT"); r.name:SetWidth(150)
            for _, key in ipairs(ALLCOLS) do r.vals[key] = makeFS(parent, 12, "CENTER") end
            for _, key in ipairs(HOVERCOLS) do
                local b = CreateFrame("Button", nil, parent)
                b:SetSize(38, 20); b:EnableMouse(true)
                b:SetScript("OnEnter", cellEnter); b:SetScript("OnLeave", cellLeave)
                r.hovers[key] = b
            end
            pool.rows[i] = r
        end
        local col = rd.class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[rd.class]
        local hex = (col and col.colorStr) or "ffffffff"
        r.name:ClearAllPoints(); r.name:SetPoint("LEFT", parent, "TOPLEFT", ox, y)
        r.name:SetText(("|c%s%s|r"):format(hex, rd.name or "?"))
        r.name:Show()
        for _, key in ipairs(ALLCOLS) do
            local fs = r.vals[key]
            fs:ClearAllPoints(); fs:SetPoint("CENTER", parent, "TOPLEFT", ox + COLX[key], y)
            fs:SetText(valText(key, rd.counts)); fs:Show()
        end
        for _, key in ipairs(HOVERCOLS) do
            local b = r.hovers[key]
            b:ClearAllPoints(); b:SetPoint("CENTER", parent, "TOPLEFT", ox + COLX[key], y)
            b.who = rd.name
            if key == "kick" then
                b.mode = "kick"; b.kh = rd.counts.kh or 0; b.km = rd.counts.km or 0
            else
                b.mode = "spells"; b.catLabel = HLABEL[key]
                b.spells = rd.spells and rd.spells[key] or nil
                b.cr, b.cg, b.cb = HCOL[key][1], HCOL[key][2], HCOL[key][3]
            end
            b:Show()
        end
        y = y - 28
    end
    return y
end

-- ============================================================
-- Popup de fin de donjon
-- ============================================================
local popup
local function BuildPopup()
    if popup then return popup end
    local f = CreateFrame("Frame", "AsylumCompagnonStatsPopup", UIParent)
    f:SetSize(524, 160)
    f:SetPoint("CENTER", 0, 120)
    f:SetFrameStrata("DIALOG")
    f:SetMovable(true); f:EnableMouse(true)
    f:SetScript("OnMouseDown", f.StartMoving)
    f:SetScript("OnMouseUp", f.StopMovingOrSizing)
    local bg = f:CreateTexture(nil, "BACKGROUND"); bg:SetTexture(WHITE)
    bg:SetAllPoints(); bg:SetVertexColor(0.04, 0.04, 0.06, 0.97)
    for _, s in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
        local t = f:CreateTexture(nil, "BORDER"); t:SetTexture(WHITE); t:SetVertexColor(0.65, 0.55, 0.98, 0.55)
        if s == "TOP" then t:SetPoint("TOPLEFT"); t:SetPoint("TOPRIGHT"); t:SetHeight(1)
        elseif s == "BOTTOM" then t:SetPoint("BOTTOMLEFT"); t:SetPoint("BOTTOMRIGHT"); t:SetHeight(1)
        elseif s == "LEFT" then t:SetPoint("TOPLEFT"); t:SetPoint("BOTTOMLEFT"); t:SetWidth(1)
        else t:SetPoint("TOPRIGHT"); t:SetPoint("BOTTOMRIGHT"); t:SetWidth(1) end
    end
    local title = f:CreateFontString(nil, "OVERLAY"); title:SetFont(FONT, 16, "OUTLINE")
    title:SetPoint("TOP", 0, -12); title:SetTextColor(0.98, 0.87, 0.42); f.title = title
    local sub = f:CreateFontString(nil, "OVERLAY"); sub:SetFont(FONT, 11, "OUTLINE")
    sub:SetPoint("TOP", title, "BOTTOM", 0, -2); sub:SetTextColor(0.6, 0.62, 0.68); f.sub = sub
    local hint = f:CreateFontString(nil, "OVERLAY"); hint:SetFont(FONT, 10, "")
    hint:SetPoint("BOTTOM", 0, 8); hint:SetTextColor(0.42, 0.44, 0.5)
    hint:SetText(NS.L("Survole Off / Déf / Ext / Kick pour le détail · glisse le coin ↘ pour redimensionner"))
    local close = CreateFrame("Button", nil, f); close:SetSize(24, 24); close:SetPoint("TOPRIGHT", -4, -4)
    local cx = close:CreateFontString(nil, "OVERLAY"); cx:SetFont(FONT, 18, "OUTLINE")
    cx:SetPoint("CENTER"); cx:SetText("×"); cx:SetTextColor(0.7, 0.7, 0.75)
    close:SetScript("OnClick", function() f:Hide() end)

    -- Poignée de redimensionnement (échelle uniforme) — la taille est
    -- SAUVEGARDÉE (statsPopupScale) et réappliquée à CHAQUE fin de donjon.
    local POPUP_W = 524
    local grip = CreateFrame("Button", nil, f)
    grip:SetSize(16, 16); grip:SetPoint("BOTTOMRIGHT", -2, 2)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(self)
        local pScale = UIParent:GetEffectiveScale()
        local sx = f:GetLeft() * f:GetEffectiveScale()
        local sy = f:GetTop() * f:GetEffectiveScale()
        self:SetScript("OnUpdate", function()
            local cxp = GetCursorPosition()
            local ns = (cxp - sx) / (POPUP_W * pScale)
            if ns < 0.7 then ns = 0.7 elseif ns > 2.2 then ns = 2.2 end
            f:SetScale(ns)
            f:ClearAllPoints()
            f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", sx / (pScale * ns), sy / (pScale * ns))
        end)
    end)
    grip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        if NS.db then NS.db.statsPopupScale = f:GetScale() end
    end)

    popup = f
    return f
end

function ST.ShowPopup(entry)
    if not entry then return end
    local f = BuildPopup()
    f:SetScale((NS.db and NS.db.statsPopupScale) or 1)  -- taille mémorisée
    f.title:SetText(("Stats M+ — %s |cfff2d159+%d|r"):format(entry.name or "Donjon", entry.level or 0))
    f.sub:SetText(entry.date or "")
    local bottom = ST.RenderTable(f, entry, 18, -56, 486)
    f:SetHeight(math.max(130, -bottom + 32))
    f:Show()
end

-- ============================================================
-- Alerte Bloodlust (message + son : « Potion + Burst ! »)
-- ============================================================
local blFrame, blAnim, lastBL, blMoving = nil, nil, 0, false

-- position sauvegardée du message (NS.db.blAlertPos), sinon défaut : centre +160
local function ApplyBLPosition(f)
    f:ClearAllPoints()
    local pos = NS.db and NS.db.blAlertPos
    if pos and pos.p then
        f:SetPoint(pos.p, UIParent, pos.rp or pos.p, pos.x or 0, pos.y or 0)
    else
        f:SetPoint("CENTER", 0, 160)
    end
end

local function BuildBLFrame()
    if blFrame then return blFrame end
    local f = CreateFrame("Frame", "AsylumCompagnonBLAlert", UIParent)
    f:SetSize(600, 80)
    f:SetFrameStrata("FULLSCREEN_DIALOG"); f:EnableMouse(false); f:Hide()
    f:SetClampedToScreen(true)
    ApplyBLPosition(f)
    local txt = f:CreateFontString(nil, "OVERLAY")
    txt:SetPoint("CENTER"); txt:SetFont(FONT, 30, "THICKOUTLINE"); f.txt = txt
    local ag = f:CreateAnimationGroup()
    local a1 = ag:CreateAnimation("Alpha"); a1:SetFromAlpha(0); a1:SetToAlpha(1); a1:SetDuration(0.15); a1:SetOrder(1)
    local a2 = ag:CreateAnimation("Alpha"); a2:SetFromAlpha(1); a2:SetToAlpha(1); a2:SetDuration(2.6); a2:SetOrder(2)
    local a3 = ag:CreateAnimation("Alpha"); a3:SetFromAlpha(1); a3:SetToAlpha(0); a3:SetDuration(0.5); a3:SetOrder(3)
    ag:SetScript("OnFinished", function() f:Hide() end)
    blFrame, blAnim = f, ag
    return f
end

-- applique taille/couleur/texte courants au message (réglages du menu)
local function StyleBLText(f)
    local size = (NS.db and NS.db.blAlertSize) or 30
    f.txt:SetFont(FONT, size, "THICKOUTLINE")
    f.txt:SetTextColor((NS.db and NS.db.blAlertR) or 1,
        (NS.db and NS.db.blAlertG) or 0.82, (NS.db and NS.db.blAlertB) or 0)
    f.txt:SetText(NS.L("BLOODLUST — Potion + Burst !"))
end

function ST.ShowLustAlert()
    if NS.db and NS.db.blAlert == false then return end
    if blMoving then
        -- mode déplacement : le message est déjà affiché en continu ;
        -- on met juste à jour taille/couleur (steppers en direct)
        if blFrame then StyleBLText(blFrame) end
        return
    end
    local now = GetTime()
    if now - (lastBL or 0) < 4 then return end
    lastBL = now
    local f = BuildBLFrame()
    StyleBLText(f)
    f:SetAlpha(1)          -- visible TOUT DE SUITE, sans dépendre de l'animation
    f:Show()
    if blAnim then pcall(function() blAnim:Stop(); blAnim:Play() end) end
    -- filet de sécurité : si l'animation ne se termine pas, on cache au bout de 3,5 s
    if C_Timer and C_Timer.After then
        C_Timer.After(3.5, function() if f and not blMoving then f:Hide() end end)
    end
    if not (NS.db and NS.db.blAlertSound == false) and PlaySound and SOUNDKIT then
        pcall(PlaySound, SOUNDKIT.RAID_WARNING, "Master")
    end
end

-- ---------- Déplacement du message (menu Alertes) ----------
-- Mode « déplacer » : le message s'affiche en continu avec un fond violet et
-- se glisse à la souris ; la position est sauvegardée (blAlertPos). On quitte
-- le mode via le même bouton (« Valider la position »).
function ST.ToggleBLMove()
    local f = BuildBLFrame()
    blMoving = not blMoving
    if blMoving then
        if blAnim then blAnim:Stop() end
        f:SetAlpha(1)
        StyleBLText(f)
        if not f.moveBg then
            local bg = f:CreateTexture(nil, "BACKGROUND")
            bg:SetAllPoints()
            bg:SetColorTexture(0.655, 0.545, 0.98, 0.18)
            f.moveBg = bg
            local hint = f:CreateFontString(nil, "OVERLAY")
            hint:SetFont(FONT, 11, "OUTLINE")
            hint:SetPoint("TOP", f, "BOTTOM", 0, -4)
            hint:SetTextColor(0.82, 0.84, 0.92)
            hint:SetText(NS.L("Glisse le cadre, puis clique « Valider la position »"))
            f.moveHint = hint
        end
        f.moveBg:Show(); f.moveHint:Show()
        f:SetMovable(true)
        f:EnableMouse(true)
        f:SetScript("OnMouseDown", f.StartMoving)
        f:SetScript("OnMouseUp", function(self)
            self:StopMovingOrSizing()
            local p, _, rp, x, y = self:GetPoint(1)
            if NS.db then NS.db.blAlertPos = { p = p, rp = rp, x = x, y = y } end
        end)
        f:Show()
    else
        f:EnableMouse(false)
        f:SetScript("OnMouseDown", nil)
        f:SetScript("OnMouseUp", nil)
        if f.moveBg then f.moveBg:Hide() end
        if f.moveHint then f.moveHint:Hide() end
        f:Hide()
    end
    return blMoving
end
function ST.IsBLMoving() return blMoving end
function ST.ResetBLPosition()
    if NS.db then NS.db.blAlertPos = nil end
    if blFrame then ApplyBLPosition(blFrame) end
end
local BL_PALETTE = {
    { 1, 0.82, 0 }, { 1, 0.5, 0.12 }, { 0.95, 0.22, 0.22 }, { 1, 0.85, 0.45 },
    { 0.35, 0.85, 1 }, { 1, 1, 1 }, { 0.9, 0.45, 0.95 },
}
function ST.ColorPresetCount() return #BL_PALETTE end
function ST.SetColorPreset(idx)
    local c = BL_PALETTE[idx] or BL_PALETTE[1]
    if NS.db then NS.db.blAlertR, NS.db.blAlertG, NS.db.blAlertB = c[1], c[2], c[3] end
end
function ST.PreviewLustAlert() lastBL = 0; ST.ShowLustAlert() end
function ST.TriggerLustAlert()
    ST.ShowLustAlert()
    if NS.Sync and NS.Sync.BroadcastLustAlert then NS.Sync.BroadcastLustAlert() end
end
function ST.OnLustAlert(sender) ST.ShowLustAlert() end

-- Détection ROBUSTE du Bloodlust (correctif 0.28) : on écoute le BUFF de lust
-- SUR SOI (UNIT_AURA du joueur). Chacun voit donc son propre lust — ça marche
-- même si le lanceur n'a pas l'addon, en donjon comme en raid, sans AUCUN
-- message réseau. Avant, l'alerte ne partait que sur le cast local (ou via un
-- message perdu en instance) → elle « ne marchait jamais » pour la plupart.
local LUST_AURAS = {
    [2825]   = true, -- Sang pour sang (chaman Horde)
    [32182]  = true, -- Héroïsme (chaman Alliance)
    [80353]  = true, -- Distorsion temporelle (mage)
    [390386] = true, -- Furie des Aspects (évocateur)
    [264667] = true, -- Rage primitive (chasseur, via familier)
    [178207] = true, -- Tambours de fureur (Legion)
    [230935] = true, -- Tambours de la montagne
    [256740] = true, -- Tambours de la fureur (BfA)
    [309658] = true, -- Tambours de férocité mortelle (Shadowlands)
    [381301] = true, -- Tambours en peau sauvage (Dragonflight)
}
local lustActive = false
-- Midnight : lire le NOM (ou le spellId) d'une aura depuis du code d'addon renvoie
-- une valeur « secrète » — toute comparaison lève une erreur de taint
-- (« attempt to compare … a secret string value », Stats.lua). On ne parcourt donc
-- plus la liste des auras : pour chaque lust CONNU, on demande directement l'aura
-- par son spellID (valeur fournie par nous, non secrète) et on ne teste QUE sa
-- présence — aucune lecture de champ secret.
local function PlayerHasLust()
    local C = C_UnitAuras
    if not C then return false end
    if C.GetPlayerAuraBySpellID then
        for sid in pairs(LUST_AURAS) do
            local ok, aura = pcall(C.GetPlayerAuraBySpellID, sid)
            if ok and aura then return true end
        end
        return false
    end
    -- repli (client sans l'API) : scan par index, spellID uniquement, jamais le nom
    if C.GetBuffDataByIndex then
        for i = 1, 40 do
            local ok, aura = pcall(C.GetBuffDataByIndex, "player", i)
            if not ok or not aura then break end
            local sid = aura and aura.spellId
            if sid and not (issecretvalue and issecretvalue(sid)) and LUST_AURAS[sid] then
                return true
            end
        end
    end
    return false
end
local lustWatch = CreateFrame("Frame")
if lustWatch.RegisterUnitEvent then
    pcall(lustWatch.RegisterUnitEvent, lustWatch, "UNIT_AURA", "player")
else
    pcall(lustWatch.RegisterEvent, lustWatch, "UNIT_AURA")
end
lustWatch:SetScript("OnEvent", function(_, _, unit)
    if unit and unit ~= "player" then return end
    local has = PlayerHasLust()
    if has and not lustActive then
        lustActive = true
        ST.ShowLustAlert()        -- alerte locale, aucune diffusion nécessaire
    elseif not has then
        lustActive = false        -- ré-arme pour le prochain lust
    end
end)
