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

local PREFIX     = "AsylumSync"
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 res = C_ChatInfo.RegisterAddonMessagePrefix(PREFIX)
    Sync.available = (res == true) or (res == 0)
        or (Enum and Enum.RegisterAddonMessagePrefixResult
            and res == Enum.RegisterAddonMessagePrefixResult.Success)
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

-- ---------- File d'envoi (anti-déconnexion / throttle Midnight) ----------
local queue = {}
local ticker

-- Envoi bas niveau qui VÉRIFIE le retour de C_ChatInfo.SendAddonMessage (Midnight) :
--   0  = envoyé
--   11 = clé Mythique+ CHRONOMÉTRÉE → Blizzard bloque TOUS les canaux de groupe
--        (PARTY / RAID / INSTANCE_CHAT). Le WHISPER, lui, passe encore.
--   nil = client sans code de retour → on suppose que c'est parti.
-- Retourne true si le message est (probablement) délivré. (Technique repérée dans
-- BliZzi_Interrupts : c'est LA raison du « rien ne passe en clé ».)
local function RawSend(msg, channel, target)
    local ok, ret = pcall(C_ChatInfo.SendAddonMessage, PREFIX, msg, channel, target)
    if not ok then return false end
    -- Midnight peut renvoyer un NOMBRE (Enum.SendAddonMessageResult : 0 = OK, 11 =
    -- clé M+ chronométrée bloquée) OU un BOOLÉEN selon le client. On gère les deux
    -- pour ne pas déclencher le repli whisper à tort (envoi réussi = booléen true).
    if ret == nil or ret == true then return true end
    if ret == false then return false end
    if type(ret) == "number" then return ret == 0 end
    return false
end

-- Cibles de repli par CHUCHOTEMENT : chaque membre du groupe/raid (hors soi), en
-- « Nom-Royaume » (fiable, cross-royaume). Le WHISPER passe même en clé M+ où les
-- canaux de groupe sont bloqués.
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 REPLI WHISPER si le canal est bloqué (clé M+).
local function SendGroupOrWhisper(msg, channel)
    if RawSend(msg, channel) then return end
    for _, target in ipairs(GroupWhisperTargets()) do
        RawSend(msg, "WHISPER", target)
    end
end

local GROUP_CHANNELS = { PARTY = true, RAID = true, INSTANCE_CHAT = true }
local function Pump()
    local item = table.remove(queue, 1)
    if not item then
        if ticker then ticker:Cancel(); ticker = nil end
        return
    end
    if GROUP_CHANNELS[item.channel] then
        SendGroupOrWhisper(item.msg, item.channel)   -- repli whisper en clé M+
    else
        RawSend(item.msg, item.channel, item.target) -- GUILD / WHISPER : tel quel
    end
end
local function Enqueue(msg, channel, target)
    if not Sync.available then return end
    queue[#queue + 1] = { msg = msg, channel = channel, target = target }
    if not ticker then
        ticker = C_Timer.NewTicker(0.3, Pump)
    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)
    if not Sync.available or not NS.db 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 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 == "" 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 == "" 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 then return end
    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)
    if not Sync.available then return end
    local channel = GroupChannel()
    if channel then
        SendGroupOrWhisper(msg, channel)   -- repli whisper si le canal est bloqué (clé M+)
    end
end

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

function Sync.BroadcastKickResult(res)
    SendGroupNow("KR\t" .. tostring(res))
end

-- 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)))
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("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 — le VRAI canal de repli en clé M+. On chuchote « PING\tW » à
    -- chaque membre du groupe. Un « [pong] … via WHISPER » prouve que le whisper
    -- passe MÊME en clé chronométrée (là où PARTY/INSTANCE sont bloqués). C'EST le
    -- test de la synchro des clés — la sonde canal ci-dessus, elle, est muette en clé.
    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(("|cff8ab4f8ping diagnostic envoyé|r sur : %s. Chaque |cff22c55e[pong … ✓]|r te dit VIA QUEL CANAL un pote t'a reçu — |cff22c55eWHISPER|r = le repli des clés M+."):format(
                table.concat(tried, ", ")))
        else
            NS.Msg("tu n'es ni en groupe ni en guilde : impossible de pinger.")
        end
        local _, itype = IsInInstance()
        NS.Msg(("|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)
    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()

    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 = {}
-- 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
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))))
    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 = cle M+ qui bloque le canal | 5 = pas en groupe | nil = vieux client)",
             "(0 or true = OK | 11 = timed key blocks the channel | 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
    local wt = GroupWhisperTargets()
    if #wt == 0 then demit(dL("(aucune cible whisper : groupe vide)", "(no 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

