-- 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 UnitName("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

-- ── Whisper-offline error suppression ─────────────────────────────────────────
local recentAddonWhispers = {}
local lastAddonWhisperAt   = 0    -- GetTime() of our most recent outgoing whisper
local WHISPER_ECHO_WINDOW  = 8    -- a "no player named" error within this many seconds of ANY
                                  -- addon whisper is treated as ours (covers cases where the
                                  -- error text doesn't quote the name in the form we sent it)
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 <= 18 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
    -- 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
    local quoted = FirstQuotedToken(msg)
    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 > 20 then recentAddonWhispers[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

local function Whisper(encoded, target, prio)
    if not encoded or not target then return end
    local nowT = GetTime()
    recentAddonWhispers[target:lower()] = nowT   -- arm the suppress filter (per-name match)
    lastAddonWhisperAt = nowT                    -- and the broad echo-window match
    dbg("TX WHISPER -> " .. target)
    Dispatch:SendCommMessage(PREFIX, encoded, "WHISPER", target, prio or "NORMAL")
end

-- ── Peer discovery (War Board community roster) ───────────────────────────────
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
    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) end
                    end
                end
            end
        end
    end
    return result
end

-- Random sample of up to k online peers (partial Fisher-Yates).
local function SelectPeers(k)
    local peers = Comm.GetOnlinePeers()
    local n = #peers
    if n == 0 then return {} end
    if n <= k then return peers end
    for i = 1, k do
        local j = math.random(i, n)
        peers[i], peers[j] = peers[j], peers[i]
    end
    local out = {}
    for i = 1, k do out[i] = peers[i] end
    return out
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)) 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

-- ── Presence + digest reconcile ────────────────────────────────────────────────
function Comm.Announce()
    if not L then return end
    local encoded = Encode({
        t = "HELLO", v = PROTO, full = MyFull(), faction = MyFaction(),
        hash = L.SyncHash(), count = L.SyncCount(), ver = L.VERSION, appver = WB.VERSION,
        rulesver = WB.RULESVER,
    })
    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)
    for _, target in ipairs(peers) do
        Whisper(encoded, target, "NORMAL")
    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 encoded = Encode({
        t = "HELLO", v = PROTO, full = MyFull(), faction = MyFaction(),
        hash = L.SyncHash(), count = L.SyncCount(), ver = L.VERSION, appver = WB.VERSION,
        rulesver = WB.RULESVER,
        reply = isReply and true or nil,
    })
    if encoded then Whisper(encoded, target, "NORMAL") 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

local function MyProfile()
    local race, raceFile      = UnitRace("player")
    local class, classToken   = UnitClass("player")
    return {
        name       = UnitName("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)) 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)) 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)) 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)) 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)
    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)
function Comm.BroadcastBountyAck(ack, targetName)
    if type(ack) ~= "table" then return end
    SendTrackTo({ t = "BACK", v = PROTO, d = ack }, { targetName }, "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)
    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)
    if WB.WarEffort and WB.WarEffort.RefreshUI then WB.WarEffort.RefreshUI() end
    if bountyEvent and WB.Bounties and WB.Bounties.RebuildFromLedger then WB.Bounties.RebuildFromLedger() 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
local function NotifyUpdate()
    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
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                                                   -- a freshly-seen newer build
    NotifyUpdate()
end
Comm.RemindUpdate = NotifyUpdate

local function HandleHello(data, sender)
    if not L then return end
    CheckRemoteVersion(data.appver)
    local mismatch = (data.hash ~= L.SyncHash()) or ((tonumber(data.count) or -1) ~= L.SyncCount())
    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
-- 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
    local n = #recs
    local i = 1
    while i <= n do
        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
    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 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)) do Whisper(enc, target, "NORMAL") end
        end
    end
end

local function HandleAlert(data)
    local a = data.a
    if type(a) ~= "table" or a.kind ~= "assault" then return end
    if WB.Alerts and WB.Alerts.RecordAssault then
        WB.Alerts.RecordAssault(a, true)   -- fromRemote: refresh/log, never re-broadcast
    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.
    local mine = (d.targetGuid == UnitGUID("player"))
    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
    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)
    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)
    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.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
