-- WarBoard_Comm.lua
-- Peer-to-peer comms for War Board, built for scale (hundreds of users).
--
-- Transport: AceComm (auto chunk/reassemble + ChatThrottleLib pacing) carrying
-- AceSerializer + LibDeflate compressed payloads. We can't send addon messages on
-- the community channel itself, so peers are discovered from the War Board
-- community roster (C_Club) and reached by addon WHISPER, with GUILD / PARTY /
-- RAID and the short realm-local WBRD channel as fallback/wide sprays.
--
-- Propagation is GOSSIP, not broadcast-to-all: a new record goes to a small random
-- fanout of peers + the free channels; receivers RELAY only the records that were
-- new to them, to their own small fanout, decrementing a TTL. Dedup by record id
-- (in the ledger) makes it idempotent and kills storms — once you've seen a batch,
-- you stop. Steady-state catch-up is the HELLO digest exchange: a peer whose ledger
-- hash differs asks for a dump. Together: gossip = live spread, digest = eventual
-- convergence for anyone offline during the spread.

local addonName, WB = ...
WB.Comm = WB.Comm or {}
local Comm = WB.Comm
local L = WB.Ledger

local AceComm       = LibStub("AceComm-3.0")
local AceSerializer = LibStub("AceSerializer-3.0")
local LibDeflate    = LibStub("LibDeflate")

local PREFIX = "WarBoard"   -- <= 16 chars
local PROTO  = 2            -- bumped: wire format changed from the manual version

-- ── Tunables (safe to tweak as the network grows) ─────────────────────────────
local FANOUT_ORIGIN  = 8      -- whisper targets when WE originate a batch
local FANOUT_RELAY   = 4      -- whisper targets when we relay someone else's batch
local MAX_TTL        = 6      -- max gossip hops (coverage ~ fanout^TTL)
local BATCH_INTERVAL = 1.5    -- seconds to coalesce outgoing records into one batch
local DUMP_BATCH     = 50     -- records per dump batch: one serialize+compress slice per 0.1s step,
                              -- so smaller batches = a smaller per-frame CPU/garbage bite
local SYNC_COOLDOWN  = 60     -- min seconds between dump requests to the same peer

-- ── Debug logging (toggle with /wb commdebug) ─────────────────────────────────
local DEBUG = false
local function dbg(msg) if DEBUG then print("|cff66ccffWBComm|r " .. msg) end end
function Comm.SetDebug(on)
    DEBUG = not not on
    print("|cff66ccffWBComm|r debug logging " .. (DEBUG and "ON" or "OFF"))
end
function Comm.IsDebug() return DEBUG end

-- ── Raw fast-path for tracking pings (bypasses AceComm/CTL) ────────────────────
-- The bounty ack + live position are tiny and must be instant. Raw whispers deliver reliably even
-- in bursts, so we send those two RAW (no CTL queue) for speed, while the normal AceComm spray still
-- goes out at ALERT as a reliable multi-route backstop. Its own prefix so AceComm's parser never
-- sees it; the receiver dedups, so a double delivery is harmless.
local FAST_PREFIX = "WBTrackFast"
if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
    C_ChatInfo.RegisterAddonMessagePrefix(FAST_PREFIX)
end

-- ── Identity / channel helpers ────────────────────────────────────────────────
local function MyShort() return UnitNameUnmodified("player") or "?" end
local function MyFull()
    local short, realm = MyShort(), GetNormalizedRealmName()
    if realm and realm ~= "" then return short .. "-" .. realm end
    return short
end
local function MyFaction() return WB.faction or UnitFactionGroup("player") or "Horde" end
local function IsSelf(sender)
    if not sender or sender == "" then return false end
    return sender == MyShort() or sender == MyFull()
end
-- Loose name match on the leading (pre-realm) token, case-insensitive — used to confirm a
-- message's claimed author matches its actual comm sender across Name / Name-Realm forms.
local function NameLeads(a, b)
    if type(a) ~= "string" or type(b) ~= "string" then return false end
    a = a:match("^([^%-]+)") or a
    b = b:match("^([^%-]+)") or b
    return a:lower() == b:lower()
end
local function GetGroupChannel()
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then return "INSTANCE_CHAT" end
    if IsInRaid()  then return "RAID"  end
    if IsInGroup() then return "PARTY" end
    return nil
end

-- Byte-identical-message suppression: the fanout sprays each message over guild + group + the WBRD
-- channel + whispers, so the SAME encoded payload routinely arrives 2-4 times within a second.
-- Decode (decompress + deserialize) is the per-message cost, so we skip re-processing an exact copy
-- we just handled. Keyed by the raw encoded string; short TTL, swept on the existing 30s ticker.
local seenMsg       = {}
local MSG_DEDUP_TTL = 4

-- ── Comm traffic stats (always-on, a few integer adds per message — read by /wb perf) ──
-- The comm hot path (Decode/Encode/serialize and the dump answering below) lives in LOCAL
-- functions and embedded libraries, which the profiler's WB.* wrapping can never see — that's
-- what a hitch attributed as "ours X ms, top: —" is. These counters make the invisible work
-- reportable: message counts/bytes by type, decode time, and ledger-dump serving stats.
local commStats
local function ResetCommStats()
    commStats = { rxN = 0, rxBytes = 0, rxDup = 0, decodeMs = 0, byType = {},
                  txN = 0, txBytes = 0, dumpServes = 0, dumpBatches = 0, dumpEncodeMs = 0 }
end
ResetCommStats()
function Comm.ResetPerfStats() ResetCommStats() end
function Comm.GetPerfStats() return commStats end
local function NoteRx(t, bytes)
    commStats.rxN, commStats.rxBytes = commStats.rxN + 1, commStats.rxBytes + (bytes or 0)
    local e = commStats.byType[t or "?"]
    if not e then e = { n = 0, b = 0 }; commStats.byType[t or "?"] = e end
    e.n, e.b = e.n + 1, e.b + (bytes or 0)
end

-- Per-sender throttle for answering full-dump requests (see HandleSReq).
local dumpServedAt = {}
local DUMP_SERVE_COOLDOWN = 120

