-- ============================================================
-- Asylum Compagnon — Cooldowns.lua
-- Tracker de cooldowns de groupe (esprit oRA3/OmniCD), compatible
-- Midnight sans CLEU. Même principe que les kicks :
--   · chaque client détecte SES casts (UNIT_SPELLCAST_SUCCEEDED) d'un
--     sort listé dans NS.COOLDOWNS et les diffuse au groupe (« D ») ;
--   · à la réception, on démarre un décompte pour l'ami.
-- L'encadré liste les CD actuellement en recharge dans le groupe
-- (une ligne par CD : nom du joueur + sort + temps restant), triés
-- par temps restant. On n'affiche que les CD cochés (NS.IsCDShown).
-- Encadré déplaçable / redimensionnable, réglages sauvegardés
-- (cdPos, cdScale, cdBgAlpha).
-- ============================================================

local ADDON_NAME, NS = ...

local CD = {}
NS.Cooldowns = CD
CD.available = false

local OVERLAY_W = 234
local ROW_H     = 20
local HEADER_H  = 24

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

-- Indexation « secrète » Midnight → toujours sous pcall
local function LookupCD(spellID)
    local ok, info = pcall(function() return NS.COOLDOWNS and NS.COOLDOWNS[spellID] end)
    if ok then return info end
end

-- utilitaires partagés (Core.lua)
local SafeNum = NS.SafeNum

-- état : [key..":"..spellID] = { key, name, class, spellID, label, type, endTime }
local active = {}
local overlay, overlayBg, overlayBorders, ticker
-- roster du board mis en cache (reconstruit sur événement, plus jamais 5×/s)
local boardRoster
local myGUID, myKey, myName, myClassToken, mySpec
-- CD réellement connus (spé + talents) : set {[spellID]=true}
local myKnown = {}
local myOverride = {}        -- base spellID -> override spellID (remplacement talent)
local myOverrideToBase = {}  -- override spellID -> base spellID (détection de cast)
local myChargeMax = {}       -- base spellID -> nb de charges max (>1)
local potionEffect = {}      -- spellID d'effet d'une potion -> { label }
local potionReady = false
local potIcon                -- icône représentative de potion (slot du board)
local potNameCache = {}      -- [spellID] = {label} détecté par nom, ou false

-- Override actif d'un sort (talent qui le remplace : ex. Bloc de glace -> Un
-- froid glacial). Renvoie l'ID de remplacement, ou nil.
local function ActiveOverride(spellID)
    if C_Spell and C_Spell.GetOverrideSpell then
        local ok, ov = pcall(C_Spell.GetOverrideSpell, spellID)
        if ok and ov and ov ~= spellID then return ov end
    end
    if FindSpellOverrideByID then
        local ok, ov = pcall(FindSpellOverrideByID, spellID)
        if ok and ov and ov ~= spellID then return ov end
    end
    return nil
end

-- Résout les sorts d'effet des potions de combat suivies (via l'objet).
local function BuildPotionEffects()
    wipe(potionEffect)
    potIcon = nil
    potionReady = true
    for _, p in ipairs(NS.POTION_ITEMS or {}) do
        for _, itemID in ipairs(p.ids) do
            local sid
            if C_Item and C_Item.GetItemSpell then
                local ok, _, s = pcall(C_Item.GetItemSpell, itemID)
                if ok then sid = s end
            elseif GetItemSpell then
                local ok, _, s = pcall(GetItemSpell, itemID)
                if ok then sid = s end
            end
            if sid then
                potionEffect[sid] = { label = p.label }
                if not potIcon then potIcon = NS.SpellIcon(sid) end
            else
                potionReady = false -- objet pas encore en cache : on réessaiera
                if C_Item and C_Item.RequestLoadItemDataByID then
                    pcall(C_Item.RequestLoadItemDataByID, itemID)
                end
            end
        end
    end
end

-- Icône représentative de potion pour le slot permanent du board
function CD.PotionIcon()
    return potIcon or "Interface\\Icons\\trade_alchemy"
end

-- ---------- Roster (clé → nom/classe pour les pairs) ----------
function CD.RefreshRoster()
    myGUID = UnitGUID("player")
    myKey  = NS.UnitKey("player")
    boardRoster = nil -- le roster du board sera reconstruit au prochain tick
    myName = UnitName("player")
    local _, ct = UnitClass("player")
    myClassToken = ct
    mySpec = 0
    if GetSpecialization and GetSpecializationInfo then
        local idx = GetSpecialization()
        if idx then local sid = GetSpecializationInfo(idx); mySpec = sid or 0 end
    end
end

