-- ============================================================
-- Asylum Compagnon — Sync.lua
-- Synchro entre joueurs via messages d'addon (canal invisible).
-- Même principe que KickSync / Northern Sky Raid Tools (AceComm).
-- Protocole :
--   H  = « hello » : présence + méta (classe, niveau, ilvl, or, items)
--   G  = « get »   : envoie-moi ton code SimC
--   C  = « chunk » : morceau i/n du code SimC
-- ============================================================

local ADDON_NAME, NS = ...

-- Deux préfixes = deux SEAUX de throttle séparés (10 jetons + 1/s CHACUN, côté
-- serveur). Les kicks ont le leur → jamais bloqués par une rafale de cooldowns. En
-- clé, comme le reste est coupé (régime 0.37), « AsylumSync » ne porte QUE les CD :
-- on obtient donc de fait un seau kicks + un seau cooldowns.
local PREFIX      = "AsylumSync"   -- tout sauf les kicks (CD, potions, rez, BL, hello, inv, SimC, coffre, stats, diag)
local PREFIX_KICK = "AsylumKick"   -- kicks uniquement (K, IC)
local OURS = { [PREFIX] = true, [PREFIX_KICK] = true }
local CHUNK_SIZE = 200
local MAX_CHUNKS = 120 -- garde-fou (≈ 24 Ko, le profil inclut les sacs)

local Sync = {}
NS.Sync = Sync

-- Enregistrement du préfixe (indispensable pour recevoir les messages)
Sync.available = false
if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
    local function reg(p)
        local res = C_ChatInfo.RegisterAddonMessagePrefix(p)
        return (res == true) or (res == 0)
            or (Enum and Enum.RegisterAddonMessagePrefixResult
                and res == Enum.RegisterAddonMessagePrefixResult.Success)
    end
    Sync.available = reg(PREFIX)
    reg(PREFIX_KICK)   -- 2e seau de throttle pour les kicks
end

-- ---------- Identité ----------
local myFull
local function MyFullName()
    if myFull then return myFull end
    local name, realm = UnitFullName("player")
    if not name then return nil end
    realm = (realm and realm ~= "" and realm)
        or (GetNormalizedRealmName and GetNormalizedRealmName()) or ""
    myFull = (realm ~= "") and (name .. "-" .. realm) or name
    return myFull
end

local function IsMe(sender)
    local me = MyFullName()
    if not me then return false end
    if sender == me then return true end
    return sender == (me:match("^(.-)%-") or me)
end

-- ---------- Canal de groupe (correctif majeur 0.27) ----------
-- En contenu INSTANCIÉ (donjon Mythique+, LFR…), le groupe est de catégorie
-- « instance » et le SEUL canal d'addon fiable est INSTANCE_CHAT. Les messages
-- envoyés à « PARTY »/« RAID » n'y sont PAS délivrés → c'est ce qui cassait,
-- pendant une clé, la vue des kicks / cooldowns / potions / stats, ET la clé
-- d'un membre hors guilde (ses infos ne passaient que par le canal de groupe,
-- alors que les guildeux étaient couverts par le canal GUILD). Hors instance,
-- on garde RAID puis PARTY. Réf. : DBM/BigWigs/OmniCD utilisent ce même schéma.
-- Catégorie « instance » (LFG/LFR/M+…). Midnight : on privilégie l'enum moderne,
-- avec repli sur l'ancien global puis la valeur 2.
local INSTANCE_CAT = (Enum and Enum.PartyCategory and Enum.PartyCategory.Instance)
    or LE_PARTY_CATEGORY_INSTANCE or 2