-- Sonde du JOURNAL DE COMBAT + des events de cast. NB : l'addon lit SES PROPRES
-- casts (spellcast) et diffuse — il ne depend PAS de lire le journal des autres.
function Sync.Diag.CombatLog()
    dhead(dL("JOURNAL DE COMBAT (sonde 6 s, reste EN COMBAT et lance des sorts)",
             "COMBAT LOG (6 s probe, stay IN COMBAT and cast spells)"))
    if type(CombatLogGetCurrentEventInfo) ~= "function" then
        demit(dL("CombatLogGetCurrentEventInfo absent : API indisponible",
                 "CombatLogGetCurrentEventInfo missing: API unavailable"))
    end
    local probe = CreateFrame("Frame")
    local n, readable, casts = 0, 0, 0
    local okReg = pcall(probe.RegisterEvent, probe, "COMBAT_LOG_EVENT_UNFILTERED")
    demit("register COMBAT_LOG_EVENT_UNFILTERED = " .. tostring(okReg) ..
        dL(" (false/erreur = bloque/interdit)", " (false/error = blocked/forbidden)"))
    pcall(probe.RegisterUnitEvent, probe, "UNIT_SPELLCAST_SUCCEEDED", "player")
    probe:SetScript("OnEvent", function(_, ev)
        if ev == "UNIT_SPELLCAST_SUCCEEDED" then casts = casts + 1; return end
        n = n + 1
        -- lecture prudente : type() ne compare PAS la valeur secrete (pas de taint)
        local ok, _, sub, _, sguid = pcall(CombatLogGetCurrentEventInfo)
        if ok and type(sub) == "string" and type(sguid) == "string" then readable = readable + 1 end
    end)
    demit(dL("sonde lancee... resultat dans 6 s, puis reclique « Recap ».",
             "probe started... result in 6 s, then click « Recap » again."))
    C_Timer.After(6, function()
        pcall(probe.UnregisterAllEvents, probe)
        probe:SetScript("OnEvent", nil)
        demit((dL("journal recu=%d | champs lisibles=%d | secrets/chiffres=%d | UNIT_SPELLCAST_SUCCEEDED(moi)=%d",
                  "log events=%d | readable fields=%d | secret/encrypted=%d | UNIT_SPELLCAST_SUCCEEDED(me)=%d")):format(
            n, readable, n - readable, casts))
        if n == 0 then demit(dL(">>> 0 evenement de journal : hors combat, ou journal bloque ici.",
                                ">>> 0 log event: out of combat, or log blocked here.")) end
        if n > 0 and readable == 0 then
            demit(dL(">>> journal recu mais champs SECRETS/chiffres : lire les AUTRES est impossible, MAIS l'addon n'en depend pas (il lit TES casts via UNIT_SPELLCAST et diffuse).",
                     ">>> log received but fields are SECRET/encrypted: reading OTHERS is impossible, BUT the addon doesn't rely on it (it reads YOUR casts via UNIT_SPELLCAST and broadcasts)."))
        end
        if casts == 0 then demit(dL(">>> 0 UNIT_SPELLCAST_SUCCEEDED : lance des sorts pendant la sonde (c'est CE signal qui alimente TES cooldowns).",
                                    ">>> 0 UNIT_SPELLCAST_SUCCEEDED: cast spells during the probe (this signal feeds YOUR cooldowns).")) end
    end)
end

function Sync.Diag.RunAll()
    demit("==== Asylum diag v" .. tostring(NS.VERSION) ..
        " | locale=" .. tostring(GetLocale and GetLocale()) .. " ====")
    Sync.Diag.Env(); Sync.Diag.SendCodes(); Sync.Diag.Rx(); Sync.Diag.Group()
    Sync.Diag.PingWhisper(); Sync.Diag.CombatLog()
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()
    wipe(diagOut)
    if NS.Diag_SetRecap then NS.Diag_SetRecap("") end
    if NS.Msg then NS.Msg(dL("[diag] journal vide.", "[diag] log cleared.")) end
end

local f = CreateFrame("Frame")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function(_, _, prefix, msg, _, sender)
    if prefix ~= PREFIX or type(msg) ~= "string" or not sender then return end
    -- 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 IsMe(sender) then r.self = r.self + 1 end
    end
    if not NS.db or IsMe(sender) then return end

    if msg:sub(1, 2) == "KR" then
        local res = msg:match("^KR\t(%a+)$")
        if res and NS.Kicks and NS.Kicks.OnPeerKickResult then
            NS.Kicks.OnPeerKickResult(sender, res)
        end
        return
    end
    -- « 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(("|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(("|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
