-- ============================================================
-- 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 MAX_ROWS  = 14

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

-- couleurs par type de CD (barre/point)
local TYPE_COLOR = {
    off  = { 0.93, 0.42, 0.30 }, -- offensif : rouge/orange
    def  = { 0.35, 0.60, 0.95 }, -- défensif : bleu
    ext  = { 0.55, 0.85, 0.45 }, -- externe : vert
    lust = { 0.85, 0.55, 0.98 }, -- lust : violet
    res  = { 0.95, 0.82, 0.35 }, -- rez : or
}

local function ClassColor(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 1, 1, 1
end

-- 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

local function SpellIcon(spellID)
    local fn = (C_Spell and C_Spell.GetSpellTexture) or GetSpellTexture
    if not fn then return nil end
    local ok, tex = pcall(fn, spellID)
    if ok then return tex end
end

-- Midnight : certaines valeurs de cooldown/charges sont « secrètes » (non
-- lisibles par les addons) ; toute comparaison plante. On les neutralise (nil).
local function SafeNum(v)
    if issecretvalue and issecretvalue(v) then return nil end
    return v
end

-- état : [key..":"..spellID] = { key, name, class, spellID, label, type, endTime }
local active = {}
local overlay, overlayBg, overlayBorders, rows, ticker
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)

-- 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) ----------
local function MyKey()
    local name, realm = UnitFullName("player")
    if not name then return nil end
    realm = (realm and realm ~= "" and realm)
        or (GetNormalizedRealmName and GetNormalizedRealmName()) or ""
    return (realm ~= "") and (name .. "-" .. realm) or name
end

function CD.RefreshRoster()
    myGUID = UnitGUID("player")
    myKey  = MyKey()
    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
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
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 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)
    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
    if NS.Sync and NS.Sync.BroadcastRes then
        NS.Sync.BroadcastRes(cur, math.max(0, nextAt - GetTime()))
    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 function OwnerUnit(unit)
    if unit == "pet" then return "player" end
    local p = unit:match("^partypet(%d+)$")
    if p then return "party" .. p end
    local r = unit:match("^raidpet(%d+)$")
    if r then return "raid" .. r end
    return unit
end

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 ?
    if not potionReady then BuildPotionEffects() end
    local pot = potionEffect[spellID]
    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

-- pools : barres (rows) + entêtes de section (headers)
local headers = {}
local function GetBar(i)
    if rows[i] then return rows[i] end
    local bar = NS.CreateBar(overlay, OVERLAY_W - 16, ROW_H - 2)
    rows[i] = bar
    return bar
end
local function GetHeader(i)
    if headers[i] then return headers[i] end
    local h = overlay:CreateFontString(nil, "OVERLAY")
    h:SetFont(FONT, 10, "OUTLINE")
    headers[i] = h
    return h
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 }
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 = {}
    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 out[#out + 1] = sp 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 name, realm = UnitFullName(unit)
        if not name then return end
        realm = (realm and realm ~= "" and realm)
            or (GetNormalizedRealmName and GetNormalizedRealmName()) or ""
        local key = (realm ~= "") and (name .. "-" .. realm) or name
        if key == myKey then return end
        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
    if IsInRaid() then
        for i = 1, 40 do add("raid" .. i) end
    elseif IsInGroup() then
        for i = 1, 4 do add("party" .. i) end
    end
    return list
end

-- Temps restant réel de MON sort (charges/talents/hâte) ; 0 = prêt
-- Renvoie (restant, durée, start) du CD réel du joueur (0 = prêt)
local function SelfRemaining(sid, baseCD)
    local cfn = C_Spell and C_Spell.GetSpellCharges
    if cfn then
        local ok, ci = pcall(cfn, sid)
        if ok and type(ci) == "table" and (ci.maxCharges or 0) > 1 then
            if (ci.currentCharges or 0) > 0 then return 0, ci.cooldownDuration or baseCD, nil end
            if ci.cooldownStartTime and (ci.cooldownDuration or 0) > 1.5 then
                return math.max(0, ci.cooldownStartTime + ci.cooldownDuration - GetTime()),
                    ci.cooldownDuration, ci.cooldownStartTime
            end
        end
    end
    local gfn = C_Spell and C_Spell.GetSpellCooldown
    if gfn then
        local ok, info = pcall(gfn, sid)
        if ok and type(info) == "table" and (info.startTime or 0) > 0 and (info.duration or 0) > 1.5 then
            return math.max(0, info.startTime + info.duration - GetTime()), info.duration, info.startTime
        end
    elseif GetSpellCooldown then
        local ok, st, dur = pcall(GetSpellCooldown, sid)
        if ok and st and st > 0 and dur and dur > 1.5 then
            return math.max(0, st + dur - GetTime()), dur, st
        end
    end
    return 0, baseCD, nil
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" }
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" }
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", 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
    for _, m in ipairs(BuildBoardRoster()) do
        local spells = MemberSpells(m.class, m.specID, m.knownCDs, m.isMe)
        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 function place(sp)
                ui = ui + 1
                local ic = GetIcon(ui)
                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)
                else
                    o = PeerState(m, base, now)
                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()

    overlayBg = overlay:CreateTexture(nil, "BACKGROUND")
    overlayBg:SetTexture(WHITE)
    overlayBg:SetAllPoints()

    overlayBorders = {}
    for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
        local t = overlay:CreateTexture(nil, "BORDER")
        t:SetTexture(WHITE)
        if side == "TOP" then
            t:SetPoint("TOPLEFT"); t:SetPoint("TOPRIGHT"); t:SetHeight(1)
        elseif side == "BOTTOM" then
            t:SetPoint("BOTTOMLEFT"); t:SetPoint("BOTTOMRIGHT"); t:SetHeight(1)
        elseif side == "LEFT" then
            t:SetPoint("TOPLEFT"); t:SetPoint("BOTTOMLEFT"); t:SetWidth(1)
        else
            t:SetPoint("TOPRIGHT"); t:SetPoint("BOTTOMRIGHT"); t:SetWidth(1)
        end
        overlayBorders[#overlayBorders + 1] = t
    end

    local accent = overlay:CreateTexture(nil, "BORDER")
    accent:SetTexture(WHITE)
    accent:SetPoint("TOPLEFT"); accent:SetPoint("BOTTOMLEFT")
    accent:SetWidth(2)
    accent:SetVertexColor(0.655, 0.545, 0.98, 0.9)

    local title = overlay:CreateFontString(nil, "OVERLAY")
    title:SetFont(FONT, 10, "OUTLINE")
    title:SetTextColor(0.65, 0.66, 0.72, 1)
    title:SetPoint("TOPLEFT", 10, -7)
    title:SetText("COOLDOWNS")

    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

    rows = {}
end

-- ---------- API ----------
function CD.SetEnabled(on)
    if NS.db then NS.db.cdOverlay = on and true or false end
    if not overlay then return end
    overlay:SetShown(on and true or false)
    if on then
        if not ticker then ticker = C_Timer.NewTicker(0.2, 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()
    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
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
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")
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)
        end
    elseif event == "GROUP_ROSTER_UPDATE" then
        CD.RefreshRoster()
        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)