-- Routage standard (DBM/BigWigs) : INSTANCE_CHAT si groupe de CATÉGORIE instance
-- (LFG/LFR), sinon RAID puis PARTY. NB : on ne peut PAS forcer INSTANCE_CHAT sur
-- un groupe premade « home » (l'envoi est rejeté) — d'où le retour à ce schéma.
-- Le cas « rien ne passe en clé M+ premade » est diagnostiqué par /asylum ping
-- (sonde multi-canal) pour savoir QUEL canal délivre réellement.
local function GroupChannel()
    if IsInGroup(INSTANCE_CAT) then return "INSTANCE_CHAT" end
    if IsInRaid() then return "RAID" end
    if IsInGroup() then return "PARTY" end
    return nil
end
Sync.GroupChannel = GroupChannel

-- Vraie clé Mythique+ CHRONOMÉTRÉE active ? En clé, TOUS les canaux d'addon sont
-- bloqués (code 11 AddOnMessageLockdown — PARTY/RAID/INSTANCE/GUILD *et* WHISPER,
-- vérifié à 3 clients) et le budget d'envoi est serré (throttle 10 msg + 1/s). On
-- coupe alors les diffusions NON essentielles (buff/inventaire/SimC/coffre/butin/
-- stats live). Délégué à NS.Restrictions (source unique de vérité).
local function InKey()
    if NS.Restrictions and NS.Restrictions.InKey then return NS.Restrictions.InKey() end
    if C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive then
        local ok, active = pcall(C_ChallengeMode.IsChallengeModeActive)
        return (ok and active) and true or false
    end
    return false
end
Sync.InKey = InKey

-- ---------- File d'envoi (anti-déconnexion / throttle Midnight) ----------
local queue = {}
local ticker
local Enqueue   -- déclaration anticipée (réessai borné sur throttle)
local MAX_RETRY    = 5     -- réessais max d'un message throttlé (code 3) avant abandon
local RETRY_EXPIRE = 8     -- s : au-delà, un message throttlé est périmé → abandon

-- Limiteur GLOBAL anti-déconnexion (tous préfixes confondus). La doc prévient
-- qu'envoyer TROP, simultanément, sur plusieurs préfixes peut DÉCONNECTER le
-- client. On plafonne le débit TOTAL avec un seau maison (rafale 10, +5/s).
local gTok, gLast = 10, GetTime()
local function GlobalToken()
    local now = GetTime()
    gTok = math.min(10, gTok + (now - gLast) * 5)
    gLast = now
    if gTok >= 1 then gTok = gTok - 1; return true end
    return false
end

-- Envoi bas niveau qui VÉRIFIE le retour de C_ChatInfo.SendAddonMessage.
-- Renvoie (ok, code) — code = Enum.SendAddonMessageResult :
--   0  Success            → parti (mise en file serveur, pas garantie de livraison)
--   3  AddonMessageThrottle→ transitoire, réessai borné
--   11 AddOnMessageLockdown→ clé chronométrée : Blizzard bloque TOUS les canaux,
--      WHISPER COMPRIS (vérifié à 3 clients). On NE réessaie PAS, on arme LOCKED.
--   12 cible whisper indisponible
--   nil/true → vieux client sans code : on suppose parti.
-- Deux niveaux : (1) préventif — si NS.Restrictions annonce « bloqué », on ne
-- tente même pas (retourne 11) ; (2) autoritaire — un 11 RÉEL arme le verrou LOCKED.
-- prefix : nil = PREFIX (défaut) · PREFIX_KICK pour les kicks (seau séparé).
local function RawSend(msg, channel, target, prefix)
    if NS.Restrictions and NS.Restrictions.OutgoingBlocked() then
        return false, 11   -- préventif : inutile de tenter, tout est bloqué
    end
    if not GlobalToken() then return false, 3 end   -- pic bridé → réessai (code 3)
    local ok, ret = pcall(C_ChatInfo.SendAddonMessage, prefix or PREFIX, msg, channel, target)
    if not ok then return false, nil end
    if ret == nil or ret == true then return true, 0 end
    if ret == false then return false, nil end
    if type(ret) == "number" then
        if ret == 11 and NS.Restrictions then NS.Restrictions.NoteLockHit("send") end
        return ret == 0, ret
    end
    return false, nil
end

-- Cibles par CHUCHOTEMENT : chaque membre du groupe/raid (hors soi), en
-- « Nom-Royaume » (fiable, cross-royaume). NB : le WHISPER est bloqué LUI AUSSI en
-- clé chronométrée (code 11) — ce n'est donc PAS un repli. Sert uniquement aux
-- SONDES de la page Diag (mesurer si le whisper passe dans un contexte donné).
local function GroupWhisperTargets()
    local out = {}
    local function add(u)
        if UnitExists(u) and UnitIsPlayer(u) and not UnitIsUnit(u, "player") then
            local n, r = UnitFullName(u)
            if n then out[#out + 1] = (r and r ~= "") and (n .. "-" .. r) or n end
        end
    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 out
end

-- Envoi sur un canal de GROUPE, avec gestion EXPLICITE des codes retour Midnight.
-- Plus AUCUN repli WHISPER : en clé chronométrée le whisper est bloqué comme le
-- reste (code 11) ; le repli ne faisait qu'amplifier un message refusé en 4 whispers
-- inutiles (voire les perdre au dépilage). Codes :
--   0  → parti
--   3  → throttle : réessai borné via la file
--   11 → LOCKED : message ABANDONNÉ, file VIDÉE (tout donnerait 11)
--   12 / nil / autre → abandon prudent
local function SendGroup(msg, channel, prefix)
    local ok, code = RawSend(msg, channel, nil, prefix)
    if ok then return end
    if code == 3 then Enqueue(msg, channel, nil, prefix); return end
    if code == 11 then wipe(queue); return end   -- clé : ne rien réessayer, ne rien chuchoter
    -- 12 / nil / autre : abandon (pas de repli)
end

-- Dépilage : un seul chemin d'envoi (RawSend) pour TOUS les canaux → plus de perte
-- silencieuse (avant, un whisper throttlé sorti de la file était ignoré). Codes :
--   ok    → terminé
--   3     → réessai BORNÉ (tries ≤ MAX_RETRY et âge ≤ RETRY_EXPIRE), sinon abandon
--   11    → clé : on vide la file (tout donnerait 11)
--   autre → abandon
local function Pump()
    local item = table.remove(queue, 1)
    if not item then
        if ticker then ticker:Cancel(); ticker = nil end
        return
    end
    local ok, code = RawSend(item.msg, item.channel, item.target, item.prefix)
    if ok then return end
    if code == 3 then
        item.tries = (item.tries or 0) + 1
        if item.tries <= MAX_RETRY and (GetTime() - (item.bornAt or GetTime())) <= RETRY_EXPIRE then
            queue[#queue + 1] = item   -- re-tente au prochain tick
        end
        return   -- sinon : périmé/épuisé → abandon borné (voulu, pas une perte silencieuse)
    end
    if code == 11 then wipe(queue); return end
    -- 12 / nil / autre : abandon
end
function Enqueue(msg, channel, target, prefix)
    if not Sync.available then return end
    queue[#queue + 1] = { msg = msg, channel = channel, target = target, prefix = prefix,
                          tries = 0, bornAt = GetTime() }
    if not ticker then
        ticker = C_Timer.NewTicker(0.9, Pump)   -- ~1 msg/s : respecte le throttle (10 + 1/s)
    end
end

-- ---------- Émission ----------
-- H \t reply \t version \t classe \t niveau \t specID \t ilvl \t ts \t or \t id:count/id:count…
local function BuildHello(wantReply)
    local p = NS.db and NS.db.myProfile
    local _, classToken = UnitClass("player")

    -- seuls les objets déjà possédés au moins une fois sont annoncés
    local items = {}
    for _, t in ipairs(NS.TRACKED_ITEMS or {}) do
        local pid = t.ids and t.ids[1]
        local v = p and p.items and pid and p.items[pid]
        if v ~= nil then
            items[#items + 1] = ("%d:%d"):format(pid, v)
        end
    end

    return ("H\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%s\t%s"):format(
        wantReply and 1 or 0,
        NS.VERSION or "?",
        (p and p.class) or classToken or "?",
        (p and p.level) or UnitLevel("player") or 0,
        (p and p.specID) or 0,
        (p and p.ilvl) or 0,
        (p and p.ts) or 0,
        (p and p.gold) or 0,
        table.concat(items, "/"),
        (p and p.flags) or "")
end

-- Annonce sa présence au groupe/raid et à la guilde
function Sync.BroadcastHello(wantReply)
    -- En clé M+ on ne diffuse plus les buffs/présence (le check buff = avant le pull).
    if not Sync.available or not NS.db or InKey() then return end
    local msg = BuildHello(wantReply)
    if IsInGuild() then Enqueue(msg, "GUILD") end
    local ch = GroupChannel()
    if ch then Enqueue(msg, ch) end
end

-- Annonce (hello) au groupe/raid UNIQUEMENT — pas de guilde.
-- Utilisé par le check à l'appel : quand un ready check démarre, chaque
-- client rediffuse ses flags à jour (flacon/bouffe/huile) à ses coéquipiers.
function Sync.AnnounceGroup()
    if not Sync.available or not NS.db or InKey() then return end
    local ch = GroupChannel()
    if ch then Enqueue(BuildHello(false), ch) end
end

-- Diffuse l'ilvl par emplacement (aide au butin, message « E »).
-- payload = "inv:ilvl/inv:ilvl/…" construit par NS.Loot.
function Sync.BroadcastSlots(payload)
    if not Sync.available or not NS.db or not payload or payload == "" or InKey() then return end
    local msg = "E\t" .. payload
    if #msg > 255 then msg = msg:sub(1, 255) end
    if IsInGuild() then Enqueue(msg, "GUILD") end
    local ch = GroupChannel()
    if ch then Enqueue(msg, ch) end
end

-- Diffuse clé M+ + coffre convoité (aide clés/vault, message « V »).
function Sync.BroadcastVault(payload)
    if not Sync.available or not NS.db or not payload or payload == "" or InKey() then return end
    local msg = "V\t" .. payload
    if #msg > 255 then msg = msg:sub(1, 255) end
    if IsInGuild() then Enqueue(msg, "GUILD") end
    local ch = GroupChannel()
    if ch then Enqueue(msg, ch) end
end

-- Demande le code SimC d'un joueur (clé = "Nom-Royaume")
function Sync.RequestProfile(peerKey)
    if not Sync.available or InKey() then return end   -- pas de fetch SimC en clé
    Enqueue("G", "WHISPER", peerKey)
end

-- Kicks : messages courts et urgents → envoi direct (sans file),
-- au groupe/raid uniquement. Depuis Midnight les casts des autres
-- joueurs sont masqués côté client : chaque addon annonce donc ses
-- propres kicks, comme le font KickSync et consorts.
local function SendGroupNow(msg, prefix)
    if not Sync.available then return end
    local channel = GroupChannel()
    if channel then
        SendGroup(msg, channel, prefix)   -- codes gérés ; aucun repli whisper (bloqué en clé)
    end
end

function Sync.BroadcastKick(cd, spellID)
    SendGroupNow(("K\t%d\t%d"):format(math.floor(cd or 0), math.floor(spellID or 0)), PREFIX_KICK)
end

-- (BroadcastKickResult retiré : plus de réussi/raté — cf. Midnight, journal de combat indisponible.)

-- CD de l'interruption du joueur (rotation de kick) : « IC \t <cd> »
function Sync.BroadcastKickCD(cd)
    SendGroupNow(("IC\t%d"):format(math.floor(cd or 0)), PREFIX_KICK)
end

-- Cooldowns de groupe : « D \t spellID \t cd \t cur \t max \t recharge »
-- Pour un sort à charges, cd = durée de recharge d'UNE charge, cur = charges
-- restantes après le cast, max > 1, recharge = temps avant la prochaine charge.
-- Pour un sort normal, max <= 1 et cd = cooldown complet.
function Sync.BroadcastCooldown(spellID, cd, cur, max, recharge)
    SendGroupNow(("D\t%d\t%d\t%d\t%d\t%d"):format(
        math.floor(spellID or 0), math.floor(cd or 0),
        math.floor(cur or 0), math.floor(max or 0), math.floor(recharge or 0)))
end

-- Usage d'une potion de combat : « P \t spellID(effet) \t cd »
function Sync.BroadcastPotion(spellID, cd)
    SendGroupNow(("P\t%d\t%d"):format(math.floor(spellID or 0), math.floor(cd or 0)))
end

-- Compteur de résurrection de combat : « R \t charges \t secondes »
function Sync.BroadcastRes(charges, timeToNext)
    SendGroupNow(("R\t%d\t%d"):format(math.floor(charges or 0), math.floor(timeToNext or 0)))
end

-- Stats M+ : résumé du joueur « MS \t kh,km,off,def,ext,bl,br,pot »
function Sync.BroadcastStats(payload)
    SendGroupNow("MS\t" .. tostring(payload or ""))
end

-- Stats M+ détail des sorts (fin de donjon) « MSD \t o:sid*n,…;d:…;e:… »
function Sync.BroadcastStatsDetail(payload)
    SendGroupNow("MSD\t" .. tostring(payload or ""))
end

-- Alerte Bloodlust (Potion + Burst) : « BLA » (sans paramètre)
function Sync.BroadcastLustAlert()
    SendGroupNow("BLA")
end

-- Diagnostic de communication (« /asylum ping ») : envoie un PING au groupe
-- (canal instancié en donjon) ET à la guilde. Chaque client qui le reçoit
-- l'affiche et renvoie un PONG. Sert à isoler qui peut émettre/recevoir des
-- messages d'addon — ex. un joueur dont le client bloque les comms : les autres
-- ne voient pas SON ping, ou lui ne reçoit aucun pong.
function Sync.SendPing()
    if not Sync.available then
        if NS.Msg then NS.Msg(NS.L("synchro indisponible (préfixe non enregistré côté client).")) end
        return
    end
    -- SONDE MULTI-CANAL : un PING SÉPARÉ sur CHAQUE canal (PARTY, RAID,
    -- INSTANCE_CHAT si en instance, GUILD). Le pair répond en WHISPER (fiable) en
    -- indiquant le canal REÇU → tu vois EXACTEMENT quel(s) canal(aux) te délivrent,
    -- notamment une fois la clé M+ activée. But : identifier le canal qui marche.
    local tried = {}
    local function try(tag, chan)
        if pcall(C_ChatInfo.SendAddonMessage, PREFIX, "PING\t" .. tag, chan) then
            tried[#tried + 1] = chan
        end
    end
    if IsInGroup() then
        try("P", "PARTY")
        if IsInRaid() then try("R", "RAID") end
        if IsInInstance and IsInInstance() then try("I", "INSTANCE_CHAT") end
    end
    if IsInGuild() then try("G", "GUILD") end
    -- SONDE WHISPER — MESURE si le whisper passe dans ce contexte. On chuchote
    -- « PING\tW » à chaque membre du groupe. NB : en clé chronométrée le whisper est
    -- bloqué LUI AUSSI (code 11) — cette sonde sert à le CONSTATER, pas à s'y reposer.
    local wt = GroupWhisperTargets()
    local wn = 0
    for _, target in ipairs(wt) do
        if pcall(C_ChatInfo.SendAddonMessage, PREFIX, "PING\tW", "WHISPER", target) then wn = wn + 1 end
    end
    if wn > 0 then tried[#tried + 1] = ("WHISPER×%d"):format(wn) end
    if NS.Msg then
        if #tried > 0 then
            NS.Msg((NS.L("|cff8ab4f8ping diagnostic envoyé|r sur : %s. Chaque |cff22c55e[pong … ✓]|r te dit VIA QUEL CANAL un pote t'a reçu. En clé chronométrée, TOUT est bloqué (whisper compris).")):format(
                table.concat(tried, ", ")))
        else
            NS.Msg(NS.L("tu n'es ni en groupe ni en guilde : impossible de pinger."))
        end
        local _, itype = IsInInstance()
        NS.Msg((NS.L("|cff8b8fa3diag : instance=%s · groupe-instance=%s · cibles-whisper=%d · dispo=%s|r")):format(
            tostring(itype or "none"), tostring(IsInGroup(INSTANCE_CAT)), #wt, tostring(Sync.available)))
    end
end

-- Liste des CD réellement connus (spé + talents) : « CK \t sid/sid/… »
function Sync.BroadcastKnownCDs(payload)
    if not Sync.available or not NS.db then return end
    local msg = "CK\t" .. (payload or "")
    if #msg > 255 then msg = msg:sub(1, 255) end
    if IsInGuild() then Enqueue(msg, "GUILD") end
    local ch = GroupChannel()
    if ch then Enqueue(msg, ch) end
end

-- Envoie son code SimC en morceaux
local function StreamProfileTo(target)
    if InKey() then return end   -- SimC = gros payload chunké : jamais en clé
    local prof = NS.db and NS.db.myProfile
    if (not prof or not prof.simc) and NS.UpdateMyProfile then
        NS.UpdateMyProfile(false)
        prof = NS.db and NS.db.myProfile
    end
    if not prof or not prof.simc then return end

    local code, ts = prof.simc, prof.ts or 0
    local total = math.ceil(#code / CHUNK_SIZE)
    if total > MAX_CHUNKS then total = MAX_CHUNKS end
    for i = 1, total do
        local part = code:sub((i - 1) * CHUNK_SIZE + 1, i * CHUNK_SIZE)
        Enqueue(("C\t%d\t%d\t%d\t%s"):format(ts, i, total, part), "WHISPER", target)
    end
end

-- ---------- Réception ----------
local function EnsurePeer(sender)
    NS.db.peers = NS.db.peers or {}
    local p = NS.db.peers[sender]
    if not p then p = {}; NS.db.peers[sender] = p end
    return p
end

local function OnHello(sender, msg)
    -- format v0.7 (avec flags buffs), puis v0.3 (or + items), puis v0.2
    local flags
    local reply, ver, class, level, specID, ilvl, ts, gold, itemsStr, flagsStr =
        msg:match("^H\t(%d)\t([^\t]*)\t([^\t]*)\t(%d+)\t(%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t]*)\t([^\t]*)$")
    if reply then
        flags = flagsStr
    else
        reply, ver, class, level, specID, ilvl, ts, gold, itemsStr =
            msg:match("^H\t(%d)\t([^\t]*)\t([^\t]*)\t(%d+)\t(%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t]*)$")
    end
    if not reply then
        reply, ver, class, level, specID, ilvl, ts =
            msg:match("^H\t(%d)\t([^\t]*)\t([^\t]*)\t(%d+)\t(%d+)\t(%d+)\t(%d+)$")
    end
    if not reply then return end

    local p = EnsurePeer(sender)
    p.ver, p.class = ver, class
    p.level  = tonumber(level)
    p.specID = tonumber(specID)
    p.ilvl   = tonumber(ilvl)
    p.profileTs = tonumber(ts) -- version du profil annoncée par le pair
    p.online = true
    p.lastSeen = (GetServerTime and GetServerTime()) or time()
    -- Provenance : ces données viennent d'un message addon RÉELLEMENT reçu (donc hors
    -- lockdown). Elles sont EXACT_SYNC — à ne jamais confondre, en clé, avec une
    -- estimation locale (qui, elle, sera OBSERVED/INFERRED).
    p.source = (NS.SOURCE and NS.SOURCE.EXACT_SYNC) or "EXACT_SYNC"

    if gold then
        p.gold = tonumber(gold)
        p.items = {}
        if itemsStr then
            for id, count in itemsStr:gmatch("(%d+):(%d+)") do
                p.items[tonumber(id)] = tonumber(count)
            end
        end
    end
    if flags and flags ~= "" then p.flags = flags end

    if reply == "1" then
        Enqueue(BuildHello(false), "WHISPER", sender)
    end
    if NS.RefreshOptimUI then NS.RefreshOptimUI("roster", sender) end
    if NS.RefreshBuffsUI then NS.RefreshBuffsUI() end   -- flags à jour → grille buffs/accueil live
    if NS.Kicks and NS.Kicks.RefreshRoster then NS.Kicks.RefreshRoster() end
    if NS.Cooldowns and NS.Cooldowns.InvalidateRoster then NS.Cooldowns.InvalidateRoster() end
    -- Échos (rediffusion de NOS slots / vault / CD connus) : UNIQUEMENT pour
    -- un hello « reply=1 » (connexion ou arrivée dans le groupe — le pair a
    -- vraiment besoin de nos données). Avant, chaque heartbeat (60 s × chaque
    -- pair) déclenchait un écho E+V+CK chez TOUS les clients, vers guilde +
    -- groupe : un bruit de fond permanent multiplié par le nombre d'amis.
    if reply == "1" then
        if NS.Loot and NS.Loot.OnPeerHello then NS.Loot.OnPeerHello() end
        if NS.Vault and NS.Vault.OnPeerHello then NS.Vault.OnPeerHello() end
        if NS.Cooldowns and NS.Cooldowns.OnPeerHello then NS.Cooldowns.OnPeerHello() end
    end
end

local incoming = {}
local function OnChunk(sender, msg)
    local ts, i, n, data = msg:match("^C\t(%d+)\t(%d+)\t(%d+)\t(.*)$")
    if not ts then return end
    i, n = tonumber(i), tonumber(n)
    if not i or not n or n < 1 or n > MAX_CHUNKS or i < 1 or i > n then return end

    local key = sender .. "#" .. ts
    local buf = incoming[key]
    if not buf then
        buf = { parts = {}, count = 0, n = n }
        incoming[key] = buf
    end
    buf.at = (GetServerTime and GetServerTime()) or time() -- pour la purge
    if not buf.parts[i] then
        buf.parts[i] = data
        buf.count = buf.count + 1
    end

    if buf.count >= buf.n then
        incoming[key] = nil
        local p = EnsurePeer(sender)
        p.simc = table.concat(buf.parts, "", 1, buf.n)
        p.ts = tonumber(ts)
        p.online = true
        if NS.RefreshOptimUI then NS.RefreshOptimUI("peerdata", sender) end
    end
end

-- ============================================================
-- DIAGNOSTIC (page « Diag ») — batterie de tests + récap copiable.
-- But : isoler pourquoi rien ne se synchronise en clé. On mesure séparément
-- l'ENVOI (codes retour bruts de SendAddonMessage) et la RÉCEPTION (compteur).
-- ============================================================
Sync._rx = Sync._rx or { n = 0, self = 0, by = {} }

local diagOut = {}
-- Jeton de SESSION : « Vider » l'incrémente → tout callback différé d'une sonde
-- (timer de WhisperTest/CombatProbe) capturé AVANT le Vider
-- devient un no-op quand il se déclenche enfin. Corrige les « callbacks fantômes »
-- (ex. deux « WHISPER MUET » d'anciens RunAll qui finissaient dans un nouveau journal).
local diagGen = 0
-- Bilingue FR/EN (selon la langue du client) + sortie 100 % ASCII : pas de fleche
-- ni de tabulation, qui s'affichent en carre dans les polices du jeu.
local IS_FR = (GetLocale and GetLocale() == "frFR")
local function dL(fr, en) if IS_FR then return fr end return en or fr end
-- Nettoie une chaine pour l'affichage : tabulation -> '|', autres caracteres de
-- controle -> '?' (evite les carres dans l'apercu des messages).
local function dsan(s) return (tostring(s or "")):gsub("\t", "|"):gsub("[%z\1-\31\127]", "?") end
local function demit(line)
    diagOut[#diagOut + 1] = line
    if NS.Msg then NS.Msg("|cff8b8fa3[diag]|r " .. line) end
end
-- Enregistre les détails volumineux sans polluer le chat pendant le combat.
local function dstore(line)
    diagOut[#diagOut + 1] = line
end
local function dhead(t) demit("==== " .. t .. " ====") end

-- Retour BRUT de SendAddonMessage (valeur + type) : dit si un canal est bloque (11)
-- ou ok (0/true) en cle. Envoie un « DIAG PROBE » inoffensif.
local function rawRet(channel, target)
    local ok, ret = pcall(C_ChatInfo.SendAddonMessage, PREFIX, "DIAG\tPROBE", channel, target)
    if not ok then return dL("ERREUR pcall", "pcall ERROR") end
    return ("%s (%s)"):format(tostring(ret), type(ret))
end

Sync.Diag = {}

function Sync.Diag.Env()
    dhead(dL("ENVIRONNEMENT", "ENVIRONMENT"))
    demit(dL("version de l'addon", "addon version") .. " = " .. tostring(NS.VERSION))
    local reg
    if C_ChatInfo and C_ChatInfo.IsAddonMessagePrefixRegistered then
        reg = C_ChatInfo.IsAddonMessagePrefixRegistered(PREFIX)
    end
    demit(dL(
        ("prefixe '%s' enregistre = %s   (doit etre true, sinon tu ne recois rien)"):format(PREFIX, tostring(reg)),
        ("prefix '%s' registered = %s   (must be true, else you receive nothing)"):format(PREFIX, tostring(reg))))
    if C_ChatInfo and C_ChatInfo.IsAddonMessagePrefixRegistered then
        demit(("prefixe '%s' (kicks) enregistre = %s"):format(PREFIX_KICK,
            tostring(C_ChatInfo.IsAddonMessagePrefixRegistered(PREFIX_KICK))))
    end
    demit("Sync.available = " .. tostring(Sync.available))
    demit(dL("mon perso", "my char") .. " = " .. tostring(MyFullName()))
    local inInst, itype = IsInInstance()
    demit(("IsInGroup=%s | IsInRaid=%s | %s=%s"):format(
        tostring(IsInGroup()), tostring(IsInRaid()),
        dL("groupe-instance", "instance-group"), tostring(IsInGroup(INSTANCE_CAT))))
    demit((dL("instance=%s (dans une instance=%s) | canal de groupe choisi = %s",
              "instance=%s (in an instance=%s) | chosen group channel = %s")):format(
        tostring(itype), tostring(inInst), tostring(GroupChannel())))
    demit(dL("membres du groupe", "group members") .. " = " ..
        tostring((GetNumGroupMembers and GetNumGroupMembers()) or 0))
    if not IsInGroup() then
        demit(dL(">>> tu es SOLO : impossible de tester la synchro entre joueurs. Rejoins le groupe / la cle et relance.",
                 ">>> you are SOLO: cannot test player-to-player sync. Join the group / key and rerun."))
    end
end

function Sync.Diag.SendCodes()
    dhead(dL("CODES RETOUR D'ENVOI", "SEND RETURN CODES"))
    demit(dL("(0 ou true = OK | 11 = canal BLOQUE (instance/cle) | 5 = pas en groupe | nil = vieux client)",
             "(0 or true = OK | 11 = channel BLOCKED (instance/key) | 5 = not in group | nil = old client)"))
    if IsInGroup() then
        demit("PARTY         = " .. rawRet("PARTY"))
        if IsInRaid() then demit("RAID          = " .. rawRet("RAID")) end
        demit("INSTANCE_CHAT = " .. rawRet("INSTANCE_CHAT"))
    else
        demit(dL("(pas en groupe : PARTY/RAID/INSTANCE non testes)", "(not in group: PARTY/RAID/INSTANCE not tested)"))
    end
    if IsInGuild() then demit("GUILD         = " .. rawRet("GUILD")) end
    -- WHISPER vers SOI-MEME : marche meme SOLO -> dit si le whisper (le repli des
    -- cles) passe dans une instance. C'EST le test decisif en Midnight.
    local me = MyFullName()
    if me then
        demit("WHISPER(self) = " .. rawRet("WHISPER", me))
        demit(dL("   ^ si 0/true DANS une instance, le repli whisper est viable ; si 11, meme le whisper est bloque.",
                 "   ^ if 0/true INSIDE an instance, the whisper fallback is viable ; if 11, even whisper is blocked."))
    end
    local wt = GroupWhisperTargets()
    if #wt == 0 then demit(dL("(aucune cible whisper de groupe : groupe vide)", "(no group whisper target: empty group)")) end
    for _, t in ipairs(wt) do
        demit(("WHISPER %s = %s"):format(t, rawRet("WHISPER", t)))
    end
end

function Sync.Diag.Rx()
    dhead(dL("RECEPTION", "RECEPTION"))
    local r = Sync._rx or {}
    local peers = (r.n or 0) - (r.self or 0)
    local others = ((GetNumGroupMembers and GetNumGroupMembers()) or 0)
    if others > 0 then others = others - 1 end   -- hors moi
    demit((dL("recus au total = %d | de moi (loopback) = %d | DE PAIRS = %d",
              "received total = %d | from me (loopback) = %d | FROM PEERS = %d")):format(r.n or 0, r.self or 0, peers))
    if (r.self or 0) > 0 then
        demit(dL(">>> tu recois tes PROPRES messages (loopback>0) : prefixe OK + evenement OK + envoi OK. Ta reception FONCTIONNE.",
                 ">>> you receive your OWN messages (loopback>0): prefix OK + event OK + send OK. Your reception WORKS."))
    end
    if IsInGroup() and others > 0 and peers == 0 then
        demit(dL(">>> DES PAIRS SONT LA mais 0 recu d'eux : soit ils n'ont pas l'addon, soit leur ENVOI ne t'atteint pas. C'est LE probleme a creuser.",
                 ">>> PEERS ARE PRESENT but 0 received from them: either they lack the addon, or their SEND doesn't reach you. THIS is the problem."))
    elseif not IsInGroup() then
        demit(dL(">>> tu es solo : 0 pair est NORMAL (personne pour t'envoyer). Refais le test en groupe / en cle.",
                 ">>> you are solo: 0 peer is NORMAL (nobody to send to you). Redo the test in a group / key."))
    end
    if r.last then
        demit((dL("dernier recu : de %s | debut='%s' | il y a %ds",
                  "last received: from %s | start='%s' | %ds ago")):format(
            tostring(r.last.from), dsan(r.last.head), time() - (r.last.at or time())))
    end
    for s, c in pairs(r.by or {}) do demit(("  - %s : %d msg"):format(tostring(s), c)) end
end

function Sync.Diag.Group()
    dhead(dL("GROUPE", "GROUP"))
    local n = 0
    local function add(u)
        if not UnitExists(u) then return end
        n = n + 1
        local nm, rl = UnitFullName(u)
        local full = nm and ((rl and rl ~= "" and (nm .. "-" .. rl)) or nm) or "?"
        demit(("  %s = %s (online=%s)"):format(u, full, tostring(UnitIsConnected(u))))
    end
    if IsInRaid() then for i = 1, 40 do add("raid" .. i) end
    elseif IsInGroup() then add("player"); for i = 1, 4 do add("party" .. i) end
    else demit("(solo)") end
    demit(dL("unites trouvees", "units found") .. " = " .. n)
end

function Sync.Diag.PingWhisper()
    dhead(dL("PING WHISPER (attends les [pong] via WHISPER dans le chat)",
             "WHISPER PING (wait for [pong] via WHISPER in chat)"))
    local wt = GroupWhisperTargets()
    demit(dL("cibles", "targets") .. " = " .. #wt)
    for _, t in ipairs(wt) do
        pcall(C_ChatInfo.SendAddonMessage, PREFIX, "PING\tW", "WHISPER", t)
        demit("  -> PING " .. dL("chuchote a ", "whispered to ") .. t)
    end
    if #wt == 0 then demit(dL("(aucune cible : groupe vide)", "(no target: empty group)")) end
end

-- Test WHISPER en ALLER-RETOUR : on se chuchote a soi-meme et on verifie que le
-- message REVIENT (compteur loopback). Marche SOLO. Code d'envoi + reception =
-- le test complet : si ca revient DANS une instance, le repli whisper est viable.
function Sync.Diag.WhisperTest()
    dhead(dL("TEST WHISPER aller-retour (vers soi, marche solo)",
             "WHISPER round-trip test (to self, works solo)"))
    local me = MyFullName()
    if not me then demit("MyFullName = nil"); return end
    local before = (Sync._rx and Sync._rx.self) or 0
    local ok, ret = pcall(C_ChatInfo.SendAddonMessage, PREFIX, "DIAG\tWHO", "WHISPER", me)
    local code = ok and ("%s (%s)"):format(tostring(ret), type(ret)) or "pcall ERROR"
    demit("WHISPER(self) " .. me .. " -> " .. code)
    demit(dL("attends 2 s... (0/true a l'envoi + retour = whisper OK)",
             "waiting 2 s... (0/true on send + return = whisper OK)"))
    local gen = diagGen
    C_Timer.After(2, function()
        if gen ~= diagGen then return end   -- « Vider » entre-temps → callback obsolète
        local after = (Sync._rx and Sync._rx.self) or 0
        if after > before then
            demit(dL(">>> WHISPER OK : le message est REVENU. Le whisper passe dans ce contexte.",
                     ">>> WHISPER OK: the message CAME BACK. Whisper works in this context."))
        else
            demit(dL(">>> WHISPER MUET : rien n'est revenu. Ici le whisper ne delivre pas (bloque/throttle). En instance = le repli des cles ne marche pas.",
                     ">>> WHISPER SILENT: nothing came back. Whisper doesn't deliver here (blocked/throttled). In an instance = the key fallback won't work."))
        end
    end)
end

-- COMMTEST : l'etat lockdown (2 niveaux) + le code brut de CHAQUE canal, en une
-- passe bornee. C'est LE test « est-ce que je peux emettre, et sur quoi ? ».
-- Les codes sont mesures en DIRECT (rawRet) : ils disent la verite meme si le
-- verrou LOCKED est arme (la mesure ne doit pas dependre de notre propre etat).
function Sync.Diag.CommTest()
    dhead(dL("COMMTEST (canaux + lockdown)", "COMMTEST (channels + lockdown)"))
    local R = NS.Restrictions
    if R and R.Snapshot then
        local s = R.Snapshot()
        demit((dL("cle M+ chrono active = %s", "timed M+ key active = %s")):format(tostring(s.inKey)))
        demit(("AreOutgoingAddonChatMessagesRestricted = %s (api=%s)"):format(
            tostring(s.addonRestricted), tostring(s.addonApiPresent)))
        demit(("InChatMessagingLockdown = %s (api=%s)"):format(
            tostring(s.chatLocked), tostring(s.chatApiPresent)))
        -- 3 états DISTINCTS (ne pas confondre) :
        demit((dL("[preventif]  gate d'envoi = InKey = %s   -> si true, on ne tente meme pas",
                  "[preventive] send gate = InKey = %s   -> if true, we don't even try")):format(tostring(s.inKey)))
        demit((dL("[production] latch LOCKED (vrai 11 encaisse par un envoi REEL) = %s | 11 comptes = %d",
                  "[production] LOCKED latch (real 11 from an ACTUAL send) = %s | 11 hits = %d")):format(
            tostring(s.locked), s.lockHits or 0))
        demit(dL("   (latch a false meme en cle = NORMAL : le gate preventif arrete l'envoi AVANT le vrai 11 ; et les sondes ci-dessous contournent le gate)",
                 "   (latch false even in a key = NORMAL: the preventive gate stops the send BEFORE the real 11 ; and the probes below bypass the gate)"))
    else
        demit("NS.Restrictions = nil (module non charge ?)")
    end
    demit(dL("codes bruts par canal (0/true=OK, 11=BLOQUE, 5=pas en groupe) :",
             "raw code per channel (0/true=OK, 11=BLOCKED, 5=not in group) :"))
    if IsInGroup() then
        demit("  PARTY         = " .. rawRet("PARTY"))
        if IsInRaid() then demit("  RAID          = " .. rawRet("RAID")) end
        demit("  INSTANCE_CHAT = " .. rawRet("INSTANCE_CHAT"))
    else
        demit(dL("  (pas en groupe : PARTY/RAID/INSTANCE non testes)",
                 "  (not in group: PARTY/RAID/INSTANCE not tested)"))
    end
    if IsInGuild() then demit("  GUILD         = " .. rawRet("GUILD")) end
    local me = MyFullName()
    if me then demit("  WHISPER(self) = " .. rawRet("WHISPER", me)) end
    demit(dL(">>> en cle chronometree, TOUT doit afficher 11 (whisper compris). Hors cle : 0/true.",
             ">>> in a timed key, ALL should show 11 (whisper included). Outside a key: 0/true."))
end

-- Sonde combat passive. Elle ne touche jamais au journal de combat : elle observe
-- uniquement les événements UNIT_* autorisés. Elle peut être armée avant le pull,
-- démarre au combat et s'arrête automatiquement quand le combat se termine.
local COMBAT_PROBE_DURATION = 600 -- sécurité : couvre aussi les combats longs
local COMBAT_PROBE_MAX_DETAILS = 40
local COMBAT_PROBE_MAX_MESSAGES = 40
local activeCombatProbe

local function DiagNow()
    if GetServerTime then
        local ok, value = pcall(GetServerTime)
        if ok and type(value) == "number" then return value end
    end
    return (time and time()) or 0
end

local function DiagStore()
    if type(NS.diaglog) == "table" then return NS.diaglog end
    AsylumCompagnonDiagLog = type(AsylumCompagnonDiagLog) == "table"
        and AsylumCompagnonDiagLog or {}
    NS.diaglog = AsylumCompagnonDiagLog
    return NS.diaglog
end

local function SaveCompletedDiag(text, probe, reason, elapsed)
    local store = DiagStore()
    local endedAt = DiagNow()
    store.schema_version = 1
    store.updated_at = endedAt
    store.last_completed = {
        addon_version = NS.VERSION,
        character = MyFullName(),
        started_at = probe.startedAtServer,
        ended_at = endedAt,
        duration_seconds = elapsed,
        reason = reason or "?",
        combat_seen = probe.combatSeen and true or false,
        line_count = #diagOut,
        text = text or "",
    }
end

function Sync.Diag.PrintFileInstructions()
    local last = DiagStore().last_completed
    if NS.Msg then
        if not (last and type(last.text) == "string" and last.text ~= "") then
            NS.Msg(dL("Aucun diagnostic termine n'est encore enregistre.",
                "No completed diagnostic has been recorded yet."))
            return
        end
        NS.Msg(dL("Le dernier diagnostic est memorise. Fais |cffffffff/reload|r pour que WoW l'ecrive sur le disque, puis envoie ce fichier :",
            "The latest diagnostic is stored. Run |cffffffff/reload|r so WoW writes it to disk, then send this file:"))
        NS.Msg("|cffffffff_retail_\\WTF\\Account\\<compte>\\<royaume>\\<personnage>\\SavedVariables\\AsylumCompagnon.lua|r")
    end
end

local function StopCombatProbe(reason, silent)
    local p = activeCombatProbe
    if not p then return false end
    activeCombatProbe = nil
    if p.timer then p.timer:Cancel(); p.timer = nil end
    if p.frame then
        pcall(p.frame.UnregisterAllEvents, p.frame)
        p.frame:SetScript("OnEvent", nil)
    end
    if silent or p.gen ~= diagGen then return true end

    local elapsed = math.max(0, math.floor((GetTime() - p.startedAt) + 0.5))
    local function FinishDiagnostic()
        local txt = (Sync.Diag and Sync.Diag.Recap) and Sync.Diag.Recap() or
            ("Asylum diag v" .. tostring(NS.VERSION) .. "\n" .. table.concat(diagOut, "\n"))
        SaveCompletedDiag(txt, p, reason, elapsed)
        if NS.Msg then
            NS.Msg(dL("|cff22c55eDIAGNOSTIC TERMINE|r - journal memorise. Utilise |cffffffff/asylum diagfile|r pour recuperer le fichier.",
                "|cff22c55eDIAGNOSTIC FINISHED|r - log stored. Use |cffffffff/asylum diagfile|r to retrieve the file."))
        end
    end
    dhead(dL("FIN SONDE COMBAT", "COMBAT PROBE FINISHED"))
    demit((dL("raison=%s | duree=%ds | casts locaux=%d | kicks locaux=%d",
              "reason=%s | duration=%ds | local casts=%d | local interrupts=%d")):format(
        reason or "?", elapsed, p.ownCasts, p.ownKickCasts))
    demit((dL("evenements interruption=%d | fins de canal interrompues=%d | fins normales ignorees=%d",
              "interrupt events=%d | interrupted channel stops=%d | normal channel stops ignored=%d")):format(
        p.interruptEvents, p.channelInterrupts, p.normalChannelStops))
    demit((dL("avec interruptedBy=%d | unit ennemi=%d | nameplate=%d | details affiches=%d/%d",
              "with interruptedBy=%d | enemy unit=%d | nameplate=%d | details shown=%d/%d")):format(
        p.withInterrupter, p.enemyEvents, p.nameplateEvents, p.details, COMBAT_PROBE_MAX_DETAILS))
    demit((dL("messages addon de pairs en combat=%d | kicks K=%d | cooldowns D=%d | details=%d/%d",
              "peer addon messages in combat=%d | kicks K=%d | cooldowns D=%d | details=%d/%d")):format(
        p.peerMessages, p.peerKickMessages, p.peerCooldownMessages,
        p.peerDetails, COMBAT_PROBE_MAX_MESSAGES))

    local messageTypes = {}
    for kind, count in pairs(p.peerByType) do messageTypes[#messageTypes + 1] = kind .. "=" .. count end
    table.sort(messageTypes)
    demit(dL("types de messages recus : ", "received message types: ") ..
        (#messageTypes > 0 and table.concat(messageTypes, ", ") or "none"))
    local messageSenders = {}
    for sender, count in pairs(p.peerBySender) do messageSenders[#messageSenders + 1] = dsan(sender) .. "=" .. count end
    table.sort(messageSenders)
    demit(dL("messages recus par joueur : ", "messages received by player: ") ..
        (#messageSenders > 0 and table.concat(messageSenders, ", ") or "none"))

    if not p.combatSeen then
        demit(dL(">>> Aucun combat detecte : arme la sonde juste avant le pull, puis joue le combat normalement.",
                 ">>> No combat detected: arm the probe just before the pull, then fight normally."))
        FinishDiagnostic()
        return true
    end

    local units = {}
    for unit, count in pairs(p.byUnit) do units[#units + 1] = unit .. "=" .. count end
    table.sort(units)
    demit(dL("tokens observes : ", "observed tokens: ") .. (#units > 0 and table.concat(units, ", ") or "none"))
    if p.nameplateEvents == 0 then
        demit(dL(">>> Aucun evenement nameplate : active les barres de nom ennemies et kicke un mob PENDANT son cast.",
                 ">>> No nameplate event: enable enemy nameplates and interrupt a mob WHILE it is casting."))
    elseif p.withInterrupter == 0 then
        demit(dL(">>> Le cast ennemi a ete vu, mais interruptedBy est toujours nil : aucune attribution de joueur possible avec ces evenements.",
                 ">>> The enemy cast was seen, but interruptedBy was always nil: these events cannot attribute a player."))
    else
        demit(dL(">>> Regarde les lignes 'by name/token/class' : plain=comparable, secret=affichable seulement, nil=non resolu.",
                 ">>> Check the 'by name/token/class' lines: plain=comparable, secret=display-only, nil=unresolved."))
    end
    -- Le résultat copiable est prêt dès la fin du combat, sans clic supplémentaire.
    FinishDiagnostic()
    return true
end

function Sync.Diag.CombatProbe()
    if activeCombatProbe then
        StopCombatProbe("manual", false)
        return
    end

    local hasSecret = (type(issecretvalue) == "function")
    local function isSecret(v)
        if not hasSecret then return false end
        local ok, secret = pcall(issecretvalue, v)
        return ok and secret and true or false
    end
    local function safeTag(v)
        if type(v) == "nil" then return "nil" end
        if isSecret(v) then return "secret:" .. type(v) end
        local ok, text = pcall(tostring, v)
        return ok and (type(v) .. "=" .. dsan(text)) or "?err"
    end

    local p = {
        gen = diagGen, startedAt = GetTime(), frame = CreateFrame("Frame"),
        startedAtServer = DiagNow(),
        combatSeen = InCombatLockdown and InCombatLockdown() and true or false,
        ownCasts = 0, ownKickCasts = 0,
        interruptEvents = 0, channelInterrupts = 0, normalChannelStops = 0,
        withInterrupter = 0, enemyEvents = 0, nameplateEvents = 0,
        details = 0, byUnit = {},
        peerMessages = 0, peerKickMessages = 0, peerCooldownMessages = 0,
        peerDetails = 0, peerByType = {}, peerBySender = {},
    }
    activeCombatProbe = p

    local function describeInterrupter(guid)
        if type(guid) == "nil" then return "name=nil | token=nil | class=nil" end

        local nameState = "error"
        if UnitNameFromGUID then
            local ok, name = pcall(UnitNameFromGUID, guid)
            if ok then
                if type(name) == "nil" then nameState = "nil"
                elseif isSecret(name) then nameState = "secret"
                else nameState = "plain" end
            end
        end

        local tokenState = "unavailable"
        if UnitTokenFromGUID then
            local ok, token = pcall(UnitTokenFromGUID, guid)
            tokenState = ok and safeTag(token) or "error"
        end

        local classState = "unavailable"
        if UnitClassFromGUID then
            local ok, _, classFile = pcall(UnitClassFromGUID, guid)
            classState = ok and safeTag(classFile) or "error"
        end
        return ("name=%s | token=%s | class=%s"):format(nameState, tokenState, classState)
    end

    local function cleanUnit(unit)
        return type(unit) == "string" and not isSecret(unit)
    end

    p.frame:SetScript("OnEvent", function(_, event, unit, castGUID, spellID, interruptedBy, castBarID)
        if p.gen ~= diagGen or activeCombatProbe ~= p then return end

        if event == "PLAYER_REGEN_DISABLED" then
            p.combatSeen = true
            p.startedAt = GetTime()
            dstore("  -- COMBAT START --")
            return
        elseif event == "PLAYER_REGEN_ENABLED" then
            if p.combatSeen then StopCombatProbe("combat_end", false) end
            return
        elseif not p.combatSeen then
            return
        end

        if event == "UNIT_SPELLCAST_SUCCEEDED" then
            p.ownCasts = p.ownCasts + 1
            local ok, cd = pcall(function() return NS.INTERRUPTS and NS.INTERRUPTS[spellID] end)
            if ok and type(cd) == "number" then
                p.ownKickCasts = p.ownKickCasts + 1
                dstore(("  OWN_KICK spellID=%s cd=%s"):format(safeTag(spellID), safeTag(cd)))
            end
            return
        end

        local hasInterrupter = type(interruptedBy) ~= "nil"
        if event == "UNIT_SPELLCAST_CHANNEL_STOP" and not hasInterrupter then
            p.normalChannelStops = p.normalChannelStops + 1
            return
        end
        if event == "UNIT_SPELLCAST_INTERRUPTED" then p.interruptEvents = p.interruptEvents + 1
        else p.channelInterrupts = p.channelInterrupts + 1 end
        if hasInterrupter then p.withInterrupter = p.withInterrupter + 1 end

        local enemy = false
        if cleanUnit(unit) then
            p.byUnit[unit] = (p.byUnit[unit] or 0) + 1
            if unit:find("^nameplate") then p.nameplateEvents = p.nameplateEvents + 1 end
            if UnitCanAttack then
                local ok, attackable = pcall(UnitCanAttack, "player", unit)
                enemy = ok and not isSecret(attackable) and attackable and true or false
            end
        end
        if enemy then p.enemyEvents = p.enemyEvents + 1 end

        if p.details < COMBAT_PROBE_MAX_DETAILS then
            p.details = p.details + 1
            local label = event == "UNIT_SPELLCAST_INTERRUPTED" and "INT" or "CHANNEL_STOP"
            dstore(("  %s unit=%s enemy=%s | castGUID=%s | spellID=%s | interruptedBy=%s | castBarID=%s"):format(
                label, safeTag(unit), tostring(enemy), safeTag(castGUID), safeTag(spellID),
                safeTag(interruptedBy), safeTag(castBarID)))
            if hasInterrupter then dstore("     by " .. describeInterrupter(interruptedBy)) end
        end
    end)

    pcall(p.frame.RegisterUnitEvent, p.frame, "UNIT_SPELLCAST_SUCCEEDED", "player")
    pcall(p.frame.RegisterEvent, p.frame, "UNIT_SPELLCAST_INTERRUPTED")
    pcall(p.frame.RegisterEvent, p.frame, "UNIT_SPELLCAST_CHANNEL_STOP")
    pcall(p.frame.RegisterEvent, p.frame, "PLAYER_REGEN_DISABLED")
    pcall(p.frame.RegisterEvent, p.frame, "PLAYER_REGEN_ENABLED")

    dhead(dL("SONDE COMBAT ARMEE", "COMBAT PROBE ARMED"))
    demit(dL("Fais un combat normal : chaque joueur doit kicker un VRAI cast ennemi et lancer au moins un cooldown suivi. La sonde s'arrete seule a la fin du combat.",
             "Fight normally: every player should interrupt one REAL enemy cast and use at least one tracked cooldown. The probe stops automatically when combat ends."))
    demit(dL("Active les nameplates ennemies. Un second clic arrete manuellement la sonde ; securite automatique apres 10 min.",
             "Enable enemy nameplates. A second click stops the probe manually; safety timeout after 10 minutes."))
    demit(dL("Les details sont enregistres sans spam dans le chat ; le recap copiable sera prepare automatiquement.",
             "Details are recorded without chat spam; the copyable recap will be prepared automatically."))
    demit("CombatLogGetCurrentEventInfo = " .. tostring(type(CombatLogGetCurrentEventInfo)) ..
        dL(" (nil attendu : aucun CLEU utilise)", " (nil expected: CLEU is not used)"))

    p.timer = C_Timer.NewTimer(COMBAT_PROBE_DURATION, function()
        if activeCombatProbe == p then
            p.timer = nil
            StopCombatProbe("timeout", false)
        end
    end)
    if NS.Msg then
        NS.Msg(dL("|cff22c55eDIAGNOSTIC DEMARRE|r - sonde active jusqu'a la fin du combat (10 min maximum).",
            "|cff22c55eDIAGNOSTIC STARTED|r - probe active until combat ends (10 minute maximum)."))
    end
end

-- Utilisé par les commandes macro : contrairement au bouton de la page Diag,
-- « assurer » une sonde active ne la coupe jamais si elle tourne déjà.
function Sync.Diag.IsCombatProbeActive()
    return activeCombatProbe ~= nil
end

function Sync.Diag.EnsureCombatProbe()
    if activeCombatProbe then return false, "already_active" end
    Sync.Diag.CombatProbe()
    return true, "started"
end

function Sync.Diag.RunAll()
    demit("==== Asylum diag v" .. tostring(NS.VERSION) ..
        " | locale=" .. tostring(GetLocale and GetLocale()) .. " ====")
    -- CommTest couvre déjà les codes de tous les canaux : ne pas relancer
    -- SendCodes/PingWhisper ici, sinon « Tout tester » se throttle lui-même.
    -- La sonde ne fait aucun envoi : on peut donc l'armer automatiquement.
    Sync.Diag.Env(); Sync.Diag.CommTest(); Sync.Diag.Rx(); Sync.Diag.Group()
    if not activeCombatProbe then Sync.Diag.CombatProbe() end
end

function Sync.Diag.Recap()
    local txt = "Asylum diag v" .. tostring(NS.VERSION) .. "\n" .. table.concat(diagOut, "\n")
    if NS.Diag_SetRecap then NS.Diag_SetRecap(txt) end
    if NS.Msg then
        NS.Msg((dL("|cff22c55erecap pret|r : %d lignes dans la zone copiable (Ctrl+A / Ctrl+C dedans).",
                   "|cff22c55erecap ready|r : %d lines in the copyable box (Ctrl+A / Ctrl+C inside).")):format(#diagOut))
    end
    return txt
end

function Sync.Diag.Clear()
    StopCombatProbe("clear", true)
    wipe(diagOut)
    -- Vraie NOUVELLE SESSION de diagnostic : on invalide les callbacks différés encore
    -- en attente (sondes lancées avant ce Vider ne pollueront plus le nouveau journal)
    -- et on remet les compteurs de messages addon à zéro.
    diagGen = diagGen + 1
    Sync._rx = { n = 0, self = 0, by = {} }
    if NS.Restrictions and NS.Restrictions.ClearLocked then NS.Restrictions.ClearLocked() end
    if NS.Diag_SetRecap then NS.Diag_SetRecap("") end
    if NS.Msg then NS.Msg(dL("[diag] journal + compteurs vides.", "[diag] log + counters cleared.")) end
end

-- Nouvelle session reproductible pour /asylum diag1 : les anciens callbacks
-- sont invalidés, tous les tests courts sont relancés et RunAll arme lui-même
-- la sonde couvrant le combat complet.
function Sync.Diag.StartFullSession()
    Sync.Diag.Clear()
    Sync.Diag.RunAll()
    return true
end

-- Pendant la sonde, journalise aussi le trafic réellement reçu des autres
-- clients. C'est le complément nécessaire aux événements UNIT_* : K prouve la
-- réception d'un kick partagé et D celle d'un cooldown partagé.
local function ObserveCombatProbeAddonMessage(prefix, msg, sender)
    local p = activeCombatProbe
    if not p or p.gen ~= diagGen or not p.combatSeen then return end
    local kind = msg:match("^([A-Z]+)") or "?"
    p.peerMessages = p.peerMessages + 1
    p.peerByType[kind] = (p.peerByType[kind] or 0) + 1
    p.peerBySender[sender] = (p.peerBySender[sender] or 0) + 1
    if kind == "K" then p.peerKickMessages = p.peerKickMessages + 1 end
    if kind == "D" then p.peerCooldownMessages = p.peerCooldownMessages + 1 end
    if p.peerDetails < COMBAT_PROBE_MAX_MESSAGES then
        p.peerDetails = p.peerDetails + 1
        dstore(("  PEER_MSG prefix=%s from=%s type=%s payload=%s"):format(
            dsan(prefix), dsan(sender), kind, dsan(msg:sub(1, 80))))
    end
end

local f = CreateFrame("Frame")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function(_, _, prefix, msg, _, sender)
    if not OURS[prefix] or type(msg) ~= "string" or not sender then return end
    local fromSelf = IsMe(sender)
    -- Diag : on compte TOUT ce qu'on reçoit avec notre préfixe, AVANT le filtre
    -- « self » (pour voir un éventuel loopback). Sert à la page Diag : si le total
    -- « de pairs » reste à 0 alors que des amis sont là, la réception est morte.
    do
        local r = Sync._rx; if not r then r = { n = 0, self = 0, by = {} }; Sync._rx = r end
        r.n = r.n + 1
        r.by[sender] = (r.by[sender] or 0) + 1
        r.last = { from = sender, head = msg:sub(1, 22), at = time() }
        if fromSelf then r.self = r.self + 1 end
    end
    if not fromSelf then ObserveCombatProbeAddonMessage(prefix, msg, sender) end
    if not NS.db or fromSelf then return end

    -- (message « KR » réussi/raté supprimé : plus de suivi hit/miss des kicks)
    -- « CK » (CD connus) avant le « C » de chunk, car même 1re lettre
    if msg:sub(1, 2) == "CK" then
        if NS.Cooldowns and NS.Cooldowns.OnPeerKnownCDs then
            NS.Cooldowns.OnPeerKnownCDs(sender, msg)
        end
        return
    end
    -- « MS » (stats M+) et « BLA » (alerte Bloodlust) — préfixes multi-lettres
    if msg:sub(1, 3) == "MSD" then
        if NS.Stats and NS.Stats.OnPeerStatsDetail then
            NS.Stats.OnPeerStatsDetail(sender, msg:match("^MSD\t(.*)$") or "")
        end
        return
    end
    if msg:sub(1, 2) == "MS" then
        if NS.Stats and NS.Stats.OnPeerStats then
            NS.Stats.OnPeerStats(sender, msg:match("^MS\t(.*)$") or "")
        end
        return
    end
    if msg == "BLA" then
        if NS.Stats and NS.Stats.OnLustAlert then NS.Stats.OnLustAlert(sender) end
        return
    end
    if msg:sub(1, 2) == "IC" then
        local cd = msg:match("^IC\t(%d+)$")
        if cd and NS.Kicks and NS.Kicks.OnPeerKickCD then
            NS.Kicks.OnPeerKickCD(sender, tonumber(cd))
        end
        return
    end
    -- Diagnostic multi-canal (« /asylum ping ») : « PING\t<canal> » reçu → on
    -- affiche PAR QUEL CANAL, et on répond « PONG\t<canal> » en WHISPER (fiable,
    -- cross-royaume) à l'émetteur pour qu'il sache ce qui lui passe.
    local CHNAME = { P = "PARTY", R = "RAID", I = "INSTANCE", G = "GUILDE", W = "WHISPER" }
    local pingTag = msg:match("^PING\t(%a)$")
    if pingTag then
        if NS.Msg then NS.Msg((NS.L("|cff8ab4f8[ping] reçu de %s via %s|r")):format(sender, CHNAME[pingTag] or pingTag)) end
        pcall(C_ChatInfo.SendAddonMessage, PREFIX, "PONG\t" .. pingTag, "WHISPER", sender)
        return
    end
    local pongTag = msg:match("^PONG\t(%a)$")
    if pongTag then
        if NS.Msg then NS.Msg((NS.L("|cff22c55e[pong] de %s ✓|r — il t'a reçu via %s")):format(sender, CHNAME[pongTag] or pongTag)) end
        return
    end

    local t = msg:sub(1, 1)
    if t == "H" then
        OnHello(sender, msg)
    elseif t == "E" then
        if NS.Loot and NS.Loot.OnPeerSlots then NS.Loot.OnPeerSlots(sender, msg) end
    elseif t == "V" then
        if NS.Vault and NS.Vault.OnPeerVault then NS.Vault.OnPeerVault(sender, msg) end
    elseif t == "G" then
        StreamProfileTo(sender)
    elseif t == "C" then
        OnChunk(sender, msg)
    elseif t == "K" then
        local cd, sid = msg:match("^K\t(%d+)\t(%d+)$")
        if not cd then cd = msg:match("^K\t(%d+)$") end
        if cd and NS.Kicks and NS.Kicks.OnPeerKick then
            NS.Kicks.OnPeerKick(sender, tonumber(cd), sid and tonumber(sid))
        end
    elseif t == "D" then
        local sid, cd, cur, mx, rc = msg:match("^D\t(%d+)\t(%d+)\t(%d+)\t(%d+)\t(%d+)$")
        if not sid then sid, cd = msg:match("^D\t(%d+)\t(%d+)$") end
        if sid and NS.Cooldowns and NS.Cooldowns.OnPeerCooldown then
            NS.Cooldowns.OnPeerCooldown(sender, tonumber(sid), tonumber(cd),
                cur and tonumber(cur), mx and tonumber(mx), rc and tonumber(rc))
        end
    elseif t == "P" then
        local sid, cd = msg:match("^P\t(%d+)\t(%d+)$")
        if sid and NS.Cooldowns and NS.Cooldowns.OnPeerPotion then
            NS.Cooldowns.OnPeerPotion(sender, tonumber(sid), tonumber(cd))
        end
    elseif t == "R" then
        local ch, nx = msg:match("^R\t(%d+)\t(%d+)$")
        if ch and NS.Cooldowns and NS.Cooldowns.OnPeerRes then
            NS.Cooldowns.OnPeerRes(sender, tonumber(ch), tonumber(nx))
        end
    end
end)

-- ============================================================
-- Présence en ligne : battement de cœur + détection hors ligne
-- Un addon ne reçoit AUCUN signal quand un autre joueur se déconnecte
-- (pas de journal de combat, les messages d'addon cessent simplement).
-- On procède donc par « heartbeat » : chaque client rediffuse un hello
-- toutes les HEARTBEAT s ; si on n'a plus de nouvelles d'un pair depuis
-- OFFLINE_AFTER s, on le repasse hors ligne. Avant, `online` n'était
-- JAMAIS remis à false → un perso vu une fois restait vert à vie.
-- ============================================================
local HEARTBEAT     = 10   -- s : fréquence de rediffusion du hello (÷3 en 0.34.9 :
                           -- roster/flacon/bouffe/profil se rafraîchissent bien plus
                           -- vite ; coût réseau négligeable : ~1 msg/10 s. Les
                           -- changements de buff/huile partent en plus tout de suite
                           -- (diffusion event-driven throttlée, voir Buffs.lua).)
local OFFLINE_AFTER = 40   -- s : sans nouvelles → hors ligne (doit rester > 2×HEARTBEAT)

-- Marque tous les pairs comme hors ligne (à ta connexion : on ne sait
-- pas encore qui est là ; leurs hellos re-verdiront les vrais en ligne).
function Sync.MarkAllOffline()
    if not (NS.db and NS.db.peers) then return end
    for _, p in pairs(NS.db.peers) do p.online = false end
    if NS.RefreshOptimUI then NS.RefreshOptimUI("roster") end
    if NS.RefreshVaultUI then NS.RefreshVaultUI() end
end

-- Balayage périodique : repasse hors ligne les pairs trop silencieux.
local function SweepOffline()
    if not (NS.db and NS.db.peers) then return end
    local now = (GetServerTime and GetServerTime()) or time()
    local changed = false
    for _, p in pairs(NS.db.peers) do
        if p.online and (now - (p.lastSeen or 0)) > OFFLINE_AFTER then
            p.online = false
            changed = true
        end
    end
    if changed then
        if NS.RefreshOptimUI then NS.RefreshOptimUI("roster") end
        if NS.Kicks and NS.Kicks.RefreshRoster then NS.Kicks.RefreshRoster() end
        if NS.RefreshVaultUI then NS.RefreshVaultUI() end -- un pair hors ligne disparaît du Coffre
    end
    -- transferts SimC interrompus (pair déconnecté en plein envoi) : on
    -- jette les morceaux incomplets après 3 min (sinon gardés à vie)
    for key, buf in pairs(incoming) do
        if (now - (buf.at or 0)) > 180 then incoming[key] = nil end
    end
end

-- Démarre les tickers (heartbeat + balayage). Idempotent.
local hbTicker, sweepTicker
function Sync.StartHeartbeat()
    if not Sync.available then return end
    if not hbTicker then
        hbTicker = C_Timer.NewTicker(HEARTBEAT, function()
            Sync.BroadcastHello(false)
        end)
    end
    if not sweepTicker then
        sweepTicker = C_Timer.NewTicker(15, SweepOffline)
    end
end
