-- 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     = 100    -- records per message when answering a dump request
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

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

-- ── 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
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
end)

-- ── Encode / decode (compressed, addon-channel-safe) ───────────────────────────
local function Encode(tbl)
    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 = 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(...)
    if WB.InInstance() then return end
    return RawSendCommMessage(self, ...)
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
    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
    local cov = {}
    if IsInGuild() and GetNumGuildMembers and GetGuildRosterInfo then          -- GUILD spray
        for i = 1, (GetNumGuildMembers() or 0) do
            local ok, full = pcall(GetGuildRosterInfo, i); if ok then MarkName(cov, full) end
        end
    end
    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 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
    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

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 (rank itself maxes out at 5000 WC)
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

-- 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
    local encoded = Encode({ t = "ALRT", v = PROTO, a = payload })
    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

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

-- ── 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)
    local encoded = Encode(tbl)
    if not encoded then return end
    prio = prio or "NORMAL"
    names = names or {}
    local peers = SelectPeers(FANOUT_ORIGIN, "bridge")
    dbg(("TX %s -> direct:[%s] + %d peer(s):[%s] | guild=%s grp=%s chan=%s"):format(
        tostring(tbl.t), table.concat(names, ", "),
        #peers, (#peers > 0 and table.concat(peers, ", ") or "none"),
        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
    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

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 }
    SendTrackTo({ t = "BACK", v = PROTO, d = ack }, names, "NORMAL")
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).
function Comm.BroadcastBountyLocation(d)
    if type(d) ~= "table" or type(d.roster) ~= "table" then return end
    SendTrackTo({ t = "BLOC", v = PROTO, d = d }, RosterNames(d.roster), "NORMAL")
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.
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")
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

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

local function HandleHello(data, sender)
    if not L then return end
    CheckRemoteVersion(data.appver)
    -- 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 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 in compressed batches with ttl = 0
-- (the requester stores them but never relays — it's a targeted catch-up).
local function HandleSReq(sender)
    if not L then return end
    local recs = L.SyncRecords()   -- dump only the recent window, never the whole history (snapshot)
    local n = #recs
    if n == 0 then return end
    -- Compress + send ONE batch per frame. Deflating a full multi-batch dump in a single tight
    -- loop is a CPU spike that freezes the frame; spreading it across ticks keeps each frame
    -- cheap (and is gentler on ChatThrottleLib's BULK queue).
    local i = 1
    local function sendNext()
        local batch = {}
        for j = i, math.min(i + DUMP_BATCH - 1, n) do batch[#batch + 1] = recs[j] end
        local encoded = Encode({ t = "ACT", v = PROTO, ttl = 0, recs = batch })
        if encoded then Whisper(encoded, sender, "BULK") end
        i = i + DUMP_BATCH
        if i <= n then C_Timer.After(0.1, sendNext) end
    end
    sendNext()
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 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)
    local a = data.a
    if type(a) ~= "table" then return 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
        if WB.Cargo and WB.Cargo.HandleRemoteAlert then WB.Cargo.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 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 WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(d.guid) then
        print(("%s |cffff3030%s has been marked a DESERTER|r \226\128\148 quest-abandon escaping; 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 escaping with a quest-abandon teleport.")
    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

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 return end
    seenMsg[message] = nowT
    local data = Decode(message)
    if type(data) ~= "table" then return end
    if type(data.v) ~= "number" or data.v > PROTO then return end   -- ignore newer protocols
    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 == "BNTY"   then HandleBounty(data, distribution)
    elseif t == "ALRT"   then HandleAlert(data)
    elseif t == "DSRT"   then HandleDeserter(data)
    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

-- ── 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
            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)
            C_Timer.NewTicker(300, Comm.Announce)    -- periodic convergence (heals partitions)
            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