-- Recalcule quels CD suivis JE possède réellement (spé + talents via
-- IsPlayerSpell) et diffuse la liste au groupe → les amis n'affichent que
-- mes CD réels (fini les CD de talents que je n'ai pas pris).
function CD.RefreshKnown()
    wipe(myKnown); wipe(myOverride); wipe(myOverrideToBase); wipe(myChargeMax)
    local _, myClass = UnitClass("player")
    local cfn = C_Spell and C_Spell.GetSpellCharges
    for sid, info in pairs(NS.COOLDOWNS or {}) do
        if info.class == myClass then
            local ok, is = pcall(IsPlayerSpell, sid)
            if ok and is then
                myKnown[sid] = true
                local ov = ActiveOverride(sid)
                if ov then myOverride[sid] = ov; myOverrideToBase[ov] = sid end
                local readSid = ov or sid -- charges lues sur le sort réel
                if cfn then
                    local ok2, ci = pcall(cfn, readSid)
                    if ok2 and type(ci) == "table" and (ci.maxCharges or 0) > 1 then
                        myChargeMax[sid] = ci.maxCharges
                    end
                end
            end
        end
    end
    if NS.Sync and NS.Sync.BroadcastKnownCDs then
        local known, ovs, chg = {}, {}, {}
        for sid in pairs(myKnown) do known[#known + 1] = sid end
        for base, ov in pairs(myOverride) do ovs[#ovs + 1] = base .. ":" .. ov end
        for base, mx in pairs(myChargeMax) do chg[#chg + 1] = base .. ":" .. mx end
        -- payload : « connus \t overrides \t chargesMax »
        NS.Sync.BroadcastKnownCDs(table.concat(known, "/") .. "\t"
            .. table.concat(ovs, "/") .. "\t" .. table.concat(chg, "/"))
    end
    boardRoster = nil -- mes CD connus ont pu changer → board à reconstruire
end

-- Invalide le roster du board (appelé par Sync sur hello, par les réglages…)
-- → reconstruit au prochain tick d'affichage.
function CD.InvalidateRoster()
    boardRoster = nil
end

-- Réception de la liste des CD connus d'un ami
function CD.OnPeerKnownCDs(sender, msg)
    local data = msg:match("^CK\t(.*)$")
    if not (data and NS.db) then return end
    NS.db.peers = NS.db.peers or {}
    local p = NS.db.peers[sender]
    if not p then p = {}; NS.db.peers[sender] = p end
    local known, ovs, chg = strsplit("\t", data)
    local set = {}
    for sid in (known or ""):gmatch("%d+") do set[tonumber(sid)] = true end
    p.knownCDs = set
    local om = {}
    for base, ov in (ovs or ""):gmatch("(%d+):(%d+)") do om[tonumber(base)] = tonumber(ov) end
    p.overrides = om
    local cm = {}
    for base, mx in (chg or ""):gmatch("(%d+):(%d+)") do cm[tonumber(base)] = tonumber(mx) end
    p.chargeMax = cm
    boardRoster = nil -- les CD connus de ce pair ont changé
end

-- Écho : quand un ami se signale (hello), on rediffuse notre liste (anti-rafale)
local knownEcho
function CD.OnPeerHello()
    if knownEcho then return end
    knownEcho = C_Timer.NewTimer(5, function()
        knownEcho = nil
        CD.RefreshKnown()
    end)
end

-- état spécial : lust hybride (Sated/Ready/Active) partagé par le groupe
CD.lustState = nil
-- état spécial : compteur de résurrection de combat (M+)
CD.res = { charges = 0, nextAt = 0, known = false }

-- ---------- Ajout / rafraîchissement d'un CD actif ----------
local function AddActive(key, name, class, spellID, cd, cur, max, recharge)
    local info = LookupCD(spellID)
    if not info or not cd or cd <= 0 then return end
    if not (NS.IsCDShown and NS.IsCDShown(spellID)) then return end
    if info.type == "lust" then
        -- ligne Lust dédiée (pas de barre) : garde le cast le plus récent
        CD.lustState = { castAt = GetTime(), cd = cd, spellID = spellID, class = class }
        return
    end
    local now = GetTime()
    local id = key .. ":" .. spellID
    if max and max > 1 then
        -- sort à charges : cd = durée de recharge d'UNE charge ; on garde
        -- l'entrée jusqu'au retour de toutes les charges (endTime = plein)
        local ready = now + (recharge or 0)
        local full = ready + math.max(0, (max - (cur or 0) - 1)) * cd
        active[id] = {
            key = key, name = name, class = class, spellID = spellID,
            label = info.label, type = info.type,
            cur = cur or 0, max = max, chargeDur = cd, chargeReadyAt = ready,
            endTime = full, total = cd,
        }
    else
        active[id] = {
            key = key, name = name, class = class, spellID = spellID,
            label = info.label, type = info.type, endTime = now + cd, total = cd,
        }
    end
end

-- Potion de combat utilisée (transitoire, hors table de classe)
local function AddPotion(key, name, class, spellID, cd, label)
    if not cd or cd <= 0 then return end
    if not (NS.db and NS.db.cdTrackPot ~= false) then return end
    active[key .. ":" .. spellID] = {
        key = key, name = name, class = class, spellID = spellID,
        label = label or (NS.SpellName and NS.SpellName(spellID, "Potion")) or "Potion",
        type = "pot", endTime = GetTime() + cd, total = cd, potion = true,
    }
end

-- ---------- Compteur de résurrection de combat (M+) ----------
local REZ_SPELL = { DRUID = 20484, DEATHKNIGHT = 61999, WARLOCK = 20707, PALADIN = 391054 }
local lastResSent = { charges = nil, hasNext = nil, at = 0 }
local function ReadCharges(spellID)
    local fn = C_Spell and C_Spell.GetSpellCharges
    if fn then
        local ok, info = pcall(fn, spellID)
        if ok and type(info) == "table" then
            return info.currentCharges, info.maxCharges, info.cooldownStartTime, info.cooldownDuration
        end
    end
    if GetSpellCharges then
        local ok, cur, max, st, dur = pcall(GetSpellCharges, spellID)
        if ok then return cur, max, st, dur end
    end
end
function CD.PollRes()
    local _, myClass = UnitClass("player")
    local sid = REZ_SPELL[myClass]
    if not sid then return end
    local cur, max, st, dur = ReadCharges(sid)
    cur, max, st, dur = SafeNum(cur), SafeNum(max), SafeNum(st), SafeNum(dur)
    if cur == nil then return end
    local nextAt = 0
    if max and cur < max and st and dur and dur > 0 then nextAt = st + dur end
    CD.res.charges, CD.res.nextAt, CD.res.known = cur, nextAt, true
    -- Diffusion SEULEMENT sur changement (charges ou début/fin de recharge),
    -- plus un rappel toutes les 30 s pour qui rejoint en cours de route.
    -- Avant : un message « R » toutes les 2 s en continu dès qu'une classe à
    -- rez était en groupe — le plus gros gaspillage réseau de l'addon.
    local now = GetTime()
    local hasNext = nextAt > now
    if cur ~= lastResSent.charges or hasNext ~= lastResSent.hasNext
        or (now - (lastResSent.at or 0)) > 30 then
        lastResSent.charges, lastResSent.hasNext, lastResSent.at = cur, hasNext, now
        if NS.Sync and NS.Sync.BroadcastRes then
            NS.Sync.BroadcastRes(cur, math.max(0, nextAt - now))
        end
    end
end
function CD.OnPeerRes(senderKey, charges, timeToNext)
    -- pool partagé en M+ : toute lecture fiable fait foi
    CD.res.charges = charges or 0
    CD.res.nextAt = (timeToNext and timeToNext > 0) and (GetTime() + timeToNext) or 0
    CD.res.known = true
end

-- ---------- Détection locale ----------
local OwnerUnit = NS.OwnerUnit

local function OnCastSucceeded(unit, spellID)
    local owner = OwnerUnit(unit)
    local guid = UnitGUID(owner)
    if not guid or guid ~= myGUID then return end -- on ne détecte QUE soi (fiable)

    -- potion de combat suivie ? (objet résolu via GetItemSpell OU set direct
    -- par spellID d'effet — repli quand l'objet n'est pas encore en cache et
    -- pour les potions/pierres de soin dont on n'a pas l'itemID)
    if not potionReady then BuildPotionEffects() end
    local pot = potionEffect[spellID] or (NS.POTION_EFFECTS and NS.POTION_EFFECTS[spellID])
    -- repli par NOM (potions de soin sans spellID connu), mémorisé par sort
    if not pot and NS.POTION_NAME_HINTS then
        local cached = potNameCache[spellID]
        if cached == nil then
            local nm = (NS.SpellName and NS.SpellName(spellID)) or ""
            if nm ~= "" then
                cached = false
                local low = nm:lower()
                for _, h in ipairs(NS.POTION_NAME_HINTS) do
                    if low:find(h, 1, true) then cached = { label = nm, kind = "hp" }; break end
                end
                potNameCache[spellID] = cached
            end
        end
        if cached then pot = cached end
    end
    if pot then
        if NS.Stats then NS.Stats.RecordSelfPotion() end   -- stats M+
        if NS.db and NS.db.cdTrackPot ~= false then
            local cd = (NS.RealCooldown and NS.RealCooldown(spellID, 300)) or 300
            AddPotion(myKey, myName, myClassToken, spellID, cd, pot.label)
            if NS.Sync and NS.Sync.BroadcastPotion then
                NS.Sync.BroadcastPotion(spellID, cd)
            end
        end
        return
    end

    -- CD de classe : résout un éventuel override (sort remplacé par talent)
    -- vers son ID de base, pour une clé de suivi partagée par tout le groupe
    local base, info = spellID, LookupCD(spellID)
    if not info then
        local b = myOverrideToBase[spellID]
        if b then base, info = b, LookupCD(b) end
    end
    if not info then return end

    -- stats M+ (compte par catégorie) + alerte Bloodlust
    if NS.Stats then
        NS.Stats.RecordSelfCast(info.type, base)
        if info.type == "lust" then NS.Stats.TriggerLustAlert() end
    end

    -- L'état de charges/cooldown n'est PAS toujours à jour à l'instant exact du
    -- UNIT_SPELLCAST_SUCCEEDED (la charge n'est pas encore décomptée). Si on lit
    -- tout de suite, on capture l'ANCIEN nombre de charges et les amis ne voient
    -- jamais l'usage. On lit et on diffuse donc juste APRÈS (frame suivante).
    local castSid = spellID
    C_Timer.After(0.1, function()
        local cur, max, recharge
        local cfn = C_Spell and C_Spell.GetSpellCharges
        if cfn then
            local ok, ci = pcall(cfn, castSid)
            if ok and type(ci) == "table" then
                local mx = SafeNum(ci.maxCharges)
                if mx and mx > 1 then
                    cur, max = SafeNum(ci.currentCharges) or 0, mx
                    local cst  = SafeNum(ci.cooldownStartTime)
                    local cdur = SafeNum(ci.cooldownDuration)
                    if cur < max and cst and cdur and cdur > 0 then
                        recharge = math.max(0, cst + cdur - GetTime())
                    else
                        recharge = 0
                    end
                end
            end
        end
        local cd = (NS.RealCooldown and NS.RealCooldown(castSid, info.cd)) or info.cd
        AddActive(myKey, myName, myClassToken, base, cd, cur, max, recharge)
        if NS.Sync and NS.Sync.BroadcastCooldown then
            NS.Sync.BroadcastCooldown(base, cd, cur or 0, max or 0, recharge or 0)
        end
    end)
end

-- ---------- Synchro reçue (via Sync.lua) ----------
function CD.OnPeerCooldown(senderKey, spellID, cd, cur, max, recharge)
    local info = LookupCD(spellID)
    if not info then return end
    local p = NS.db and NS.db.peers and NS.db.peers[senderKey]
    local class = p and p.class
    local name = senderKey:match("^(.-)%-") or senderKey
    AddActive(senderKey, name, class, spellID, cd, cur, max, recharge)
end

function CD.OnPeerPotion(senderKey, spellID, cd)
    local p = NS.db and NS.db.peers and NS.db.peers[senderKey]
    local class = p and p.class
    local name = senderKey:match("^(.-)%-") or senderKey
    AddPotion(senderKey, name, class, spellID, cd)
end

-- ---------- Encadré ----------
local function ApplyPosition()
    if not overlay then return end
    overlay:ClearAllPoints()
    local pos = NS.db and NS.db.cdPos
    if pos and pos.p then
        overlay:SetPoint(pos.p, UIParent, pos.rp or pos.p, pos.x or 0, pos.y or 0)
    else
        overlay:SetPoint("CENTER", UIParent, "CENTER", 320, -60)
    end
end

function CD.ApplyStyle()
    if not overlay then return end
    local a = (NS.db and NS.db.cdBgAlpha) or 0.6
    overlayBg:SetVertexColor(0, 0, 0, a)
    for _, t in ipairs(overlayBorders) do
        t:SetVertexColor(1, 1, 1, math.min(0.25, 0.05 + a * 0.1))
    end
    overlay:SetScale((NS.db and NS.db.cdScale) or 1)
end

function CD.SetBgAlpha(a)
    if NS.db then NS.db.cdBgAlpha = a end
    CD.ApplyStyle()
end

local function SavePosition()
    if not (overlay and NS.db) then return end
    local p, _, rp, x, y = overlay:GetPoint(1)
    NS.db.cdPos = { p = p, rp = rp, x = x, y = y }
end

-- ---------- Board persistant : CD par membre ----------
-- Cache classe → liste de sorts, triée off/déf/ext(+res)/BL puis nom.
-- Tout est inclus : res et lust apparaissent aussi par joueur (colonnes Ext/BL).
local TYPE_ORDER = { off = 1, def = 2, ext = 3, lust = 4, res = 5, disp = 6 }
local classSpells
local function BuildClassCache()
    classSpells = {}
    for sid, info in pairs(NS.COOLDOWNS or {}) do
        local list = classSpells[info.class]
        if not list then list = {}; classSpells[info.class] = list end
        list[#list + 1] = { sid = sid, info = info }
    end
    for _, list in pairs(classSpells) do
        table.sort(list, function(a, b)
            local ta, tb = TYPE_ORDER[a.info.type] or 9, TYPE_ORDER[b.info.type] or 9
            if ta ~= tb then return ta < tb end
            return (a.info.label or "") < (b.info.label or "")
        end)
    end
end
-- Sorts cochés (affichés) d'une classe, filtrés par ce que le joueur POSSÈDE
-- réellement : moi via IsPlayerSpell (myKnown), un ami via sa liste annoncée
-- (knownSet), et à défaut (pas encore reçu) par la spé (CD_SPEC).
local function MemberSpells(class, specID, knownSet, isMe)
    if not classSpells then BuildClassCache() end
    local all = class and classSpells[class]
    if not all then return nil end
    local out, byName = {}, {}
    for _, sp in ipairs(all) do
        if NS.IsCDShown and NS.IsCDShown(sp.sid) then
            local has
            if isMe and next(myKnown) then
                has = myKnown[sp.sid]
            elseif knownSet then
                has = knownSet[sp.sid]
            else
                has = (not NS.CDMatchesSpec) or NS.CDMatchesSpec(sp.sid, specID)
            end
            if has then
                -- dédup par NOM : un même sort a parfois plusieurs spellID (variantes
                -- de spé / talents de héros) → UNE seule icône, dont l'état CD se
                -- déclenche si l'UN OU L'AUTRE des sids est lancé (fusion dans place()).
                local nm = (NS.SpellName and NS.SpellName(sp.sid, sp.info and sp.info.label))
                    or tostring(sp.sid)
                local e = byName[nm]
                if e then
                    e.merged = e.merged or { e.sid }
                    e.merged[#e.merged + 1] = sp.sid
                else
                    e = { sid = sp.sid, info = sp.info }   -- copie (ne pas polluer le cache)
                    byName[nm] = e
                    out[#out + 1] = e
                end
            end
        end
    end
    return out
end

-- Roster du board : moi + membres du groupe qui ont l'addon
local function BuildBoardRoster()
    local list = { { name = myName or UnitName("player") or "?",
                     class = myClassToken, key = myKey, isMe = true, specID = mySpec } }
    local function add(unit)
        if UnitExists and not UnitExists(unit) then return end
        local key = NS.UnitKey(unit)
        if not key or key == myKey then return end
        local name = key:match("^([^%-]+)") or key
        local p = NS.db and NS.db.peers and NS.db.peers[key]
        if not p then return end
        local _, ct = UnitClass(unit)
        list[#list + 1] = { name = name, class = ct, key = key, isMe = false,
                            specID = p.specID, knownCDs = p.knownCDs,
                            overrides = p.overrides, chargeMax = p.chargeMax }
    end
    -- coéquipiers à suivre : EN RAID, seulement notre sous-groupe (NS.MyGroupUnits)
    for _, u in ipairs(NS.MyGroupUnits and NS.MyGroupUnits() or {}) do add(u) end
    return list
end

-- Overlay façon OmniCD : par joueur, une ligne « Nom + icônes » regroupées
-- par catégorie Off / Déf / Ext / BL, avec balayage de cooldown + décompte.
-- Une ligne « Battle Rez » (compteur M+) en tête. Toujours visible.
local ICON_SZ   = 22
local ICON_GAP  = 2
local CAT_GAP   = 7
local NAME_W    = 92
local ROW_STEP  = 26
local CAT_ORDER = { "off", "def", "ext", "lust", "disp" }
local function CatOf(t) if t == "res" then return "ext" end return t end

-- pools d'icônes + libellés de nom (recyclés d'une frame à l'autre)
local iconPool, nameFsPool = {}, {}
local function GetIcon(i)
    if iconPool[i] then return iconPool[i] end
    local ic = NS.CreateCDIcon(overlay, ICON_SZ)
    iconPool[i] = ic
    return ic
end
local function GetNameFs(i)
    if nameFsPool[i] then return nameFsPool[i] end
    local fs = overlay:CreateFontString(nil, "OVERLAY")
    fs:SetFont(FONT, 11, "OUTLINE")
    fs:SetJustifyH("LEFT")
    fs:SetWordWrap(false)
    nameFsPool[i] = fs
    return fs
end

-- en-têtes de colonnes (Off/Def/Ext/BL) + bandes de fond teintées par classe
-- + séparateurs verticaux entre colonnes
local CAT_LABEL = { off = "Off", def = "Def", ext = "Ext", lust = "BL", disp = "Disp" }
local catHdrPool, stripePool, sepPool = {}, {}, {}
local function GetCatHdr(i)
    if catHdrPool[i] then return catHdrPool[i] end
    local h = overlay:CreateFontString(nil, "OVERLAY")
    h:SetFont(FONT, 10, "OUTLINE")
    h:SetJustifyH("LEFT")
    h:SetTextColor(0.62, 0.66, 0.78)
    catHdrPool[i] = h
    return h
end
local function GetStripe(i)
    if stripePool[i] then return stripePool[i] end
    local t = overlay:CreateTexture(nil, "BACKGROUND", nil, 2)
    stripePool[i] = t
    return t
end
local function GetSep(i)
    if sepPool[i] then return sepPool[i] end
    local t = overlay:CreateTexture(nil, "BORDER")
    t:SetColorTexture(0.22, 0.23, 0.29, 0.9)
    sepPool[i] = t
    return t
end

-- libellés de catégorie pour le tooltip
local CAT_TIP = { off = "Offensif", def = "Défensif", ext = "Externe",
    lust = "Bloodlust", res = "Battle Rez", disp = "Dissipation", pot = "Potion" }

-- état d'affichage d'un sort pour MOI (live, charges incluses) : renvoie
-- { onCD, remaining, start, dur, cur, max } — cur>0 = prêt (pas de CD).
local function SelfState(baseSid)
    local readSid = myOverride[baseSid] or baseSid
    local now = GetTime()
    local cfn = C_Spell and C_Spell.GetSpellCharges
    if cfn then
        local ok, ci = pcall(cfn, readSid)
        if ok and type(ci) == "table" then
            local maxC = SafeNum(ci.maxCharges)
            if maxC and maxC > 1 then
                local cur = SafeNum(ci.currentCharges) or 0
                if cur > 0 then return { cur = cur, max = maxC, onCD = false } end
                local start = SafeNum(ci.cooldownStartTime)
                local dur = SafeNum(ci.cooldownDuration)
                if start and dur and dur > 1.5 then
                    return { cur = 0, max = maxC, onCD = true, start = start, dur = dur,
                        remaining = math.max(0, start + dur - now) }
                end
                return { cur = 0, max = maxC, onCD = false }
            end
        end
    end
    local gfn = C_Spell and C_Spell.GetSpellCooldown
    if gfn then
        local ok, cdi = pcall(gfn, readSid)
        if ok and type(cdi) == "table" then
            local st  = SafeNum(cdi.startTime)
            local dur = SafeNum(cdi.duration)
            if st and dur and st > 0 and dur > 1.5 then
                return { onCD = true, start = st, dur = dur,
                    remaining = math.max(0, st + dur - now) }
            end
        end
    end
    return { onCD = false }
end

-- état d'affichage d'un sort pour un PAIR (via active[] ou charges au repos)
local function PeerState(m, baseSid, now)
    local e = active[m.key .. ":" .. baseSid]
    if e then
        if e.max and e.max > 1 then
            if (e.cur or 0) > 0 then return { cur = e.cur, max = e.max, onCD = false } end
            return { cur = 0, max = e.max, onCD = true,
                start = e.chargeReadyAt - e.chargeDur, dur = e.chargeDur,
                remaining = math.max(0, e.chargeReadyAt - now) }
        end
        return { onCD = true, start = e.endTime - e.total, dur = e.total,
            remaining = math.max(0, e.endTime - now) }
    end
    local mx = m.chargeMax and m.chargeMax[baseSid]
    if mx and mx > 1 then return { cur = mx, max = mx, onCD = false } end
    return { onCD = false }
end

-- Board façon OmniCD : une ligne par joueur, icônes SERRÉES (tri off/déf/ext/
-- BL/rez/pot). La catégorie est dans le tooltip (toggle cdShowCat). Charges,
-- overrides (sorts remplacés par talent) et potions gérés. Toujours visible.
local function UpdateRows()
    if not overlay or not overlay:IsShown() then return end
    local now = GetTime()
    -- simulation des charges (recharge) + purge
    for id, e in pairs(active) do
        if e.max and e.max > 1 and e.chargeReadyAt then
            while (e.cur or 0) < e.max and now >= e.chargeReadyAt do
                e.cur = (e.cur or 0) + 1
                if e.cur < e.max then e.chargeReadyAt = e.chargeReadyAt + e.chargeDur end
            end
        end
        if (e.endTime or 0) <= now then active[id] = nil end
    end

    -- potions actives par joueur (transitoires, hors table de classe)
    local potByKey = {}
    for _, e in pairs(active) do
        if e.type == "pot" then
            local l = potByKey[e.key]; if not l then l = {}; potByKey[e.key] = l end
            l[#l + 1] = e
        end
    end

    local showCat = not (NS.db and NS.db.cdShowCat == false)
    local trackPot = not (NS.db and NS.db.cdTrackPot == false)
    local ui, un, us = 0, 0, 0
    local y = HEADER_H
    -- largeur de la colonne des noms, réglable : plus c'est petit, plus les
    -- icônes se rapprochent du pseudo (fenêtre plus étroite).
    local nameW = (NS.db and NS.db.cdNameW) or NAME_W
    if nameW < 24 then nameW = 24 end
    local maxW = nameW + 30
    -- espacement vertical entre lignes de membres, réglable (0 = collé)
    local gap = (NS.db and NS.db.cdRowGap) or 0
    if gap < 0 then gap = 0 end
    local rowStep = ROW_STEP + gap

    -- ligne compteur Battle Rez
    if CD.res and CD.res.known then
        un = un + 1
        local fs = GetNameFs(un)
        fs:ClearAllPoints()
        fs:SetPoint("LEFT", overlay, "TOPLEFT", 8, -(y + ICON_SZ / 2))
        fs:SetWidth(nameW - 4)
        fs:SetText("|cffcfd2dcBattle Rez|r")
        fs:Show()
        ui = ui + 1
        local ic = GetIcon(ui)
        ic:ClearAllPoints()
        ic:SetPoint("TOPLEFT", overlay, "TOPLEFT", 8 + nameW, -(y + 2))
        ic:SetState(NS.SpellIcon(20484), nil, nil, 0)
        local charges = CD.res.charges or 0
        if charges > 0 then
            ic:SetCount(tostring(charges), 0.2, 0.85, 0.45)
        else
            ic:SetCount("0", 0.95, 0.35, 0.35)
            ic:SetBorderColor(0.88, 0.34, 0.30, 0.95)
        end
        local sub = ""
        if CD.res.nextAt and CD.res.nextAt > now then
            local rr = CD.res.nextAt - now
            sub = ("%s %d:%02d"):format(NS.L("Prochaine charge :"),
                math.floor(rr / 60), math.floor(rr % 60))
        end
        ic:SetInfo(20484, "Battle Rez", sub)
        ic:Show()
        y = y + rowStep
    end

    -- lignes joueurs : bande teintée classe + nom + icônes serrées.
    -- Roster ET liste de sorts par membre mis en CACHE : reconstruits sur
    -- événement (groupe, hello, CK, spé, sélection) — avant, tout était
    -- recalculé à chaque tick (UnitFullName ×40 en raid, allocations…).
    if not boardRoster then boardRoster = BuildBoardRoster() end
    for _, m in ipairs(boardRoster) do
        local spells = m._spells
        if spells == nil then
            spells = MemberSpells(m.class, m.specID, m.knownCDs, m.isMe) or false
            m._spells = spells
        end
        local pots = potByKey[m.key]
        if (spells and #spells > 0) or trackPot then
            local col = m.class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[m.class]
            us = us + 1
            local st = GetStripe(us)
            st:ClearAllPoints()
            st:SetPoint("TOPLEFT", overlay, "TOPLEFT", 4, -y)
            st:SetHeight(ROW_STEP - 2)
            if col then st:SetColorTexture(col.r, col.g, col.b, 0.12)
            else st:SetColorTexture(0.5, 0.5, 0.5, 0.10) end
            st:Show()

            un = un + 1
            local fs = GetNameFs(un)
            fs:ClearAllPoints()
            fs:SetPoint("LEFT", overlay, "TOPLEFT", 8, -(y + ICON_SZ / 2))
            fs:SetWidth(nameW - 4)
            fs:SetText(("|c%s%s|r"):format((col and col.colorStr) or "ffffffff", m.name))
            fs:Show()

            local x = 8 + nameW
            local lastCat
            local function place(sp)
                ui = ui + 1
                local ic = GetIcon(ui)
                -- petit espace entre catégories (Off | Déf | Ext/BL | Potion)
                local cat = CatOf(sp.info.type)
                if lastCat and cat ~= lastCat then x = x + CAT_GAP end
                lastCat = cat
                ic:ClearAllPoints()
                ic:SetPoint("TOPLEFT", overlay, "TOPLEFT", x, -(y + 2))
                local base = sp.sid
                local dispSid = (m.overrides and m.overrides[base])
                    or (m.isMe and myOverride[base]) or base
                local o
                if sp.info.type == "lust" then
                    if CD.lustState then
                        local ls = CD.lustState
                        local rem = (ls.castAt + (ls.cd or 300)) - now
                        if rem <= 0 then CD.lustState = nil; o = { onCD = false }
                        else o = { onCD = true, start = ls.castAt, dur = ls.cd or 300, remaining = rem } end
                    else o = { onCD = false } end
                elseif sp.info.type == "pot" then
                    local rem = (sp.endTime or now) - now
                    o = { onCD = rem > 0, start = (sp.endTime or now) - (sp.total or 1),
                        dur = sp.total or 1, remaining = math.max(0, rem) }
                elseif m.isMe then
                    o = SelfState(base)
                    if sp.merged then      -- fusion : la variante EN RECHARGE prime
                        for _, s2 in ipairs(sp.merged) do
                            if s2 ~= base then
                                local o2 = SelfState(s2)
                                if o2 and o2.onCD and (not o.onCD or (o2.remaining or 0) > (o.remaining or 0)) then o = o2 end
                            end
                        end
                    end
                else
                    o = PeerState(m, base, now)
                    if sp.merged then
                        for _, s2 in ipairs(sp.merged) do
                            if s2 ~= base then
                                local o2 = PeerState(m, s2, now)
                                if o2 and o2.onCD and (not o.onCD or (o2.remaining or 0) > (o.remaining or 0)) then o = o2 end
                            end
                        end
                    end
                end
                ic:SetCD(sp.potIcon or NS.SpellIcon(dispSid), o)
                local state
                if o.onCD then state = ("%ds"):format(math.ceil(o.remaining or 0))
                elseif o.max and o.max > 1 then
                    state = ("%d/%d %s"):format(o.cur or 0, o.max, NS.L("charges"))
                else state = NS.L("Prêt") end
                local cat = showCat and CAT_TIP[sp.info.type] and NS.L(CAT_TIP[sp.info.type]) or nil
                ic:SetInfo(dispSid, NS.SpellName(dispSid, sp.info.label or ""),
                    ("%s — %s"):format(m.name, state), cat)
                ic:Show()
                x = x + ICON_SZ + ICON_GAP
            end

            if spells then for _, sp in ipairs(spells) do place(sp) end end
            -- slot potion PERMANENT (toujours visible) : prêt, ou en CD si une
            -- potion de combat a été utilisée (par soi ou un pair)
            if trackPot then
                local best
                if pots then
                    for _, e in ipairs(pots) do
                        if not best or (e.endTime or 0) > (best.endTime or 0) then best = e end
                    end
                end
                place({
                    sid = best and best.spellID or 0,
                    endTime = best and best.endTime, total = best and best.total,
                    potIcon = CD.PotionIcon(),
                    info = { type = "pot", cd = (best and best.total) or 300, label = NS.L("Potion") },
                })
            end
            if x > maxW then maxW = x end
            y = y + rowStep
        end
    end

    local totalW = math.max(maxW + 4, 150)
    for i = 1, us do stripePool[i]:SetWidth(totalW - 8) end

    for i = ui + 1, #iconPool do iconPool[i]:Hide() end
    for i = un + 1, #nameFsPool do nameFsPool[i]:Hide() end
    for i = us + 1, #stripePool do stripePool[i]:Hide() end
    overlay:SetHeight(math.max(y + 6, HEADER_H + rowStep))
    overlay:SetWidth(totalW)
end

local function BuildOverlay()
    if overlay then return end
    overlay = CreateFrame("Frame", "AsylumCompagnonCDFrame", UIParent)
    overlay:SetSize(OVERLAY_W, HEADER_H + ROW_H + 8)
    overlay:SetFrameStrata("MEDIUM")
    overlay:SetClampedToScreen(true)
    overlay:SetMovable(true)
    overlay:EnableMouse(true)
    overlay:Hide()

    -- chrome commun (fond, bordure 1 px, liseré accent, titre) : Bars.lua
    local chrome = NS.MakeOverlayChrome(overlay, "COOLDOWNS", {
        get = function() return CD.IsLocked() end,
        set = function(v) CD.SetLocked(v) end,
    })
    CD._syncLock = chrome.syncLock
    overlayBg, overlayBorders = chrome.bg, chrome.borders

    overlay:SetScript("OnMouseDown", function(self)
        if NS.db and NS.db.cdLocked then return end  -- verrou : pas de déplacement
        self:StartMoving()
    end)
    overlay:SetScript("OnMouseUp", function(self)
        self:StopMovingOrSizing()
        SavePosition()
    end)

    local grip = CreateFrame("Button", nil, overlay)
    grip:SetSize(12, 12)
    grip:SetPoint("BOTTOMRIGHT", -1, 1)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    -- Poignée bas-droite : DEUX axes indépendants (pas de ratio imposé).
    --   · horizontal  → taille globale (échelle uniforme, 0,6×–2×)
    --   · vertical ↓  → espacement entre les lignes de membres (cdRowGap, 0–30 px)
    grip:SetScript("OnMouseDown", function(self)
        local pScale = UIParent:GetEffectiveScale()
        local sx = overlay:GetLeft() * overlay:GetEffectiveScale()
        local sy = overlay:GetTop() * overlay:GetEffectiveScale()
        local _, cy0 = GetCursorPosition()
        local gap0 = (NS.db and NS.db.cdRowGap) or 0
        self:SetScript("OnUpdate", function()
            local cx, cy = GetCursorPosition()
            -- axe X → échelle globale
            local newScale = (cx - sx) / (OVERLAY_W * pScale)
            if newScale < 0.6 then newScale = 0.6 end
            if newScale > 2.0 then newScale = 2.0 end
            overlay:SetScale(newScale)
            overlay:ClearAllPoints()
            overlay:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT",
                sx / (pScale * newScale), sy / (pScale * newScale))
            -- axe Y (glisser vers le bas) → espacement des lignes
            local g = gap0 + math.floor((cy0 - cy) / 6 + 0.5)
            if g < 0 then g = 0 elseif g > 30 then g = 30 end
            if NS.db and NS.db.cdRowGap ~= g then
                NS.db.cdRowGap = g
                UpdateRows()
            end
        end)
    end)
    grip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        if NS.db then NS.db.cdScale = overlay:GetScale() end
        SavePosition()
        if NS.RefreshCDPage then NS.RefreshCDPage() end
    end)
    overlay.grip = grip
    grip:SetShown(not (NS.db and NS.db.cdLocked))  -- verrou de taille
end

-- ---------- API ----------
function CD.SetEnabled(on)
    if NS.db then NS.db.cdOverlay = on and true or false end
    CD.UpdateVisibility()
end

-- Aperçu : affiche le board même hors groupe (ta propre ligne sert de démo),
-- pour positionner le cadre. Coupé au changement d'onglet par l'UI (ShowPage).
function CD.SetPreview(on)
    CD._preview = on and true or false
    boardRoster = nil            -- reconstruit la ligne « moi »
    CD.UpdateVisibility()
end
function CD.IsPreview() return CD._preview and true or false end

-- Le board ne s'affiche QUE si : module activé (master) + en groupe avec un
-- VRAI joueur + contexte courant coché (Open World / Donjon / Raid).
function CD.UpdateVisibility()
    if not overlay then return end
    -- mode Aperçu (menu) : force l'affichage hors groupe pour placer le cadre
    local show = CD._preview or (CD.IsEnabled()
        and (not NS.HasRealGroup or NS.HasRealGroup())
        and (not NS.ModuleEnabledHere or NS.ModuleEnabledHere("cd")))
    overlay:SetShown(show and true or false)
    if show then
        -- 0,5 s suffit : le balayage des icônes s'anime tout seul (SetCooldown
        -- natif), le tick ne sert qu'aux textes de décompte et aux recyclages
        if not ticker then ticker = C_Timer.NewTicker(0.5, UpdateRows) end
    elseif ticker then
        ticker:Cancel()
        ticker = nil
    end
end

function CD.IsEnabled()
    return not (NS.db and NS.db.cdOverlay == false)
end

-- rafraîchit le board tout de suite (ex. changement d'espacement des lignes)
function CD.Refresh()
    boardRoster = nil
    if UpdateRows then UpdateRows() end
end

-- verrou du cadre : masque la poignée (plus de redimensionnement) ET bloque le
-- déplacement (voir OnMouseDown) — pour ne rien bouger par erreur.
function CD.SetLocked(on)
    if NS.db then NS.db.cdLocked = on and true or false end
    if overlay and overlay.grip then
        overlay.grip:SetShown(not (NS.db and NS.db.cdLocked))
    end
    if CD._syncLock then CD._syncLock() end              -- cadenas de la barre de titre
    if NS._syncCDMenuLock then NS._syncCDMenuLock() end   -- case « Verrouiller » du menu
end
function CD.IsLocked()
    return NS.db and NS.db.cdLocked and true or false
end

function CD.ResetPosition()
    if NS.db then NS.db.cdPos = nil; NS.db.cdScale = nil end
    if overlay then overlay:SetScale(1) end
    if overlay and overlay.grip then overlay.grip:SetShown(not (NS.db and NS.db.cdLocked)) end
    ApplyPosition()
end

-- purge des CD d'un type qu'on vient de décocher (nettoyage immédiat)
function CD.PruneHidden()
    for id, e in pairs(active) do
        if not (NS.IsCDShown and NS.IsCDShown(e.spellID)) then active[id] = nil end
    end
    boardRoster = nil -- la sélection de CD a changé → listes par membre à refaire
end

-- ---------- Init / événements ----------
local caster = CreateFrame("Frame")
local function InitCastEvents()
    local ok = pcall(caster.RegisterEvent, caster, "UNIT_SPELLCAST_SUCCEEDED")
    CD.available = ok and true or false
    if CD.available then
        caster:SetScript("OnEvent", function(_, _, unit, _, spellID)
            if type(unit) ~= "string" then return end
            pcall(OnCastSucceeded, unit, spellID)
        end)
    end
end

local ev = CreateFrame("Frame")
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
ev:RegisterEvent("GROUP_ROSTER_UPDATE")
ev:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
ev:RegisterEvent("TRAIT_CONFIG_UPDATED")
ev:RegisterEvent("GET_ITEM_INFO_RECEIVED")
ev:RegisterEvent("ZONE_CHANGED_NEW_AREA")   -- changement de contexte (monde/donjon/raid)
local knownTimer
local function QueueKnown()
    if knownTimer then return end
    knownTimer = C_Timer.NewTimer(2, function()
        knownTimer = nil
        CD.RefreshRoster()
        CD.RefreshKnown()
    end)
end
ev:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_ENTERING_WORLD" then
        if not overlay then
            C_Timer.After(3, function()
                BuildOverlay()
                ApplyPosition()
                CD.ApplyStyle()
                CD.RefreshRoster()
                BuildPotionEffects()
                InitCastEvents()
                CD.SetEnabled(CD.IsEnabled())
                -- sondage battle rez : tourne TOUJOURS (on diffuse aux amis
                -- même si l'affichage local est coupé)
                if not CD._resTicker then
                    CD._resTicker = C_Timer.NewTicker(2, CD.PollRes)
                end
                CD.RefreshKnown()
            end)
        else
            -- re-zone : le contexte (monde/donjon/raid) et le sous-groupe
            -- peuvent changer → on reconstruit + réévalue la visibilité
            CD.RefreshRoster()
            CD.UpdateVisibility()
        end
    elseif event == "ZONE_CHANGED_NEW_AREA" then
        CD.UpdateVisibility()
    elseif event == "GROUP_ROSTER_UPDATE" then
        CD.RefreshRoster()
        CD.UpdateVisibility()
        QueueKnown()
    elseif event == "GET_ITEM_INFO_RECEIVED" then
        if not potionReady then BuildPotionEffects() end
    else
        -- changement de spé ou de talents → on recalcule nos CD réels
        QueueKnown()
    end
end)