-- ── Whisper-offline error suppression ─────────────────────────────────────────
local recentAddonWhispers = {}
local lastAddonWhisperAt   = 0     -- GetTime() of our most recent ACTUAL whisper send
local recentWhisperErr     = {}    -- [name] = GetTime() of the last offline-error we saw for them
-- The per-name window is armed when we CALL Whisper() AND re-armed when ChatThrottleLib ACTUALLY
-- sends each chunk (AceComm's send callback) — because CTL paces the real send and splits a big
-- payload into many chunks, so an offline-peer error can land long after the enqueue. Re-arming at
-- real-send time keeps the window aligned with when the error actually fires. The precise per-name
-- match only silences names WE whispered (so it can't hide a manual whisper to someone else); the
-- echo window is a fallback; and a BURST of identical errors (a dump to a peer who logged off
-- mid-transfer fails once per chunk) is collapsed so it can never spam, even if one slips the net.
local WHISPER_NAME_TTL     = 300   -- suppress "no player named X" while X was whispered this recently
local WHISPER_ECHO_WINDOW  = 30    -- also suppress any such error within this long of ANY whisper
local WHISPER_BURST_WINDOW = 30    -- collapse a repeating burst of the SAME name's error into one
local WHISPER_FAIL_NEEDLES = { "no player named", "currently playing", "ne joue actuellement" }
local function FirstQuotedToken(msg)
    if not msg then return nil end
    for _, pat in ipairs({ "'([^']+)'",
        "\226\128\152([^\226\128\153]+)\226\128\153",
        "\226\128\156([^\226\128\157]+)\226\128\157" }) do
        local q = msg:match(pat)
        if q then return (q:match("^%s*(.-)%s*$")) or q end
    end
    return nil
end
local function QuotedMatchesRecentWhisper(quoted)
    local now, ql = GetTime(), quoted:lower()
    local qb = ql:match("^([^%-]+)") or ql
    for target, ts in pairs(recentAddonWhispers) do
        if now - ts <= WHISPER_NAME_TTL then
            local tb = target:match("^([^%-]+)") or target
            if target == ql or qb == tb or ql == tb or qb == target then return true end
        end
    end
    return false
end
local function SuppressWhisperOffline(_, _, msg)
    if type(msg) ~= "string" then return false end
    local lower = msg:lower()                       -- case-insensitive needle match
    local hit = false
    for _, needle in ipairs(WHISPER_FAIL_NEEDLES) do
        if lower:find(needle, 1, true) then hit = true; break end
    end
    if not hit then return false end
    local quoted = FirstQuotedToken(msg)
    -- Burst collapse (backstop): a chunked dump to a peer who logged off mid-transfer fails once per
    -- chunk, so the SAME "no player named X" repeats 8-10 times in seconds. Once we've seen one for a
    -- name, swallow the rest within the burst window — so even an error we can't attribute never spams
    -- (at most the first line ever shows). The window slides as repeats arrive.
    if quoted then
        local key, nowB = quoted:lower(), GetTime()
        local seen = recentWhisperErr[key]
        recentWhisperErr[key] = nowB
        if seen and (nowB - seen) <= WHISPER_BURST_WINDOW then return true end
    end
    -- Ours if it arrives right after ANY addon whisper (the addon whispers offline community
    -- peers constantly during sync, and the error doesn't always quote the name the way we sent
    -- it) OR if it names a peer we just whispered (the precise, longer-window check).
    if (GetTime() - lastAddonWhisperAt) <= WHISPER_ECHO_WINDOW then return true end
    return quoted ~= nil and QuotedMatchesRecentWhisper(quoted)
end
local filterInstalled, filterAttempts = false, 0
local function InstallWhisperFilter()
    if filterInstalled then return end
    local ok = false
    if type(ChatFrame_AddMessageEventFilter) == "function" then
        ok = select(1, pcall(ChatFrame_AddMessageEventFilter, "CHAT_MSG_SYSTEM", SuppressWhisperOffline))
    end
    if not ok and type(Chat_AddMessageEventFilter) == "function" then
        ok = select(1, pcall(Chat_AddMessageEventFilter, SuppressWhisperOffline))
    end
    if ok then
        filterInstalled = true
    elseif filterAttempts < 6 then
        filterAttempts = filterAttempts + 1
        C_Timer.After(5, InstallWhisperFilter)
    end
end
-- Per-peer cooldown for the HELLO-triggered kill-tally heal (see HealKillTally below).
local ktalSentAt = {}
local KTAL_HELLO_COOLDOWN = 240

C_Timer.NewTicker(30, function()
    local now = GetTime()
    for k, ts in pairs(recentAddonWhispers) do
        if now - ts > WHISPER_NAME_TTL then recentAddonWhispers[k] = nil end
    end
    for k, ts in pairs(recentWhisperErr) do
        if now - ts > WHISPER_BURST_WINDOW then recentWhisperErr[k] = nil end
    end
    for k, ts in pairs(seenMsg) do
        if now - ts > MSG_DEDUP_TTL then seenMsg[k] = nil end
    end
    for k, ts in pairs(dumpServedAt) do
        if now - ts > DUMP_SERVE_COOLDOWN then dumpServedAt[k] = nil end
    end
    for k, ts in pairs(ktalSentAt) do
        if now - ts > KTAL_HELLO_COOLDOWN then ktalSentAt[k] = nil end
    end
end)

-- ── Encode / decode (compressed, addon-channel-safe) ───────────────────────────
local function Encode(tbl, level)
    local ok, s = pcall(function() return AceSerializer:Serialize(tbl) end)
    if not ok or type(s) ~= "string" then return nil end
    local c = LibDeflate:CompressDeflate(s, { level = level or 7 })
    if not c then return nil end
    return LibDeflate:EncodeForWoWAddonChannel(c)
end
local function Decode(str)
    if type(str) ~= "string" then return nil end
    local c = LibDeflate:DecodeForWoWAddonChannel(str); if not c then return nil end
    local s = LibDeflate:DecompressDeflate(c);           if not s then return nil end
    local ok, tbl = AceSerializer:Deserialize(s)
    if ok and type(tbl) == "table" then return tbl end
    return nil
end

-- ── AceComm dispatcher ─────────────────────────────────────────────────────────
local Dispatch = {}
AceComm:Embed(Dispatch)

-- Hard gate: halt ALL outbound addon traffic while inside any instance. Every send in
-- this file (whispers, the realm channel, guild/group) funnels through
-- Dispatch:SendCommMessage, so wrapping it here is the single chokepoint that silences
-- the addon in instanced content. (Inbound is dropped at the top of OnCommReceived.)
local RawSendCommMessage = Dispatch.SendCommMessage
function Dispatch:SendCommMessage(prefix, text, ...)
    if WB.InInstance() then return end
    commStats.txN = commStats.txN + 1
    if type(text) == "string" then commStats.txBytes = commStats.txBytes + #text end
    return RawSendCommMessage(self, prefix, text, ...)
end

-- AceComm invokes this (target, sent, total) when ChatThrottleLib ACTUALLY transmits each chunk —
-- which is what we want to arm the suppress window on: the offline-peer error fires milliseconds
-- after this real send, however long CTL delayed it past the original enqueue. (Fires per chunk, so
-- a paced multi-chunk dump keeps the window continuously fresh through the whole transfer.)
local function OnWhisperSent(target)
    if not target then return end
    local nowT = GetTime()
    recentAddonWhispers[tostring(target):lower()] = nowT
    lastAddonWhisperAt = nowT
end

local function Whisper(encoded, target, prio)
    if not encoded or not target then return end
    local nowT = GetTime()
    recentAddonWhispers[target:lower()] = nowT   -- arm now (covers an immediate send)...
    lastAddonWhisperAt = nowT
    dbg("TX WHISPER -> " .. target)
    -- ...and re-arm at REAL send time via the callback, since CTL paces the actual transmission and
    -- the error lands right after it — long after this enqueue for a big BULK dump.
    Dispatch:SendCommMessage(PREFIX, encoded, "WHISPER", target, prio or "NORMAL", OnWhisperSent, target)
end

-- ── Peer discovery (War Board community roster) ───────────────────────────────
-- Walking the club roster costs a C_Club.GetMemberInfo call PER MEMBER (plus GUID lookups),
-- and it used to run on every gossip send/relay AND the bounty presence poll — a recurring
-- micro-hitch on busy comm traffic. Presence doesn't change second-to-second, so one scan is
-- shared through a short TTL cache; only a scan older than the TTL walks the roster again.
local peersCache, peersCacheAt = nil, 0
local PEERS_TTL = 10   -- seconds a roster scan stays fresh
-- GUID → current routable "Name-Realm" from the last full roster scan. The bounty tracker acks
-- this (resolved by the target's GUID) instead of a stored name that may be stale / missing its
-- realm — the same reason supply-runner tracking is reliable (it acks a fresh broadcast identity).
local guidName = {}

-- The current routable Name-Realm for an online community member by GUID (nil if not seen online
-- in the last scan). Kept fresh by GetOnlinePeers, which the tracker's presence poll already runs.
function Comm.NameForGuid(guid)
    local nm = guid and guidName[guid]
    return (type(nm) == "string" and nm ~= "") and nm or nil
end

function Comm.GetOnlinePeers()
    local result, seen = {}, {}
    -- C_Club hands back SECRET tables in instanced/restricted content, so #members would
    -- error. We don't sync in instances anyway, so bail before touching the roster.
    if WB.InInstance and WB.InInstance() then return result end
    if peersCache and (GetTime() - peersCacheAt) < PEERS_TTL then return peersCache end
    -- In combat a STALE roster beats a rescan: this walk costs a C_Club call per member (+ GUID
    -- lookups), and it gets triggered mid-fight by things like assault alerts (which fire on kills).
    -- Presence changing a few seconds late is harmless; a roster walk during a teamfight is not.
    if peersCache and UnitAffectingCombat("player") then return peersCache end
    wipe(guidName)   -- rebuilt alongside this fresh scan, so it only ever holds currently-online names
    local myShort, myRealm, myFull = MyShort(), GetNormalizedRealmName() or "", MyFull()
    local function Add(name)
        if not name or name == "" then return end
        if name == myFull or name == myShort or seen[name] then return end
        seen[name] = true
        result[#result + 1] = name
    end
    local clubId = WB.Community and WB.Community.CLUB_ID
    if clubId and C_Club and C_Club.GetClubMembers then
        local ok, members = pcall(C_Club.GetClubMembers, clubId)
        if ok and type(members) == "table" and not (WB.IsSecret and WB.IsSecret(members)) then
            local ids = {}
            for i = 1, #members do ids[i] = members[i] end   -- copy to avoid tainting C_Club tables
            for _, memberID in ipairs(ids) do
                local ok2, info = pcall(C_Club.GetMemberInfo, clubId, memberID)
                if ok2 and info and info.name then
                    local p = info.presence
                    if p == Enum.ClubMemberPresence.Online
                    or p == Enum.ClubMemberPresence.Away
                    or p == Enum.ClubMemberPresence.Busy then
                        -- Flip a bounty target's card Online the moment it shows here.
                        if info.guid and WB.BountyTracker and WB.BountyTracker.NotePresence then
                            WB.BountyTracker.NotePresence(info.guid)
                        end
                        local nm = info.name
                        if not nm:find("-") then
                            local guid = info.guid
                            if guid and guid ~= "" then
                                local okG, _, _, _, _, _, _, realmFromGUID = pcall(GetPlayerInfoByGUID, guid)
                                if okG and realmFromGUID and realmFromGUID ~= "" then
                                    nm = nm .. "-" .. realmFromGUID:gsub("%s+", "")
                                end
                            end
                            if not nm:find("-") and myRealm ~= "" then nm = nm .. "-" .. myRealm end
                        end
                        if #nm >= 2 and #nm <= 50 then
                            Add(nm)
                            if info.guid and info.guid ~= "" then guidName[info.guid] = nm end   -- ack targets by GUID
                        end
                    end
                end
            end
        end
    end
    peersCache, peersCacheAt = result, GetTime()
    return result
end

-- ── Bridge-aware peer selection ────────────────────────────────────────────────
-- Every gossip send ALSO sprays GUILD / PARTY / RAID / the realm-local WBRD channel, which
-- blankets the local cluster — so the WHISPER fanout's real value is BRIDGING to peers those
-- paths DON'T reach (other realms / disconnected clusters). Random selection wastes the small
-- fanout on locally-covered peers, letting updates bounce inside one cluster. So we score peers
-- and, in "bridge" mode, prefer those NOT already covered locally — ideally another realm,
-- recently seen, not just contacted. GetOnlinePeers (the community roster) stays the
-- authoritative peer list; local rosters are used ONLY to mark who's already cheaply reached,
-- never as the complete peer list. Fallback: if too few bridges, fill with any healthy peer.
--
-- TODO(scale): faction-aware islands. The WBRD channel/guild/group are all same-faction, so the
-- ONLY path to the other faction is community whispers. Today that's handled implicitly — opposite-
-- faction peers are never "locally covered", so they already score as preferred bridges — which is
-- plenty for a small community. At larger scale, capture each member's faction (C_Club
-- GetClubMemberInfo().faction, surfaced via GetOnlinePeers) and add an opposite-faction bonus here,
-- so every fanout DETERMINISTICALLY bridges into both factions instead of relying on probabilistic
-- relay. Revisit only if one faction is observed lagging the other on sync.

-- "Recently active" / "proven useful relay" tracking, keyed by lowercased sender name.
local peerSeen, peerRelayed = {}, {}
local function NoteSeen(s)    if type(s) == "string" and s ~= "" then peerSeen[s:lower()]    = GetTime() end end
local function NoteRelayed(s) if type(s) == "string" and s ~= "" then peerRelayed[s:lower()] = GetTime() end end

local function NormRealm(r) return type(r) == "string" and (r:gsub("%s+", "")):lower() or "" end
local function RealmOf(nameRealm)   -- "Name-Realm" → normalized "realm" ("" if none)
    return NormRealm(type(nameRealm) == "string" and (nameRealm:match("%-(.+)$") or "") or "")
end

-- Sets of peers already covered by a cheap local broadcast (guild / group / WBRD channel),
-- rebuilt at most every COVERAGE_TTL seconds. Each name is marked both as "name-realm" and the
-- bare "name" (lowercased) so either form of an incoming peer name matches.
local COVERAGE_TTL = 10
local covCache, covAt = nil, -1000
local function MarkName(set, name)
    if type(name) ~= "string" or name == "" then return end
    name = name:lower(); set[name] = true
    local lead = name:match("^([^%-]+)"); if lead and lead ~= name then set[lead] = true end
end
local function MarkUnit(set, unit)
    local nm, realm = UnitNameUnmodified(unit)
    if not nm then return end
    MarkName(set, (realm and realm ~= "") and (nm .. "-" .. realm) or nm)
end
local function LocalCoverage()
    local now = GetTime()
    if covCache and (now - covAt) < COVERAGE_TTL then return covCache end
    if covCache and UnitAffectingCombat("player") then return covCache end   -- same rule as GetOnlinePeers

    local cov = {}
    -- We deliberately do NOT enumerate the guild roster. The guild spray is one broadcast to the
    -- GUILD channel — every guildmate running WarBoard picks it up with no per-member addressing —
    -- so walking GetGuildRosterInfo for the whole guild every cycle was pure cost (a periodic
    -- O(guild) hitch on big guilds). Addon-using guildmates are already marked "covered" by the WBRD
    -- channel scan below (they auto-join it), and non-addon guildmates aren't peers, so the walk
    -- added no useful coverage. Worst case now: a same-realm guildmate who opted out of the channel
    -- gets one redundant whisper — harmless (the receiver's byte-dedup drops it).
    if IsInRaid() then                                                          -- GROUP spray
        for i = 1, GetNumGroupMembers() do MarkUnit(cov, "raid" .. i) end
    elseif IsInGroup() then
        for i = 1, GetNumSubgroupMembers() do MarkUnit(cov, "party" .. i) end
    end
    local cid = Comm.SyncChannelId and Comm.SyncChannelId()                     -- WBRD channel (best-effort)
    if cid then
        for i = 1, 2000 do
            local ok, nm = pcall(function()
                if C_ChatInfo and C_ChatInfo.GetChannelRosterInfo then return C_ChatInfo.GetChannelRosterInfo(cid, i) end
                if GetChannelRosterInfo then return GetChannelRosterInfo(cid, i) end
            end)
            if not ok or type(nm) ~= "string" or nm == "" then break end
            MarkName(cov, nm)
        end
    end
    covCache, covAt = cov, now
    return cov
end

-- Score one peer. Higher = better bridge target. Returns score, reasonString, covered.
local function ScorePeer(name, cov, myRealm, now)
    local lower = name:lower()
    local lead  = lower:match("^([^%-]+)") or lower
    local covered = (cov[lower] or cov[lead]) and true or false
    local reasons, score = {}, 0
    local function add(p, why) score = score + p; if why then reasons[#reasons + 1] = why end end

    if RealmOf(name) ~= "" and RealmOf(name) ~= myRealm then add(40, "diffRealm") else add(-5, "sameRealm") end
    if covered then add(-50, "localCovered") else add(20, "notLocal") end
    if recentAddonWhispers[lower] then add(-30, "recentContact") end
    if peerSeen[lower]    and (now - peerSeen[lower])    <= 300  then add(15, "recentlySeen") end
    if peerRelayed[lower] and (now - peerRelayed[lower]) <= 1800 then add(20, "provenRelay")  end
    add(math.random(0, 15))   -- jitter so similarly-scored peers rotate instead of repeating
    return score, table.concat(reasons, ","), covered
end

-- SelectPeers(count, mode): up to `count` peer names. mode = "bridge" (prefer un-covered),
-- "local" (prefer covered), or "any"/nil (best by score). Never returns the same peer twice;
-- always falls back to the rest of the pool so the fanout is never starved.
local function SelectPeers(count, mode)
    mode = mode or "any"
    local peers = Comm.GetOnlinePeers()
    if #peers == 0 then return {} end
    local cov, myRealm, now = LocalCoverage(), NormRealm(GetNormalizedRealmName() or ""), GetTime()

    local scored = {}
    for _, name in ipairs(peers) do
        local s, why, covered = ScorePeer(name, cov, myRealm, now)
        scored[#scored + 1] = { name = name, score = s, why = why, covered = covered }
    end
    table.sort(scored, function(a, b) return a.score > b.score end)

    local out, picked = {}, {}
    local function take(list)
        for _, e in ipairs(list) do
            if #out >= count then break end
            if not picked[e.name] then
                picked[e.name] = true
                out[#out + 1] = e.name
                dbg(("peer[%s] +%s score=%d (%s)"):format(mode, e.name, e.score, e.why ~= "" and e.why or "-"))
            end
        end
    end

    if mode == "bridge" or mode == "local" then
        local pref, rest = {}, {}
        local wantCovered = (mode == "local")
        for _, e in ipairs(scored) do
            if (e.covered == wantCovered) then pref[#pref + 1] = e else rest[#rest + 1] = e end
        end
        take(pref)
        if #out < count then take(rest) end   -- fallback: never starve the fanout
    else
        take(scored)
    end
    if Comm.IsDebug and Comm.IsDebug() then
        dbg(("SelectPeers[%s] picked %d/%d of %d online"):format(mode, #out, count, #peers))
    end
    return out
end
Comm.SelectPeers = SelectPeers
function Comm.SelectPeer(mode) return (SelectPeers(1, mode))[1] end

-- Keep the seen/relayed tracking bounded (entries only matter for 300s / 1800s respectively).
if C_Timer then
    C_Timer.NewTicker(120, function()
        local now = GetTime()
        for k, ts in pairs(peerSeen)    do if now - ts > 600  then peerSeen[k]    = nil end end
        for k, ts in pairs(peerRelayed) do if now - ts > 3600 then peerRelayed[k] = nil end end
    end)
end

-- ── Cross-faction sync channel ─────────────────────────────────────────────────
-- Community peers are still reached by addon WHISPER when possible, including
-- cross-faction peers that are whisperable through the shared community. This
-- short custom channel is an extra same-realm mesh/backstop and wide spray, not
-- a replacement for peer whispers.
local SYNC_CHANNEL = "WBRD"
function Comm.SyncChannelName() return SYNC_CHANNEL end
local LEGACY_SYNC_CHANNELS = { "WarBoardSync" }

local function SyncChannelId()
    local id = GetChannelName and GetChannelName(SYNC_CHANNEL)
    return (id and id > 0) and id or nil
end
Comm.SyncChannelId = SyncChannelId

local function JoinSyncChannel(attempt)
    attempt = attempt or 1
    if WarBoardCharDB and WarBoardCharDB.disableSyncChannel then return end   -- user opted out (see /wb channel)
    if SyncChannelId() then dbg("sync channel ready (id " .. SyncChannelId() .. ")"); return end
    for _, legacy in ipairs(LEGACY_SYNC_CHANNELS) do
        local oldId = GetChannelName and GetChannelName(legacy)
        if oldId and oldId > 0 and LeaveChannelByName then
            pcall(LeaveChannelByName, legacy)
        end
    end
    -- JoinTemporaryChannel (NOT JoinChannelByName) joins silently and does NOT dock the channel to
    -- any chat window — it never appears in your chat. It does occupy a channel slot, which is why
    -- we let people turn it off entirely below.
    if JoinTemporaryChannel then pcall(JoinTemporaryChannel, SYNC_CHANNEL) end
    if attempt < 8 then
        C_Timer.After(3, function() JoinSyncChannel(attempt + 1) end)
    end
end
Comm.JoinSyncChannel = JoinSyncChannel

-- Opt in/out of the WBRD realm sync channel. OFF leaves it; sync still works over guild + whispers +
-- bridge peers (it just loses the same-realm cross-faction backstop). Persisted per character.
function Comm.SetSyncChannelEnabled(on)
    WarBoardCharDB = WarBoardCharDB or {}
    if on then
        WarBoardCharDB.disableSyncChannel = nil
        JoinSyncChannel(1)
        if WB.Print then WB.Print("realm sync channel |cff44ff44ON|r — using the WBRD channel (hidden; not shown in chat).") end
    else
        WarBoardCharDB.disableSyncChannel = true
        if LeaveChannelByName then pcall(LeaveChannelByName, SYNC_CHANNEL) end
        if WB.Print then WB.Print("realm sync channel |cffff6060OFF|r — left WBRD. Sync still works via guild / whispers / peers.") end
    end
end
function Comm.SyncChannelEnabled() return not (WarBoardCharDB and WarBoardCharDB.disableSyncChannel) end

local function ChannelSend(encoded, prio)
    local id = SyncChannelId()
    if id then
        dbg("TX CHANNEL " .. SYNC_CHANNEL)
        Dispatch:SendCommMessage(PREFIX, encoded, "CHANNEL", id, prio or "NORMAL")
    end
end

-- ── Gossip send ────────────────────────────────────────────────────────────────
-- Whisper a random fanout + spray the free channels, skipping the channel a relay
-- arrived on (everyone there already has it).
local function GossipBatch(recs, ttl, viaChannel, fanout)
    if not recs or #recs == 0 then return end
    local encoded = Encode({ t = "ACT", v = PROTO, ttl = ttl or MAX_TTL, recs = recs })
    if not encoded then return end
    for _, target in ipairs(SelectPeers(fanout, "bridge")) do
        Whisper(encoded, target, "NORMAL")
    end
    if viaChannel ~= "GUILD" and IsInGuild() then
        Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL")
    end
    local grp = GetGroupChannel()
    if grp and viaChannel ~= grp then
        Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL")
    end
    if viaChannel ~= "CHANNEL" then ChannelSend(encoded, "NORMAL") end   -- realm-local fallback mesh
end

-- ── Origination: coalesce our own new records into one batch, then gossip ──────
local pendingOut, flushScheduled = {}, false
local function FlushOutbound()
    flushScheduled = false
    if #pendingOut == 0 then return end
    local batch = pendingOut
    pendingOut = {}
    GossipBatch(batch, MAX_TTL, nil, FANOUT_ORIGIN)
end
-- Called by the tracker when WE complete an objective (broadcast = true path).
function Comm.BroadcastRecord(rec)
    if type(rec) ~= "table" then return end
    pendingOut[#pendingOut + 1] = rec
    if not flushScheduled then
        flushScheduled = true
        C_Timer.After(BATCH_INTERVAL, FlushOutbound)
    end
end

-- ── Campaign results (finalized, locked, shared) ───────────────────────────────
-- A finalized campaign result gossips like a record: peers keep the STRONGEST per campaign
-- (Campaign.MergeResult decides via accepted-record completeness) and only RELAY when it
-- actually changed what they hold, so a weaker/duplicate result dies on arrival (storm killer).
-- This is what stops a late/stale client from imposing a wrong winner — and lets it adopt the
-- community's complete result instead of recalculating from its own partial ledger.
function Comm.BroadcastCampaignResult(entry)
    if type(entry) ~= "table" or entry.index == nil then return end
    local encoded = Encode({ t = "CRES", v = PROTO, ttl = MAX_TTL, r = entry })
    if not encoded then return end
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do Whisper(encoded, target, "NORMAL") end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
    ChannelSend(encoded, "NORMAL")
end

local function HandleCRES(data, distribution)
    if type(data.r) ~= "table" or not (WB.Campaign and WB.Campaign.MergeResult) then return end
    if not WB.Campaign.MergeResult(data.r) then return end   -- weaker/dup → no relay (storm killer)
    local ttl = tonumber(data.ttl) or 0
    if ttl <= 0 then return end
    local enc = Encode({ t = "CRES", v = PROTO, ttl = ttl - 1, r = data.r })
    if not enc then return end
    for _, target in ipairs(SelectPeers(FANOUT_RELAY, "bridge")) do Whisper(enc, target, "NORMAL") end
    if distribution ~= "GUILD" and IsInGuild() then Dispatch:SendCommMessage(PREFIX, enc, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp and distribution ~= grp then Dispatch:SendCommMessage(PREFIX, enc, grp, nil, "NORMAL") end
    if distribution ~= "CHANNEL" then ChannelSend(enc, "NORMAL") end
end

-- Late-login catch-up: ask peers for finalized results we lack OR only hold more weakly.
function Comm.RequestCampaignResults()
    if not (WB.Campaign and WB.Campaign.FinalizedDigest) then return end
    local encoded = Encode({ t = "CREQ", v = PROTO, have = WB.Campaign.FinalizedDigest() })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
    ChannelSend(encoded, "NORMAL")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do Whisper(encoded, target, "NORMAL") end
end

-- Answer a catch-up request: whisper back the finalized results the requester is missing
-- (newest first, capped), targeted with ttl 0 so they don't re-relay.
local function HandleCReq(data, sender)
    if not sender or not (WB.Campaign and WB.Campaign.History) then return end
    local have = (type(data.have) == "table") and data.have or {}   -- { [index] = recordCount }
    local sent = 0
    for _, e in ipairs(WB.Campaign.History()) do        -- newest first
        -- Send what they lack, or where ours is more complete (more accepted records).
        if e.finalized and e.index ~= nil
           and (have[e.index] == nil or (tonumber(e.recordCount) or 0) > (tonumber(have[e.index]) or 0)) then
            local enc = Encode({ t = "CRES", v = PROTO, ttl = 0, r = e })
            if enc then Whisper(enc, sender, "BULK"); sent = sent + 1 end
            if sent >= 12 then break end
        end
    end
end

-- ── Presence + digest reconcile ────────────────────────────────────────────────
-- HELLOs carry the ledger sync digest. Computing it fresh is a whole-window hash — a frame
-- spike at ledger scale — so every HELLO path gets it via L.SyncDigestAsync, which slices
-- the rebuild across frames (and answers instantly whenever the cached digest is fresh).
function Comm.Announce()
    if not L then return end
    local digest = (L.SyncDigestAsync and L.SyncDigestAsync)
        or function(cb) cb(L.SyncHash(), L.SyncCount()) end   -- older ledger fallback
    digest(function(hash, count)
        local encoded = Encode({
            t = "HELLO", v = PROTO, full = MyFull(), faction = MyFaction(),
            hash = hash, count = count, ver = L.VERSION, appver = WB.VERSION,
            rulesver = WB.RULESVER, campaignEpoch = (WB.Campaign and WB.Campaign.EPOCH) or 1,
        })
        if not encoded then return end
        if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
        local grp = GetGroupChannel()
        if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
        ChannelSend(encoded, "NORMAL")
        local peers = SelectPeers(FANOUT_ORIGIN, "bridge")
        for _, target in ipairs(peers) do
            Whisper(encoded, target, "NORMAL")
        end
    end)
    return #(Comm.GetOnlinePeers())
end

-- Targeted HELLO to a single peer. `isReply` marks it as an answer to another HELLO so
-- the recipient pulls from us but does NOT announce back again (breaks the ping-pong).
local function SendHelloTo(target, isReply)
    if not L or not target then return end
    local digest = (L.SyncDigestAsync and L.SyncDigestAsync)
        or function(cb) cb(L.SyncHash(), L.SyncCount()) end
    digest(function(hash, count)
        local encoded = Encode({
            t = "HELLO", v = PROTO, full = MyFull(), faction = MyFaction(),
            hash = hash, count = count, ver = L.VERSION, appver = WB.VERSION,
            rulesver = WB.RULESVER, campaignEpoch = (WB.Campaign and WB.Campaign.EPOCH) or 1,
            reply = isReply and true or nil,
        })
        if encoded then Whisper(encoded, target, "NORMAL") end
    end)
end

-- ── Roster directory (basic profiles, persisted) ───────────────────────────────
-- Every War Board user broadcasts a small profile on login; peers store it so the
-- bounty "Pick a Player" list (and future features) know who's out there — even
-- offline. Persisted in WarBoardCharDB.directory[guid].
WB.Roster = WB.Roster or {}

local function Directory()
    WarBoardCharDB = WarBoardCharDB or {}
    WarBoardCharDB.directory = WarBoardCharDB.directory or {}
    return WarBoardCharDB.directory
end

-- ── Lifetime War Credit (the rank "service record", a compact synced summary) ──────
-- Rank is lifetime WC. It used to be re-summed from the full raw ledger, but the ledger now
-- only syncs a RECENT window, so a fresh install would lose old rank. So each player carries a
-- compact lifetime-WC floor (`lwc`) on their synced profile: it MAX-merges (monotonic — only
-- ratchets up), is sanity-capped, and is PRESTIGE-ONLY (it never feeds campaign scoring; the
-- leaderboard / war bar stay on validated current-campaign records). A fresh client adopts its
-- real lwc back from peers (see the heal-reply in HandleWHO), so rank survives a wipe.
local WC_CAP = 10000000   -- absolute plausibility ceiling (well above the current 87,500-WC max rank)
local function SaneWC(v)
    v = tonumber(v) or 0
    if v ~= v or v < 0 then return 0 end        -- NaN / negative → 0
    if v > WC_CAP then return WC_CAP end
    return math.floor(v)
end

-- A known player's lifetime WC from their synced profile (0 if unknown). Used for their rank.
function WB.Roster.LifetimeWC(guid)
    local p = guid and Directory()[guid]
    return SaneWC(p and p.lwc or 0)
end

-- My best-known lifetime WC: the live ledger sum (authoritative while I still hold my records)
-- OR the synced floor (which survives a wipe), whichever is higher.
function WB.Roster.MyLifetimeWC()
    local ledgerWC = 0
    if WB.Ledger and WB.Ledger.MyTotals then
        local _, w = WB.Ledger.MyTotals()
        ledgerWC = w or 0
    end
    local guid = UnitGUID("player")
    local floor = (guid and Directory()[guid] and Directory()[guid].lwc) or 0
    return SaneWC(math.max(ledgerWC, floor))
end

-- Grow our lifetime-WC floor by freshly-earned war credit. The floor is the authoritative lifetime
-- total (synced + reinstall-healed), so bumping it on each earn makes new WC show on the rank bar /
-- Service Record immediately — even when the live ledger sum is still BELOW the floor after the
-- historical roll-back, where new WC would otherwise stay hidden until the ledger climbed back past
-- it. No double-count: MyLifetimeWC takes max(ledger, floor), and the earn lands in both.
function WB.Roster.AddLifetimeWC(n)
    n = tonumber(n) or 0
    if n <= 0 then return end
    local guid = UnitGUID("player")
    local cur  = guid and Directory()[guid]
    if not cur then return end   -- floor not established yet; AnnounceProfile seeds it from the ledger
    cur.lwc = SaneWC((tonumber(cur.lwc) or 0) + n)
end

local function MyProfile()
    local race, raceFile      = UnitRace("player")
    local class, classToken   = UnitClass("player")
    return {
        name       = UnitNameUnmodified("player"),
        realm      = GetNormalizedRealmName() or "",
        guid       = UnitGUID("player"),
        faction    = MyFaction(),
        race       = race,
        raceFile   = raceFile,
        sex        = UnitSex("player"),
        class      = class,
        classToken = classToken,
        guild      = GetGuildInfo("player"),
        level      = UnitLevel("player"),
        lwc        = WB.Roster.MyLifetimeWC(),   -- compact lifetime-WC floor (rank persistence)
        t          = time(),
    }
end
WB.Roster.MyProfile = MyProfile

local function StoreProfile(p)
    if type(p) ~= "table" or not p.guid then return end
    local dir = Directory()
    local cur = dir[p.guid]
    -- Lifetime WC ratchets up (monotonic), separate from the newest-wins profile merge — so a
    -- stale-but-newer profile (e.g. a reinstall reporting 0) can never LOWER a known rank.
    local bestLWC = math.max(SaneWC(cur and cur.lwc or 0), SaneWC(p.lwc or 0))
    local replaced = false
    if not cur or (tonumber(p.t) or 0) >= (tonumber(cur.t) or 0) then
        dir[p.guid] = p
        replaced = true
    end
    local rec = dir[p.guid]
    local lwcChanged = (SaneWC(rec.lwc or 0) ~= bestLWC)
    rec.lwc = bestLWC
    if (replaced or lwcChanged) and WB.UI and WB.UI.RefreshBountyPicker then
        WB.UI.RefreshBountyPicker()
    end
    -- My own floor just rose (e.g. healed by a peer after a reinstall) → refresh the rank badge.
    if lwcChanged and rec.guid == UnitGUID("player") and WB.UI and WB.UI.RefreshRank then
        WB.UI.RefreshRank()
    end
end

function WB.Roster.GetAll()
    local out = {}
    for _, p in pairs(Directory()) do out[#out + 1] = p end
    return out
end

function WB.Roster.GetByFaction(faction)
    local out = {}
    for _, p in pairs(Directory()) do
        if p.faction == faction then out[#out + 1] = p end
    end
    table.sort(out, function(a, b) return (a.name or "") < (b.name or "") end)
    return out
end

-- Broadcast our profile (and store our own). Sprayed to peers + free channels.
function Comm.AnnounceProfile()
    StoreProfile(MyProfile())
    local encoded = Encode({ t = "WHO", v = PROTO, p = MyProfile() })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
    ChannelSend(encoded, "NORMAL")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "NORMAL")
    end
end

-- Shard Matcher presence. Reports are deliberately short-lived and lightly gossiped:
-- the matcher only displays a shard after it has received a live report from both factions.
local SHARD_PRESENCE_TTL = 1
local function SendShardPresence(report, ttl, viaChannel, fanout)
    if type(report) ~= "table" then return end
    local encoded = Encode({ t = "SHRD", v = PROTO, ttl = ttl or 0, d = report })
    if not encoded then return end
    if viaChannel ~= "GUILD" and IsInGuild() then
        Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL")
    end
    local grp = GetGroupChannel()
    if grp and viaChannel ~= grp then
        Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL")
    end
    if viaChannel ~= "CHANNEL" then ChannelSend(encoded, "NORMAL") end
    for _, target in ipairs(SelectPeers(fanout or FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "NORMAL")
    end
end

function Comm.BroadcastShardPresence(report)
    SendShardPresence(report, SHARD_PRESENCE_TTL, nil, FANOUT_ORIGIN)
end

-- Hand our ENTIRE known directory to one peer (so they learn everyone we know,
-- including players who are now offline). Batched + BULK so it never floods.
local DIR_BATCH = 40
local function SendDirectoryTo(target)
    if not target then return end
    local all = {}
    for _, p in pairs(Directory()) do all[#all + 1] = p end
    local i = 1
    while i <= #all do
        local batch = {}
        for j = i, math.min(i + DIR_BATCH - 1, #all) do batch[#batch + 1] = all[j] end
        local encoded = Encode({ t = "WHODIR", v = PROTO, ps = batch })
        if encoded then Whisper(encoded, target, "BULK") end
        i = i + DIR_BATCH
    end
end

-- ── Bounties ───────────────────────────────────────────────────────────────────
-- Broadcast a single posted bounty to peers + free channels (cross-faction via the
-- channel, since you bounty the enemy faction).
function Comm.BroadcastBounty(bounty)
    if type(bounty) ~= "table" then return end
    local encoded = Encode({ t = "BNTY", v = PROTO, ttl = MAX_TTL, b = bounty })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
    ChannelSend(encoded, "NORMAL")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "NORMAL")
    end
end

-- ── Alerts ─────────────────────────────────────────────────────────────────────
-- Spray an assault alert one hop wide (no multi-hop relay — these refresh on a timer
-- and dedup by objective+shard, so a single spread to guild/group/channel/peers is
-- enough; the cross-faction channel is what reaches the defending side same-realm).
function Comm.BroadcastAlert(payload)
    if type(payload) ~= "table" then return end
    if WB.ShardMatcher and WB.ShardMatcher.RememberLocalAlert then
        WB.ShardMatcher.RememberLocalAlert(payload)
    end
    local encoded = Encode({ t = "ALRT", v = PROTO, a = payload })
    if not encoded then return end
    local peers = SelectPeers(FANOUT_ORIGIN, "bridge")
    -- Same treatment as the tracking pings: raw-whisper the peers FIRST (instant, no CTL queue) and
    -- send the AceComm copies at ALERT so the "spotted" notice lands promptly instead of trailing
    -- sync traffic. Channel/guild reach still rides AceComm (raw can't broadcast a channel); the
    -- receiver dedups, so the double delivery is harmless.
    if C_ChatInfo and C_ChatInfo.SendAddonMessage and #encoded <= 250 then
        for _, target in ipairs(peers) do pcall(C_ChatInfo.SendAddonMessage, FAST_PREFIX, encoded, "WHISPER", target) end
    end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "ALERT") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "ALERT") end
    ChannelSend(encoded, "ALERT")
    for _, target in ipairs(peers) do Whisper(encoded, target, "ALERT") end
end

-- ── Deserter status ──────────────────────────────────────────────────────────────
-- Announce that the originator (d.guid) has been marked a DESERTER (quest-abandon escape)
-- until d.u. Sprayed like an alert so hunters on any path learn it; cross-faction via the
-- channel. One hop only — there's no relay (the offender re-announces on login).
function Comm.BroadcastDeserter(d)
    if type(d) ~= "table" or not d.guid then return end
    local encoded = Encode({ t = "DSRT", v = PROTO, d = d })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "NORMAL") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "NORMAL") end
    ChannelSend(encoded, "NORMAL")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "NORMAL")
    end
end

-- ── Kill tally (per-player World-PvP kill credit, kept out of the ledger) ────────────
-- One evolving value per player per campaign; receivers keep the HIGHEST (monotonic), so a lost or
-- reordered copy leaves a peer briefly behind but never undercounts. Low urgency → BULK priority, so
-- it rides behind tracking/alerts/records instead of competing with them.
function Comm.BroadcastKillTally(d)
    if type(d) ~= "table" or not d.guid then return end
    local encoded = Encode({ t = "KTAL", v = PROTO, d = d })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "BULK") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "BULK") end
    ChannelSend(encoded, "BULK")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "BULK")
    end
end

-- Bulk kill-tally gossip (KTLB): EVERYTHING the sender knows (capped), not just their own
-- entry. Receivers monotonic-merge per entry, so repeats/overlaps from many peers are
-- idempotent. This is the anti-entropy the single-entry KTAL never had — without it, a
-- peer that missed a killer's own (one-hop, online-only) announcements showed them at 0
-- kills forever, even though the same player's quest WC arrived fine through the ledger's
-- digest/dump healing. Old clients ignore the unknown type (no PROTO bump needed).
function Comm.BroadcastKillTallyBulk(list)
    if type(list) ~= "table" or #list == 0 then return end
    local encoded = Encode({ t = "KTLB", v = PROTO, d = list })
    if not encoded then return end
    if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, "BULK") end
    local grp = GetGroupChannel()
    if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "BULK") end
    ChannelSend(encoded, "BULK")
    for _, target in ipairs(SelectPeers(FANOUT_ORIGIN, "bridge")) do
        Whisper(encoded, target, "BULK")
    end
end

-- Targeted kill-tally catch-up for one peer (the HELLO heal path).
function Comm.SendKillTallyBulkTo(target, list)
    if not target or type(list) ~= "table" or #list == 0 then return end
    local encoded = Encode({ t = "KTLB", v = PROTO, d = list })
    if encoded then Whisper(encoded, target, "BULK") end
end

-- ── Group-member kill reports (PKB) ───────────────────────────────────────────────
-- Killing a member of your own group fires no PARTY_KILL for the killer, so the VICTIM tells
-- the group who landed the finishing blow (read from its own Death Recap). Sent only over the
-- group channel — the killer is by definition a groupmate. The named killer credits it via
-- WE.ConfirmGroupKill, but only if they were also engaging us, so a group fight still pays
-- exactly one person. `vname` lets the receiver confirm the report really came from the victim.
function Comm.BroadcastKillReport(killerGUID)
    if type(killerGUID) ~= "string" or killerGUID == "" then return end
    local grp = GetGroupChannel()
    if not grp then return end
    local encoded = Encode({ t = "PKB", v = PROTO, killer = killerGUID, victim = UnitGUID("player"), vname = MyFull() })
    if encoded then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, "ALERT") end
end

-- ── Bounty location tracking ─────────────────────────────────────────────────────
-- Uses the EXACT same spread as the (working) alert/ledger sync: addon WHISPERS to
-- online community members — the real cross-faction transport, since the target and
-- its hunters are opposite factions and the club roster (GetOnlinePeers) gives proper
-- whisperable Name-Realm for both factions — plus GUILD/PARTY and the WBRD channel as
-- same-realm backstops. Messages self-identify by GUID (and the position carries the
-- acknowledger roster), so only the intended client acts on / displays each one.
-- Whisper the KNOWN recipient(s) by name first — these names came from messages they
-- sent us, so they're proven whisperable and don't depend on the (sometimes unloaded)
-- C_Club roster. SelectPeers + guild/party/channel are wide backstops.
local function SendTrackTo(tbl, names, prio, fast, wide)
    if wide == nil then wide = true end   -- callers opt OUT of the wide spray explicitly
    local encoded = Encode(tbl)
    if not encoded then return end
    prio = prio or "NORMAL"
    names = names or {}
    -- Fast path: raw-whisper the direct recipients FIRST (bypassing AceComm/CTL → instant), for the
    -- tiny latency-critical tracking pings only, and only when the payload fits one packet. The
    -- AceComm spray below still goes out as the reliable backstop (peers/guild/channel + the same
    -- direct whispers), and the receiver dedups, so whichever copy lands first wins.
    if fast and C_ChatInfo and C_ChatInfo.SendAddonMessage and #encoded <= 250 then
        for _, name in ipairs(names) do
            if name and name ~= "" then pcall(C_ChatInfo.SendAddonMessage, FAST_PREFIX, encoded, "WHISPER", name) end
        end
    end
    -- wide=false → direct-only: just the named recipients, no peers/guild/group/channel. The
    -- steady 5s position stream uses this (its recipients are known + proven-routable); the wide
    -- spray on EVERY ping was what saturated ChatThrottleLib and made tracking lag by a minute.
    local peers = wide and SelectPeers(FANOUT_ORIGIN, "bridge") or {}
    dbg(("TX %s -> direct:[%s] + %d peer(s):[%s] | wide=%s guild=%s grp=%s chan=%s"):format(
        tostring(tbl.t), table.concat(names, ", "),
        #peers, (#peers > 0 and table.concat(peers, ", ") or "none"),
        tostring(wide), tostring(IsInGuild()), tostring(GetGroupChannel() or "no"), SyncChannelId() and "yes" or "no"))
    for _, name in ipairs(names) do if name and name ~= "" then Whisper(encoded, name, prio) end end
    if wide then
        for _, target in ipairs(peers) do Whisper(encoded, target, prio) end
        if IsInGuild() then Dispatch:SendCommMessage(PREFIX, encoded, "GUILD", nil, prio) end
        local grp = GetGroupChannel()
        if grp then Dispatch:SendCommMessage(PREFIX, encoded, grp, nil, prio) end
        ChannelSend(encoded, prio)
    end
end

local function RosterNames(roster, extra)
    local names = {}
    if extra and extra ~= "" then names[#names + 1] = extra end
    if type(roster) == "table" then
        for _, s in ipairs(roster) do if s.name then names[#names + 1] = s.name end end
    end
    return names
end

-- Hunter → target: "I accepted your bounty, share your location." (whisper the target)
-- Accepts one name or a LIST of candidate addresses for the same player (stored bounty name +
-- roster-resolved name) — a bad whisper address fails silently, so the tracker sends every
-- plausible one; the target dedups acks by hunterGuid.
function Comm.BroadcastBountyAck(ack, targetNames)
    if type(ack) ~= "table" then return end
    local names = (type(targetNames) == "table") and targetNames or { targetNames }
    -- ALERT priority: the ack is tiny and latency-critical (it starts the location stream), so it
    -- must jump ChatThrottleLib's queue ahead of bulk record/sync traffic instead of waiting behind
    -- it at NORMAL. Low volume (only during an active track), so it never starves other traffic.
    SendTrackTo({ t = "BACK", v = PROTO, d = ack }, names, "ALERT", true)
end

-- Hunter → target: "stop sharing with me."
function Comm.BroadcastBountyUnack(d, targetName)
    if type(d) ~= "table" then return end
    SendTrackTo({ t = "BUNK", v = PROTO, d = d }, { targetName }, "NORMAL")
end

-- Target → acknowledgers: current world position (whisper each acknowledger by name).
-- ALERT priority so the live position skips the CTL queue and lands promptly — this is the message
-- that makes tracking feel "instant" instead of trailing behind sync traffic. `wide=false` sends
-- direct-only (the steady 5s cadence); omitted/true adds the wide insurance spray (first contact
-- + every WIDE_EVERY'th tick — see the tracker).
function Comm.BroadcastBountyLocation(d, wide)
    if type(d) ~= "table" or type(d.roster) ~= "table" then return end
    SendTrackTo({ t = "BLOC", v = PROTO, d = d }, RosterNames(d.roster), "ALERT", true, wide ~= false)
end

-- Target → acknowledgers: my "warm bucket" of recently-seen enemies (for the eye popup).
-- BULK priority — bigger and less time-critical than the live position. Direct-only: sightings are
-- consumed ONLY by roster members (everyone else drops them on the inRoster gate), so the wide
-- spray was pure redundant bulk — a big slice of the CTL backlog that lagged live tracking.
function Comm.BroadcastBountySightings(d)
    if type(d) ~= "table" or type(d.roster) ~= "table" then return end
    SendTrackTo({ t = "BSEE", v = PROTO, d = d }, RosterNames(d.roster), "BULK", false, false)
end

-- Anyone → everyone hunting: a bounty was claimed/cleared; drop it.
function Comm.BroadcastBountyClear(targetGuid, roster, targetName)
    if not targetGuid then return end
    SendTrackTo({ t = "BCLR", v = PROTO, d = { targetGuid = targetGuid, roster = roster } },
        RosterNames(roster, targetName), "ALERT")
end

-- ── Post-login ledger settling (the spend gate) ────────────────────────────────
-- The only window where the local books can be WRONG (vs the community) is right after login,
-- before records a lost save dropped — e.g. your own bounty costs from a crashed session —
-- have re-synced from peers. In that window the balance can read HIGH, and spending against it
-- is exactly how gold used to get minted (spend → crash → balance looks restored → spend again).
-- So gold SPENDING (bounty posting, the only sink) is gated until the ledger has demonstrably
-- converged: the first incoming HELLO whose digest MATCHES ours proves nothing is outstanding.
-- Fallback: after SETTLE_TIMEOUT it unlocks regardless — nobody online to compare against means
-- nobody holds records we lack, and a permanently-diverged peer must not lock spending forever.
-- Earning, tracking, alerts, everything else: never gated.
local SETTLE_TIMEOUT = 120
local settleLoginAt  = GetTime()
local ledgerSettled  = false
function Comm.LedgerSettled()
    return ledgerSettled or (GetTime() - settleLoginAt) > SETTLE_TIMEOUT
end
-- Seconds until the timeout unlock (for a friendly "try again in ~Xs" message).
function Comm.LedgerSettleRemaining()
    if Comm.LedgerSettled() then return 0 end
    return math.max(0, math.ceil(SETTLE_TIMEOUT - (GetTime() - settleLoginAt)))
end

-- ── Receive ───────────────────────────────────────────────────────────────────
local syncCooldown = {}

-- Manual-sync helpers (driven by /wb sync). The per-peer SYNC_COOLDOWN throttles automatic
-- digest exchanges so the network can't storm; a USER asking to sync wants it NOW, so we let
-- the slash command clear the cooldown and/or hit one peer directly.
function Comm.ResetSyncCooldown() wipe(syncCooldown) end
-- Ask ONE specific player to dump their ledger to us right now (immediate targeted catch-up).
-- A direct SREQ makes them dump; the HELLO makes the exchange bidirectional (they pull too).
function Comm.RequestSyncFrom(target)
    if type(target) ~= "string" or target == "" then return false end
    syncCooldown[target] = nil
    local req = Encode({ t = "SREQ", v = PROTO })
    if req then Whisper(req, target, "NORMAL") end
    SendHelloTo(target, false)
    return true
end

local function HandleAct(data, distribution, sender)
    if type(data.recs) ~= "table" then return end
    local newRecs, bountyEvent = {}, false
    for _, rec in ipairs(data.recs) do
        if type(rec) == "table" and rec.id and rec.id ~= "" then
            -- Light-validate on the way in: obvious junk is quarantined (not added, not
            -- relayed); plausible records are admitted PROVISIONALLY and count for live
            -- views. Only genuinely-new records relay (storm killer + don't spread junk).
            local result
            if WB.Verifier and WB.Verifier.Admit then
                result = WB.Verifier.Admit(rec, "comm")
            elseif L and L.Add(rec) then
                result = "new"
            end
            if result == "new" then
                newRecs[#newRecs + 1] = rec
                if rec.atype == "bounty_post" or rec.atype == "bounty_claim" or rec.atype == "bounty_clear" then
                    bountyEvent = true             -- new durable bounty event → rebuild the board
                end
            end
        end
    end
    if #newRecs == 0 then return end                                   -- nothing new → no relay (storm killer)
    NoteRelayed(sender)                                                -- this peer just gave us new data → good relay
    -- Coalesced: a multi-batch dump arrives back-to-back, so defer the (heavy) full UI rebuild
    -- to once after the burst settles instead of rebuilding per batch.
    if WB.WarEffort and WB.WarEffort.RefreshUISoon then WB.WarEffort.RefreshUISoon()
    elseif WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
    if bountyEvent and WB.Bounties then
        if WB.Bounties.RebuildFromLedgerSoon then WB.Bounties.RebuildFromLedgerSoon()   -- coalesce sync bursts
        elseif WB.Bounties.RebuildFromLedger then WB.Bounties.RebuildFromLedger() end
    end
    local ttl = tonumber(data.ttl) or 0
    if ttl > 0 then
        GossipBatch(newRecs, ttl - 1, distribution, FANOUT_RELAY)      -- relay only the new ones
    end
end
-- ── Update detection ───────────────────────────────────────────────────────────
-- Peers stamp their addon version on every HELLO. If we see a newer one, we tell the
-- player — once right away, then a gentle reminder on a timer (set up in init).
local newestRemote    = nil   -- highest version seen from peers this session
local notifiedVersion = nil   -- highest version we've actually printed a notice for
local notifyScheduled = false
local function NotifyUpdate()  -- prints the newest known (also the periodic reminder)
    if not newestRemote then return end
    if WB.Print then
        WB.Print(("A newer version |cff44ff44%s|r is available \226\128\148 you're on %s. Please update.")
            :format(tostring(newestRemote), tostring(WB.VERSION)))
    end
    notifiedVersion = newestRemote
end
local function CheckRemoteVersion(remoteVer)
    if not remoteVer or remoteVer == "" or not WB.VERSION or not WB.CompareVersions then return end
    if WB.CompareVersions(remoteVer, WB.VERSION) <= 0 then return end          -- not newer than us
    if newestRemote and WB.CompareVersions(remoteVer, newestRemote) <= 0 then return end
    newestRemote = remoteVer                                                   -- ratchet up the newest seen
    -- Debounce: a burst of escalating versions (e.g. a 1.1.0 peer and a 1.2.0 peer seconds
    -- apart) collapses into ONE notice for the highest, instead of a line per step.
    if notifyScheduled then return end
    notifyScheduled = true
    if C_Timer then
        C_Timer.After(20, function()
            notifyScheduled = false
            if newestRemote and (not notifiedVersion or WB.CompareVersions(newestRemote, notifiedVersion) > 0) then
                NotifyUpdate()
            end
        end)
    else
        notifyScheduled = false
        NotifyUpdate()
    end
end
Comm.RemindUpdate = NotifyUpdate

-- Kill tallies live OUTSIDE the ledger, so the HELLO digest says nothing about them: even
-- two peers whose ledgers agree can disagree on kill counts. A HELLO is exactly the moment
-- a peer (re)appears, so answer it with our kill-tally gossip too — cooldown-guarded
-- (ktalSentAt, declared with the sweeper up top) so the periodic HELLO cycle doesn't turn
-- into a KTLB storm.
local function HealKillTally(sender)
    if not (WB.KillTally and WB.KillTally.SendTalliesTo) then return end
    local now = GetTime()
    if ktalSentAt[sender] and (now - ktalSentAt[sender]) < KTAL_HELLO_COOLDOWN then return end
    ktalSentAt[sender] = now
    WB.KillTally.SendTalliesTo(sender)
end

local function HandleHello(data, sender)
    if not L then return end
    CheckRemoteVersion(data.appver)
    HealKillTally(sender)   -- kill counts heal on the same trigger the ledger reconciles on
    -- The digest compare needs OUR windowed ledger hash — computed sliced across frames
    -- (L.SyncDigestAsync) instead of in one spike, since any ledger add stales the cache
    -- and busy comm traffic otherwise turns each HELLO into a micro-freeze.
    local digest = (L.SyncDigestAsync and L.SyncDigestAsync)
        or function(cb) cb(L.SyncHash(), L.SyncCount()) end
    digest(function(hash, count)
        local mismatch = (data.hash ~= hash) or ((tonumber(data.count) or -1) ~= count)
        if not mismatch then
            ledgerSettled = true   -- a peer's digest agrees with ours → nothing outstanding → spending safe
            return
        end
        local now = GetTime()
        if syncCooldown[sender] and (now - syncCooldown[sender]) <= SYNC_COOLDOWN then return end
        syncCooldown[sender] = now
        -- Pull: ask the sender to dump their ledger to us.
        local req = Encode({ t = "SREQ", v = PROTO })
        if req then Whisper(req, sender, "NORMAL") end
        -- Push: their hash differs, so they may be MISSING records WE hold — e.g. activity we
        -- relayed from a peer who has since logged off (an alt that did work, then logged out
        -- before this peer came online). Announce back so they pull from us too. `reply`
        -- guards against an endless HELLO ping-pong — a reply never triggers another reply.
        if not data.reply then SendHelloTo(sender, true) end
    end)
end
-- Answer a dump request: replay our ledger window in compressed batches with ttl = 0
-- (the requester stores them but never relays — it's a targeted catch-up).
--
-- Serving used to re-serialize + re-compress the WHOLE window PER REQUESTER, inline in local
-- closures the profiler can't name. Zoning into a hub (a capital during a login wave) makes
-- several peers HELLO at once, so two+ simultaneous dumps meant 2× ~30 slices of AceSerializer +
-- LibDeflate level-7 churn — tens of MB of transient string garbage in a couple of seconds (the
-- reported 5→60-70 MB memory balloon) and a run of "ours ~XX ms, top: —" hitches while the GC
-- chased it down. Now:
--   * the encoded batches are built ONCE per ledger state (keyed by the sync digest) and CACHED —
--     every requester gets the same strings, and a repeat serve is just cheap whisper enqueues;
--   * a requester arriving mid-build attaches to it: catch-up on finished batches, then rides
--     along batch-by-batch (one encode pass total, no matter how many ask);
--   * dump batches compress at a lighter level — they ride BULK, where CTL transmission time
--     dwarfs the few-% size difference — and in smaller slices (DUMP_BATCH), so each 0.1s step
--     stays cheap;
--   * a per-sender serve cooldown, so re-requests can't stack transfers to the same peer.
local DUMP_LEVEL     = 5     -- deflate level for dump batches (vs 7 default: ~half the CPU)
local DUMP_CACHE_TTL = 60    -- cache lifetime cap; the digest key already invalidates on change
local dumpCache              -- { key, batches = { encodedString, ... }, at }
local dumpBuild              -- { key, recs, n, i, batches, recipients } while a build is in flight

-- Pace a prebuilt batch list to one peer (enqueue one whisper per 0.1s tick — CTL does the rest).
local function SendBatchList(batches, sender)
    if not batches or #batches == 0 then return end
    local i = 0
    local function sendNext()
        i = i + 1
        local enc = batches[i]
        if not enc then return end
        Whisper(enc, sender, "BULK")
        if batches[i + 1] then C_Timer.After(0.1, sendNext) end
    end
    sendNext()
end

local function DumpBuildStep()
    local b = dumpBuild
    if not b then return end
    local batch = {}
    for j = b.i, math.min(b.i + DUMP_BATCH - 1, b.n) do batch[#batch + 1] = b.recs[j] end
    b.i = b.i + DUMP_BATCH
    local t0 = debugprofilestop()
    local encoded = Encode({ t = "ACT", v = PROTO, ttl = 0, recs = batch }, DUMP_LEVEL)
    commStats.dumpBatches  = commStats.dumpBatches + 1
    commStats.dumpEncodeMs = commStats.dumpEncodeMs + (debugprofilestop() - t0)
    if encoded then
        b.batches[#b.batches + 1] = encoded
        for sender in pairs(b.recipients) do Whisper(encoded, sender, "BULK") end
    end
    if b.i <= b.n then
        C_Timer.After(0.1, DumpBuildStep)
    else
        dumpCache = { key = b.key, batches = b.batches, at = GetTime() }
        dumpBuild = nil
    end
end

-- Attach a requester to the in-flight build: catch them up on what's already encoded, then they
-- receive each remaining batch as it's produced. Even if OUR ledger changed since the build began,
-- finishing one coherent snapshot is correct — sync is eventually consistent, and the next HELLO
-- cycle heals any residue. The invariant that matters: ONE encode pass, however many peers ask.
local function AttachToBuild(sender)
    if dumpBuild.recipients[sender] then return end
    dumpBuild.recipients[sender] = true
    commStats.dumpServes = commStats.dumpServes + 1
    SendBatchList(dumpBuild.batches, sender)
end

local function HandleSReq(sender)
    if not L or not sender then return end
    local nowG = GetTime()
    if dumpServedAt[sender] and (nowG - dumpServedAt[sender]) < DUMP_SERVE_COOLDOWN then return end
    dumpServedAt[sender] = nowG
    if dumpBuild then AttachToBuild(sender); return end
    -- Key the cache by the ledger's sync digest (hash+count): any admitted record changes it, so a
    -- cache hit is byte-for-byte the dump we'd rebuild. The digest is the sliced async one — cheap.
    local digest = (L.SyncDigestAsync and L.SyncDigestAsync)
        or function(cb) cb(L.SyncHash and L.SyncHash() or "?", L.SyncCount and L.SyncCount() or 0) end
    digest(function(hash, count)
        local key = tostring(hash) .. ":" .. tostring(count)
        if dumpCache and dumpCache.key == key and (GetTime() - dumpCache.at) < DUMP_CACHE_TTL then
            commStats.dumpServes = commStats.dumpServes + 1
            SendBatchList(dumpCache.batches, sender)              -- zero re-encode
            return
        end
        if dumpBuild then AttachToBuild(sender); return end       -- a build started while we hashed
        local recs = L.SyncRecords()   -- snapshot of the recent window (never the whole history)
        if #recs == 0 then return end
        commStats.dumpServes = commStats.dumpServes + 1
        dumpBuild = { key = key, recs = recs, n = #recs, i = 1, batches = {}, recipients = { [sender] = true } }
        DumpBuildStep()
    end)
end
local function HandleWHO(data, sender)
    local p = data.p
    if type(p) ~= "table" or not p.guid then return end
    local isNew = not Directory()[p.guid]
    local theirLWC = SaneWC(p.lwc or 0)
    StoreProfile(p)
    -- Rank heal: if we remember a HIGHER lifetime WC for them than they just reported (e.g.
    -- they reinstalled and announced 0), whisper their own stored profile back so their rank
    -- recovers. They'll then report the higher value, so this fires once — no loop.
    local known = Directory()[p.guid]
    if sender and known and SaneWC(known.lwc or 0) > theirLWC then
        local enc = Encode({ t = "WHO", v = PROTO, p = known })
        if enc then Whisper(enc, sender, "NORMAL") end
    end
    -- First time we hear of this peer:
    --   1) reply with our own profile, so they treat US as new and send back THEIR
    --      directory in turn (bidirectional convergence);
    --   2) hand them our whole directory, so they learn everyone we know — including
    --      players who have since logged off.
    -- They already have us by then, so they won't re-reply — no loop.
    if isNew and sender then
        local encoded = Encode({ t = "WHO", v = PROTO, p = MyProfile() })
        if encoded then Whisper(encoded, sender, "NORMAL") end
        SendDirectoryTo(sender)
    end
end

-- A directory hand-off: absorb every profile, but do NOT reply (this is bulk
-- catch-up, not a peer announcement — replying would loop).
local function HandleWHODIR(data)
    if type(data.ps) ~= "table" then return end
    for _, p in ipairs(data.ps) do StoreProfile(p) end
end

local function HandleShardPresence(data, distribution, sender)
    local report = data.d
    if type(report) ~= "table" or not (WB.ShardMatcher and WB.ShardMatcher.RememberReport) then return end
    local ttl = tonumber(data.ttl) or 0
    if ttl > 0 and not NameLeads(sender, report.name) then return end
    if not WB.ShardMatcher.RememberReport(report) then return end
    -- Local broadcasts already covered their guild/group/channel. Only a direct bridge
    -- whisper gets one small whisper-only relay, preventing periodic presence fanout storms.
    if ttl > 0 and distribution == "WHISPER" then
        local encoded = Encode({ t = "SHRD", v = PROTO, ttl = ttl - 1, d = report })
        if encoded then
            for _, target in ipairs(SelectPeers(FANOUT_RELAY, "bridge")) do
                Whisper(encoded, target, "NORMAL")
            end
        end
    end
end

local function HandleBounty(data, distribution)
    local b = data.b
    if type(b) ~= "table" or not b.id then return end
    if not (WB.Bounties and WB.Bounties.Add and WB.Bounties.Add(b)) then return end  -- not new → no relay
    if WB.Alerts and WB.Alerts.AddBountyPosted then WB.Alerts.AddBountyPosted(b) end  -- live ones only
    if WB.Bounties.NotifyPosted then WB.Bounties.NotifyPosted(b) end                  -- hunters: chat + sound
    local ttl = tonumber(data.ttl) or 0
    if ttl > 0 then
        local enc = Encode({ t = "BNTY", v = PROTO, ttl = ttl - 1, b = b })
        if enc then
            if distribution ~= "GUILD" and IsInGuild() then Dispatch:SendCommMessage(PREFIX, enc, "GUILD", nil, "NORMAL") end
            local grp = GetGroupChannel()
            if grp and distribution ~= grp then Dispatch:SendCommMessage(PREFIX, enc, grp, nil, "NORMAL") end
            if distribution ~= "CHANNEL" then ChannelSend(enc, "NORMAL") end
            for _, target in ipairs(SelectPeers(FANOUT_RELAY, "bridge")) do Whisper(enc, target, "NORMAL") end
        end
    end
end

local function HandleAlert(data, sender)
    local a = data.a
    if type(a) ~= "table" then return end
    if WB.ShardMatcher and WB.ShardMatcher.RememberAlert then
        WB.ShardMatcher.RememberAlert(a, sender)
    end
    if a.kind == "assault" then
        if WB.Alerts and WB.Alerts.RecordAssault then
            WB.Alerts.RecordAssault(a, true)   -- fromRemote: refresh/log, never re-broadcast
        end
    elseif a.kind == "supply_runner" then
        -- Pass the exact comm sender: the runner's proven-routable address, captured so a later
        -- tracking ack whispers straight back to it (the fix for flaky supply-runner tracking).
        if WB.Cargo and WB.Cargo.HandleRemoteAlert then WB.Cargo.HandleRemoteAlert(a, sender) end
    elseif a.kind == "race" then
        if WB.Race and WB.Race.HandleRemoteAlert then WB.Race.HandleRemoteAlert(a) end
    end
end

-- A peer reports they (d.guid) were marked a DESERTER. Add it to the Alerts feed and
-- chat-ping hunters who took a bounty on that target.
local dsrtSeen = {}   -- [guid:until] = true, so a re-broadcast escape announcement prints once
local function HandleDeserter(data)
    local d = data.d
    if type(d) ~= "table" or not d.guid then return end
    local nm = (WB.SanitizeDisplay and WB.SanitizeDisplay(d.name, 32)) or d.name or "An enemy"
    if d.escaped then
        -- Public announcement: a fresh port-out escape. Everyone sees it (dedup on the multi-path spray).
        local key = tostring(d.guid) .. ":" .. tostring(d.u)
        if not dsrtSeen[key] then
            dsrtSeen[key] = true
            print(("%s |cffff3030%s used a port-out exploit to escape|r \226\128\148 marked a DESERTER for ~%d hour(s) (stacks per offense)."):format(
                WB.TAG, nm, tonumber(d.hours) or 1))
            if d.awardedTo and d.awardedTo ~= "" then
                local w = (WB.SanitizeDisplay and WB.SanitizeDisplay(d.awardedTo, 32)) or d.awardedTo
                print(("%s The bounty on them was awarded to |cff44ff44%s|r."):format(WB.TAG, w))
            end
        end
    elseif WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(d.guid) then
        -- Status-only (e.g. a login re-announce) — quietly remind hunters who took the bounty.
        print(("%s |cffff3030%s is marked a DESERTER|r \226\128\148 they earn nothing while marked."):format(WB.TAG, nm))
    end
    if WB.Alerts and WB.Alerts.AddSystem then
        WB.Alerts.AddSystem("deserter:" .. tostring(d.guid) .. ":" .. tostring(d.u),
            "Deserter Marked", nm .. " was marked a DESERTER for a port-out escape.")
    end
end

-- A groupmate we killed reports the killing blow. Only the named killer acts on it, and the
-- tracker credits it only if WE were actually engaging the victim. Require the report to come
-- from the victim themselves (sender matches vname) so a third party can't gift a kill.
local function HandlePKB(data, sender)
    if data.killer ~= UnitGUID("player") then return end                    -- not me → ignore
    if type(data.victim) ~= "string" or data.victim == "" then return end
    if data.vname and not NameLeads(sender, data.vname) then return end     -- must come from the victim
    if WB.WarEffort and WB.WarEffort.ConfirmGroupKill then
        WB.WarEffort.ConfirmGroupKill(data.victim)
    end
end

-- Bounty tracking receivers. Everything is broadcast (channel/guild/group), so the
-- handlers gate by GUID/roster and dedup duplicate deliveries — no relay needed.
local locSeenT = {}   -- [targetGuid] = newest position timestamp processed (dedup)
local clrSeen  = {}   -- [targetGuid] = true once a clear has been handled (dedup)

local function HandleBAck(data, sender)
    local d = data.d
    if type(d) ~= "table" then return end
    -- Only the targeted player cares about an acknowledge addressed to them. Match by GUID *or*
    -- name+realm (mirrors B.GetBountyOnMe): a bounty's targetGuid is captured from the club roster
    -- at post time and can differ from the target's CURRENT character GUID — a guid-only check
    -- would make the target silently ignore the ack and never share its position (the "supply
    -- tracks, bounty doesn't" bug). Supply's guid is the runner's own live UnitGUID, so it always
    -- matched; bounties need the name fallback.
    local mine = (d.targetGuid == UnitGUID("player"))
    if not mine and type(d.targetName) == "string" and d.targetName ~= "" then
        local myName  = UnitNameUnmodified("player")
        local myRealm = (GetNormalizedRealmName() or ""):gsub("%s+", "")
        local dRealm  = (type(d.targetRealm) == "string" and d.targetRealm:gsub("%s+", "")) or ""
        if d.targetName == myName and (dRealm == "" or dRealm == myRealm) then mine = true end
    end
    dbg(("RX BACK: targetGuid=%s me=%s -> %s"):format(
        tostring(d.targetGuid and d.targetGuid:sub(-6)), tostring(UnitGUID("player") and UnitGUID("player"):sub(-6)),
        mine and "FOR ME (OnAck)" or "not me, ignoring"))
    if mine and WB.BountyTracker and WB.BountyTracker.OnAck then
        -- Pass `sender` — the exact comm address we received from. Replying to it (rather
        -- than a reconstructed Name-Realm) uses the client's own routing cache, which is
        -- what makes a CROSS-REALM whisper back to the hunter actually deliver.
        WB.BountyTracker.OnAck(d.hunterGuid, d.hunterName, sender)
    end
end

local function HandleBUnk(data)
    local d = data.d
    if type(d) ~= "table" then return end
    if d.targetGuid == UnitGUID("player") and WB.BountyTracker and WB.BountyTracker.OnUnack then
        WB.BountyTracker.OnUnack(d.hunterGuid)
    end
end

local function HandleBLoc(data)
    local d = data.d
    if type(d) ~= "table" or not d.targetGuid then return end
    -- Only acknowledgers (roster members) display it; everyone else ignores it.
    local me, inRoster, rosterN = UnitGUID("player"), false, 0
    if type(d.roster) == "table" then
        rosterN = #d.roster
        for _, s in ipairs(d.roster) do if s.guid == me then inRoster = true; break end end
    end
    local fresh = (tonumber(d.t) or 0) > (locSeenT[d.targetGuid] or 0)
    dbg(("RX BLOC: target=%s roster=%d inRoster=%s fresh=%s"):format(
        tostring(d.targetGuid:sub(-6)), rosterN, tostring(inRoster), tostring(fresh)))
    if not inRoster then return end
    if not fresh then return end   -- dedup (channel + guild can deliver twice)
    locSeenT[d.targetGuid] = tonumber(d.t) or 0
    clrSeen[d.targetGuid] = nil   -- a live tracking cycle re-arms clear handling
    if WB.BountyTracker and WB.BountyTracker.OnLocation then WB.BountyTracker.OnLocation(d) end
end

local function HandleBClr(data)
    local d = data.d
    if type(d) ~= "table" or not d.targetGuid then return end
    if clrSeen[d.targetGuid] then return end          -- already handled this clear
    clrSeen[d.targetGuid] = true
    if WB.BountyTracker and WB.BountyTracker.OnClear then WB.BountyTracker.OnClear(d.targetGuid) end
end

-- A target's shared "warm bucket" of enemies. Only acknowledgers (roster members) keep it.
local function HandleBSee(data)
    local d = data.d
    if type(d) ~= "table" or not d.targetGuid then return end
    local me, inRoster = UnitGUID("player"), false
    if type(d.roster) == "table" then
        for _, s in ipairs(d.roster) do if s.guid == me then inRoster = true; break end end
    end
    if not inRoster then return end
    if WB.Sightings and WB.Sightings.RememberRemote then
        WB.Sightings.RememberRemote(d.targetGuid, d.list or {}, d.t)
    end
end

-- Raw fast-path receiver: BACK/BLOC sent via FAST_PREFIX arrive here (bypassing AceComm). We decode
-- and route through the SAME handlers as the AceComm copy, which also arrives as a backstop. The
-- handlers dedup anyway (HandleBAck by hunterGuid, HandleBLoc by timestamp), but we also mark the
-- payload seen so the AceComm twin (identical bytes) is dropped by OnCommReceived's byte-dedup
-- WITHOUT paying Decode's decompress+deserialize a second time — whichever copy lands first wins.
do
    local f = CreateFrame("Frame")
    f:RegisterEvent("CHAT_MSG_ADDON")
    f:SetScript("OnEvent", function(_, _, prefix, message, _, sender)
        if prefix ~= FAST_PREFIX or IsSelf(sender) then return end
        if WB.InInstance() then return end
        local t0 = debugprofilestop()
        local data = Decode(message)
        commStats.decodeMs = commStats.decodeMs + (debugprofilestop() - t0)
        if type(data) ~= "table" or type(data.v) ~= "number" or data.v > PROTO then return end
        NoteRx(data.t, #message)
        NoteSeen(sender)
        -- Suppress the AceComm backstop copy's redundant decode. BACK/BLOC/ALRT never relay, so the
        -- intended recipient having already handled it here means the twin has nothing left to do.
        seenMsg[message] = GetTime()
        if     data.t == "BACK" then HandleBAck(data, sender)
        elseif data.t == "BLOC" then HandleBLoc(data)
        elseif data.t == "ALRT" then HandleAlert(data, sender)   -- supply-runner / assault "spotted" notices
        end
    end)
end

local function Route(data, distribution, sender)
    local t = data.t
    dbg(("RX %s from %s (%s)"):format(tostring(t), tostring(sender), tostring(distribution)))
    if     t == "ACT"    then HandleAct(data, distribution, sender)
    elseif t == "HELLO"  then HandleHello(data, sender)
    elseif t == "SREQ"   then HandleSReq(sender)
    elseif t == "WHO"    then HandleWHO(data, sender)
    elseif t == "WHODIR" then HandleWHODIR(data)
    elseif t == "SHRD"   then HandleShardPresence(data, distribution, sender)
    elseif t == "BNTY"   then HandleBounty(data, distribution)
    elseif t == "ALRT"   then HandleAlert(data, sender)
    elseif t == "DSRT"   then HandleDeserter(data)
    elseif t == "KTAL"   then if WB.KillTally and WB.KillTally.OnRemote then WB.KillTally.OnRemote(data.d) end
    elseif t == "KTLB"   then if WB.KillTally and WB.KillTally.OnRemoteBulk then WB.KillTally.OnRemoteBulk(data.d) end
    elseif t == "PKB"    then HandlePKB(data, sender)
    elseif t == "BACK"   then HandleBAck(data, sender)
    elseif t == "BUNK"   then HandleBUnk(data)
    elseif t == "BLOC"   then HandleBLoc(data)
    elseif t == "BCLR"   then HandleBClr(data)
    elseif t == "BSEE"   then HandleBSee(data)
    elseif t == "CRES"   then HandleCRES(data, distribution)
    elseif t == "CREQ"   then HandleCReq(data, sender)
    end
end

-- ── Combat lull ─────────────────────────────────────────────────────────────────
-- In combat, only the live WPvP traffic is processed: bounties, alerts, tracking pings, deserter
-- notices, kill reports, kill tallies. The SYNC protocol — ledger record batches, digests, dumps,
-- campaign results, the directory — is deferred: none of it is time-critical (sync is eventually
-- consistent by design), and its costs are the heavy ones (a multi-KB batch pays decompress +
-- deserialize + per-record validation + ledger adds + cache invalidations in one frame — a mid-fight
-- spike). Deferred messages queue and drain GENTLY (one per tick) after combat ends.
local COMBAT_DEFER = {          -- decoded types that wait out combat
    ACT = true, HELLO = true, SREQ = true, WHO = true, WHODIR = true, CRES = true, CREQ = true,
}
local COMBAT_BIG_BYTES  = 1200  -- bigger encoded payloads are always sync dumps/batches: defer them
                                -- BEFORE decoding, so the decompress itself never runs mid-fight
                                -- (every live-critical message is far smaller — fast path caps at 250)
local COMBAT_QUEUE_MAX  = 150   -- bound the queue; dropped sync heals on the next HELLO cycle
local combatQueue = {}

local function InCombatNow() return UnitAffectingCombat("player") end

local function QueueCombat(item)
    if #combatQueue >= COMBAT_QUEUE_MAX then table.remove(combatQueue, 1) end
    combatQueue[#combatQueue + 1] = item
end

local function DrainStep()
    if InCombatNow() then return end            -- re-entered combat: the next regen restarts the drain
    local item = table.remove(combatQueue, 1)
    if not item then return end
    local data = item.data
    if not data and item.raw then                -- raw-queued messages pay their decode now, off-combat
        local t0 = debugprofilestop()
        data = Decode(item.raw)
        commStats.decodeMs = commStats.decodeMs + (debugprofilestop() - t0)
        if type(data) == "table" then NoteRx(data.t, #item.raw) end
    end
    if type(data) == "table" and type(data.v) == "number" and data.v <= PROTO then
        Route(data, item.dist, item.sender)
    end
    if #combatQueue > 0 then C_Timer.After(0.15, DrainStep) end
end

do
    local f = CreateFrame("Frame")
    f:RegisterEvent("PLAYER_REGEN_ENABLED")
    f:SetScript("OnEvent", function()
        if #combatQueue > 0 then
            dbg(("combat ended -> draining %d deferred sync message(s)"):format(#combatQueue))
            C_Timer.After(1.0, DrainStep)       -- let the post-fight dust settle first
        end
    end)
end

function Dispatch:OnCommReceived(prefix, message, distribution, sender)
    if prefix ~= PREFIX or IsSelf(sender) then return end
    if WB.InInstance() then return end   -- stand down in instanced content
    NoteSeen(sender)                     -- recently-active signal for peer scoring (cheap; before dedup)
    -- Drop a byte-identical copy we just processed (multi-path fanout delivers the same payload 2-4×),
    -- so we pay Decode's decompress + deserialize once instead of per copy. Exact-match only, so it
    -- can never swallow a genuinely different message (unique record ids/timestamps + the TTL field
    -- all change the bytes); relayed copies carry a decremented TTL and are handled downstream as today.
    local nowT = GetTime()
    if seenMsg[message] and (nowT - seenMsg[message]) < MSG_DEDUP_TTL then
        commStats.rxDup = commStats.rxDup + 1
        return
    end
    seenMsg[message] = nowT
    -- Combat lull, stage 1: a big payload is by construction sync traffic — park it RAW so even its
    -- decompression waits for the fight to end.
    if InCombatNow() and #message > COMBAT_BIG_BYTES then
        QueueCombat({ raw = message, dist = distribution, sender = sender })
        return
    end
    local t0 = debugprofilestop()
    local data = Decode(message)
    commStats.decodeMs = commStats.decodeMs + (debugprofilestop() - t0)
    if type(data) ~= "table" then return end
    if type(data.v) ~= "number" or data.v > PROTO then return end   -- ignore newer protocols
    NoteRx(data.t, #message)
    -- Combat lull, stage 2: small sync-protocol messages decode cheaply but their HANDLING (ledger
    -- adds, digest rebuilds, dumps) isn't combat business — park the decoded form.
    if InCombatNow() and COMBAT_DEFER[data.t] then
        QueueCombat({ data = data, dist = distribution, sender = sender })
        return
    end
    Route(data, distribution, sender)
end

-- ── Init ──────────────────────────────────────────────────────────────────────
do
    Dispatch:RegisterComm(PREFIX, "OnCommReceived")
    local guildThrottle = false
    local f = CreateFrame("Frame")
    f:RegisterEvent("PLAYER_LOGIN")
    f:RegisterEvent("PLAYER_GUILD_UPDATE")
    f:SetScript("OnEvent", function(_, event)
        if event == "PLAYER_LOGIN" then
            settleLoginAt = GetTime()   -- arm the post-login spend gate window (see LedgerSettled)
            InstallWhisperFilter()
            C_Timer.After(5,  function() JoinSyncChannel(1) end)  -- realm-local fallback channel (chat ready by now)
            C_Timer.After(8,  Comm.Announce)         -- club roster needs a moment after login
            C_Timer.After(8,  Comm.AnnounceProfile)  -- share our directory profile
            C_Timer.After(30, Comm.Announce)         -- catch peers who logged in just after us
            C_Timer.After(30, Comm.AnnounceProfile)
            C_Timer.After(25, Comm.RequestCampaignResults)  -- pull finalized results we're missing (late login)
            -- Periodic convergence (heals partitions) — skipped while fighting: an announce invites
            -- peers to exchange digests/dumps with us, which is exactly the traffic the combat lull
            -- defers. The next tick (or the post-combat drain answering THEIR hellos) heals anything missed.
            C_Timer.NewTicker(300, function() if not UnitAffectingCombat("player") then Comm.Announce() end end)
            C_Timer.NewTicker(60, function() if not SyncChannelId() then JoinSyncChannel(1) end end)  -- re-join if dropped
            C_Timer.NewTicker(1800, function() Comm.RemindUpdate() end)  -- gentle "update available" nudge (no-op until one is seen)
        elseif event == "PLAYER_GUILD_UPDATE" then
            -- Guild just (un)loaded/changed — re-share our profile so guild is current.
            if not guildThrottle then
                guildThrottle = true
                C_Timer.After(5, function() guildThrottle = false; Comm.AnnounceProfile() end)
            end
        end
    end)
end
