--[[
	Cerebro - Communication Module (V1)
	Handles addon messaging, synchronization, and broadcasts
	
	TODO: Retire this module once all guild members are on v0.11.0+.
	All active sync/broadcast logic has moved to CommunicationV2.lua.
	This file is kept only for backward compatibility during the mixed-version
	upgrade window. The 18 DEPRECATED functions here serve as fallback handlers
	for clients still sending V1 messages.
]]--

local AddonName = "Cerebro"
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)

local Communication = Cerebro:NewModule("Communication", "AceEvent-3.0", "AceTimer-3.0")
Cerebro.modules.Communication = Communication

function Communication:OnEnable()
    -- V1 SYNC TIMERS DISABLED (v0.11.0) — V2 heartbeat handles all sync detection
    -- AnnounceSelf: replaced by CommunicationV2:AnnounceSelf() (RPC ANNOUNCE)
    -- MaybeRequestSync: replaced by V2 heartbeat hash-mismatch detection
    -- CheckPendingSyncs: V2 uses full snapshots, no pending delta syncs
    -- BroadcastGossipDigest: replaced by STATE_HEARTBEAT
    
    -- Receipt Cursor Tracking: still uses V1 path (deferred to V2 migration Phase 2)
    self:ScheduleRepeatingTimer("CheckPendingReceiptBatches", 30)
    
    Cerebro:Debug("Communication v1: sync timers disabled (v2 active)")
end


-- Message types
local MSG = {
	SYNC_REQUEST = "SYNC_REQ",
	SYNC_DATA = "SYNC_DATA",
	EPGP_UPDATE = "EPGP_UPD",
	BID_SUBMIT = "BID_SUB",
	BID_LOCK = "BID_LOCK",
	BID_UNLOCK = "BID_UNLOCK",
	ITEM_DROP = "ITEM_DROP",
	ITEM_AWARD = "ITEM_AWARD",
	SESSION_START = "SESS_START",
	SESSION_END = "SESS_END",
	BOSS_KILL = "BOSS_KILL",
	DECAY = "DECAY",
	RESET = "RESET",
	VERSION_CHECK = "VER_CHK",
    ANNOUNCE_SELF = "ANN_SELF", -- New player announcing presence to officers
    SYNC_RECEIPTS = "SYNC_RCPT", -- Client uploading receipts to officer
    BROADCAST_TRANSACTION = "BCAST_TX",
    BROADCAST_AUDIT = "BCAST_LOG",
    DISCOVERY_REQUEST = "DISC_REQ", -- Officer asking pending players to identify
    PLAYER_UPDATE = "PLR_UPD", -- Targeted update (e.g. Status change)
    MPLUS_RUN_INJECT = "MPLUS_RUN_INJECT", -- Dev-only: Inject simulated M+ run into target's history
    RECEIPT_REQUEST = "RCPT_REQ", -- Officer requesting receipts from all clients (fallback)
    MPLUS_RUN_PUSH = "MPLUS_PUSH", -- Raider → Officers: M+ run data for Guild History
    PLAYER_PURGE = "PLR_PURGE", -- Permanent player removal (sync-aware)
    -- Delta Sync Protocol v2 (see docs/DELTA_SYNC_IMPLEMENTATION.md)
    SYNC_ACK = "SYNC_ACK", -- ACK a SYNC_DATA seq (WHISPER only)
    SYNC_RCPT_ACK = "SYNC_RCPT_ACK", -- ACK a receipt batch and cursor
    GOSSIP_DIGEST = "GOSSIP_DIG",    -- Officer-to-officer anti-entropy digest (v0.9.3+)
    -- Sync Audit (diagnostic)
    SYNC_AUDIT_REQ = "SAUD_REQ",     -- Officer requests per-player fingerprints from all guild members
    SYNC_AUDIT_RESP = "SAUD_RESP",   -- Guild member responds with their per-player fingerprints
    -- Award forwarding (Fix 2c: non-leader officer → Sync Leader)
    EPGP_FORWARD = "EPGP_FWD",      -- Non-leader officer whispers award data to sync leader for durability
    SYNC_CHECK = "SYNC_CHK",         -- V1-transport sync check (officer broadcasts, all respond with hashes)
}

-- Minimum version for sync acceptance (Version Enforcement)
-- Sync data from clients below this version will be rejected
local MIN_SYNC_VERSION = "0.7.15"

-- Quarantine System Constants
local QUARANTINE_WINDOW = 15 * 60  -- 15 minutes

--[[=============================================
	Quarantine Helpers
===============================================]]

function Communication:IsQuarantined()
    local q = Cerebro.db.global.resetQuarantine
    if not q then return false end
    return (time() - q.at) < QUARANTINE_WINDOW
end

-- Combat sync gate moved to SyncScheduler:ShouldSync()

-- Delta Sync v2: Retry stale pending syncs
-- DEPRECATED (v0.11.0): Timer disabled, V2 uses full snapshots instead of delta retries
function Communication:CheckPendingSyncs()
    if not Cerebro:IsOfficer() then return end
    
    -- Combat Sync Optimization: Skip during encounters
    if not Cerebro.modules.SyncScheduler:ShouldSync() then
        Cerebro:Debug("CheckPendingSyncs: skipped (" .. (Cerebro.modules.SyncScheduler:GetSyncBlockReason() or "blocked") .. ")")
        return
    end
    
    local s = Cerebro.db.global.syncState
    if not s or not s.pendingSyncs then return end
    
    local now = GetServerTime and GetServerTime() or time()
    local staleThreshold = 120  -- 2 minutes
    local maxRetries = 3
    
    -- Build lookup of online guild members (same pattern as GetKnownOfficers)
    local onlineMembers = {}
    if IsInGuild() then
        for i = 1, GetNumGuildMembers() do
            local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
            if name and online then
                local fullName = Cerebro:GetFullPlayerName(name)
                local shortName = Cerebro:GetShortPlayerName(name)
                onlineMembers[fullName] = true
                onlineMembers[shortName] = true
            end
        end
    end
    
    for seq, pending in pairs(s.pendingSyncs) do
        if pending.sentAt and (now - pending.sentAt) > staleThreshold then
            -- FIX: Skip retry if target is offline (prevents whisper spam)
            if not onlineMembers[pending.target] then
                Cerebro:Debug("Skipping retry for seq " .. seq .. " - target " .. pending.target .. " is offline")
                -- Clear pending entry after offline grace period to avoid infinite accumulation
                if pending.retryCount >= maxRetries then
                    s.pendingSyncs[seq] = nil
                else
                    pending.retryCount = pending.retryCount + 1
                    pending.sentAt = now  -- Reset timer
                end
            elseif pending.retryCount < maxRetries then
                -- Retry: resend delta to target
                pending.retryCount = pending.retryCount + 1
                pending.sentAt = now
                
                local syncData = self:BuildSyncPayload(false)  -- Delta
                syncData.seq = seq  -- Use same seq for ACK matching
                
                local whisperTarget = Cerebro:GetShortPlayerName(pending.target) or pending.target
                self:SendMessage(MSG.SYNC_DATA, syncData, "WHISPER", whisperTarget)
                Cerebro:Debug(string.format("Retry %d for seq %d to %s", 
                    pending.retryCount, seq, whisperTarget))
            else
                -- Max retries exceeded: force full sync ONLY if target is still online
                if not onlineMembers[pending.target] then
                    Cerebro:Debug("Max retries for seq " .. seq .. ", target offline — dropping")
                    s.pendingSyncs[seq] = nil
                else
                    Cerebro:Debug("Max retries for seq " .. seq .. ", forcing full to " .. pending.target)
                    
                    local fullData = self:BuildSyncPayload(true)  -- Full
                    local whisperTarget = Cerebro:GetShortPlayerName(pending.target) or pending.target
                    self:SendMessage(MSG.SYNC_DATA, fullData, "WHISPER", whisperTarget)
                    
                    -- Remove this pending entry
                    s.pendingSyncs[seq] = nil
                end
            end
        end
    end
end

-- Receipt Cursor Tracking: Retry stale pending receipt batches
function Communication:CheckPendingReceiptBatches()
    -- Combat Sync Optimization: Skip during encounters
    if not Cerebro.modules.SyncScheduler:ShouldSync() then
        Cerebro:Debug("CheckPendingReceiptBatches: skipped (" .. (Cerebro.modules.SyncScheduler:GetSyncBlockReason() or "blocked") .. ")")
        return
    end
    
    local state = Cerebro.db.global.receiptSyncState
    if not state or not state.pendingBatches then return end
    
    local now = time()
    local staleThreshold = 120  -- 2 minutes
    local maxRetries = 3
    
    -- Build lookup of online guild members (same pattern as CheckPendingSyncs)
    local onlineMembers = {}
    if IsInGuild() then
        for i = 1, GetNumGuildMembers() do
            local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
            if name and online then
                local fullName = Cerebro:GetFullPlayerName(name)
                local shortName = Cerebro:GetShortPlayerName(name)
                onlineMembers[fullName] = true
                onlineMembers[shortName] = true
            end
        end
    end
    
    for batchId, pending in pairs(state.pendingBatches) do
        if pending.sentAt and (now - pending.sentAt) > staleThreshold then
            -- FIX: Skip retry if target is offline (prevents whisper spam)
            local targetOnline = onlineMembers[pending.target] or onlineMembers[Cerebro:GetShortPlayerName(pending.target)]
            
            if not targetOnline then
                Cerebro:Debug("Skipping receipt batch " .. batchId .. " - target " .. pending.target .. " is offline")
                -- Increment retry count but don't send; clear after max retries
                if pending.retryCount >= maxRetries then
                    Cerebro.db.global.staleCursors = Cerebro.db.global.staleCursors or {}
                    Cerebro.db.global.staleCursors[pending.target] = true
                    state.pendingBatches[batchId] = nil
                else
                    pending.retryCount = pending.retryCount + 1
                    pending.sentAt = now
                end
            elseif pending.retryCount < maxRetries then
                -- Retry: resend stored payload
                pending.retryCount = pending.retryCount + 1
                pending.sentAt = now
                
                if pending.payload and pending.target then
                    -- WHISPER uses short name, but cursor keys use full name
                    local whisperTarget = Cerebro:GetShortPlayerName(pending.target) or pending.target
                    self:SendMessage(MSG.SYNC_RECEIPTS, pending.payload, "WHISPER", whisperTarget)
                    Cerebro:Debug(string.format("Receipt batch %s retry %d to %s",
                        batchId, pending.retryCount, pending.target))
                else
                    Cerebro:Debug("Receipt batch " .. batchId .. " missing payload, skipping retry")
                end
            else
                -- Max retries exceeded: mark cursor stale, remove pending
                Cerebro:Debug("Receipt batch " .. batchId .. " max retries, marking stale for " .. pending.target)
                Cerebro.db.global.staleCursors = Cerebro.db.global.staleCursors or {}
                Cerebro.db.global.staleCursors[pending.target] = true
                state.pendingBatches[batchId] = nil
            end
        end
    end
end

--[[=============================================
	Message Sending
===============================================]]

function Communication:SendMessage(msgType, data, distribution, target)
	distribution = distribution or "RAID"

	-- Smart WHISPER guard: prevent sending to offline players
	-- AceComm chunks large messages into ~250-byte segments, each producing
	-- "No player named X is currently playing" if the target is offline.
	-- Uses multi-source reachability check (raid roster → guild roster → allow).
	if distribution == "WHISPER" and target then
		if not self:IsPlayerReachable(target) then
			Cerebro:Debug("SendMessage blocked: " .. msgType .. " to unreachable " .. target)
			return
		end
	end

	-- Serialize data
	local payload = Cerebro:Serialize(msgType, data)

	-- Send via AceComm
	Cerebro:SendCommMessage("Cerebro", payload, distribution, target, "NORMAL")

	Cerebro:Debug("Sent " .. msgType .. " to " .. distribution)
end

function Communication:BroadcastToRaid(msgType, data)
	if IsInRaid() then
		self:SendMessage(msgType, data, "RAID")
	elseif IsInGroup() then
		self:SendMessage(msgType, data, "PARTY")
	end
end

function Communication:BroadcastToOfficers(msgType, data)
	-- Always use GUILD channel for maximum reliability
	-- Officers filter on receive side via IsOfficer() checks
	if IsInGuild() then
		self:SendMessage(msgType, data, "GUILD")
	end
end

function Communication:BroadcastToGuild(msgType, data)
    if IsInGuild() then
        self:SendMessage(msgType, data, "GUILD")
    end
end

-- Get known officers from player database for targeted syncs
-- Returns array of officer FULL names (Name-Realm) for cursor key consistency
-- Cached for 30s to avoid expensive IsOfficer() calls on every sync
-- FIX: Only returns ONLINE officers to prevent whisper spam to offline players
function Communication:GetKnownOfficers()
    local now = time()
    local cache = self._knownOfficersCache
    
    -- Return cached value if fresh (30s TTL)
    if cache and cache.at and (now - cache.at) < 30 then
        return cache.names
    end
    
    -- Build lookup of online guild members
    local onlineMembers = {}
    if IsInGuild() then
        for i = 1, GetNumGuildMembers() do
            local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
            if name and online then
                -- Normalize to full name for comparison
                local fullName = Cerebro:GetFullPlayerName(name)
                onlineMembers[fullName] = true
            end
        end
    end
    
    -- Rebuild cache with online-only filter
    local officers = {}
    local me = Cerebro:GetPlayerFullName()
    
    for name, _ in pairs(Cerebro.db.global.players or {}) do
        -- Use IsOfficer function (checks guild rank) instead of data.isOfficer field
        -- FIX: Also check if officer is currently online to prevent whisper spam
        if Cerebro:IsOfficer(name) and name ~= me and onlineMembers[name] then
            -- Return full names for cursor key consistency
            table.insert(officers, name)
        end
    end
    
    -- Store in cache
    self._knownOfficersCache = { at = now, names = officers }
    
    return officers
end

-- Smart reachability check: multi-source verification before WHISPER
-- Checks raid roster first (always loaded during raids), falls back to guild roster,
-- defaults to allowing the whisper if neither can confirm (one error > silent data loss).
--
-- PERF FIX (v0.10.36): Builds a cached lookup table (2s TTL) instead of scanning the
-- entire raid+guild roster on every call.  HandleSnapshot → SendRPC → SendV2Message
-- calls this for each WHISPER target; without caching the O(roster×peers) cost triggers
-- WoW's "script ran too long" guard.
function Communication:IsPlayerReachable(playerName)
    local fullName = Cerebro:GetFullPlayerName(playerName)
    local shortName = Cerebro:GetShortPlayerName(playerName)

    -- Build/reuse a cached roster lookup (2s TTL keeps it hot within a single sync burst)
    local now = GetTime()  -- frame-accurate monotonic clock
    if not self._reachableCache or not self._reachableCacheAt or (now - self._reachableCacheAt) >= 2 then
        local cache = {}  -- fullName -> online (bool)
        local shortCache = {}  -- shortName -> online (bool), for cross-realm fallback

        -- Source 1: Raid roster (most reliable during raids, always loaded)
        if IsInRaid() then
            for i = 1, GetNumGroupMembers() do
                local unitName, unitRealm = UnitFullName("raid" .. i)
                if unitName then
                    local memberName
                    if unitRealm and unitRealm ~= "" then
                        memberName = unitName .. "-" .. unitRealm
                    else
                        memberName = unitName
                    end
                    local memberFull = Cerebro:GetFullPlayerName(memberName)
                    local _, _, _, _, _, _, _, online = GetRaidRosterInfo(i)
                    -- Raid roster takes precedence (most reliable source)
                    cache[memberFull] = online or false
                end
            end
        end

        -- Source 2: Guild roster (fills in non-raid members, does NOT overwrite raid entries)
        if IsInGuild() then
            for i = 1, GetNumGuildMembers() do
                local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
                if name then
                    local memberFull = Cerebro:GetFullPlayerName(name)
                    if cache[memberFull] == nil then
                        cache[memberFull] = online or false
                    end
                    -- Also build short-name lookup for cross-realm fallback
                    local memberShort = Cerebro:GetShortPlayerName(name)
                    if memberShort and shortCache[memberShort] == nil then
                        shortCache[memberShort] = online or false
                    end
                end
            end
        end

        self._reachableCache = cache
        self._reachableShortCache = shortCache
        self._reachableCacheAt = now
    end

    -- Lookup in cached tables
    local cached = self._reachableCache[fullName]
    if cached ~= nil then
        return cached
    end

    -- Cross-realm short-name fallback (Source 3)
    if shortName and self._reachableShortCache[shortName] ~= nil then
        Cerebro:Debug("IsPlayerReachable: " .. playerName .. " matched by short name (cached)")
        return self._reachableShortCache[shortName]
    end

    -- Source 4: If we can't verify via any roster, BLOCK the whisper.
    -- Prevents queue starvation from spamming offline cross-realm players.
    -- The old default-allow caused ChatThrottleLib saturation, killing ALL communication.
    Cerebro:Debug("IsPlayerReachable: " .. playerName .. " not found in any roster, blocking")
    return false
end

-- Legacy alias for backward compatibility
function Communication:IsGuildMemberOnline(playerName)
    return self:IsPlayerReachable(playerName)
end

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastPlayerPurge()
function Communication:BroadcastPlayerPurge(playerName)
    local data = {
        player = playerName,
        timestamp = time(),
        initiator = Cerebro:GetPlayerFullName(),
    }
    if IsInRaid() or IsInGroup() then
        self:BroadcastToRaid(MSG.PLAYER_PURGE, data)
    end
    if IsInGuild() then
        self:BroadcastToGuild(MSG.PLAYER_PURGE, data)
    end
end

--[[=============================================
	Message Handling
===============================================]]

function Communication:HandleMessage(msgType, data, sender)
	Cerebro:Debug("Received " .. msgType .. " from " .. sender)

	-- Guild Scope Gating
	-- Allow certain message types even when scope is pending (nil) or mismatched
	-- This allows scope to arrive during bootstrapping
	local ALLOWED_WHILE_PENDING = {
		[MSG.VERSION_CHECK] = true,
		[MSG.SYNC_CHECK] = true,
		[MSG.SYNC_DATA] = true,
		[MSG.SYNC_REQUEST] = true,
		[MSG.ANNOUNCE_SELF] = true,
		[MSG.GOSSIP_DIGEST] = true,
		[MSG.SYNC_AUDIT_REQ] = true,
		[MSG.SYNC_AUDIT_RESP] = true,
		[MSG.EPGP_UPDATE] = true,
		[MSG.SYNC_ACK] = true,
		[MSG.EPGP_FORWARD] = true,
	}
	
	if not ALLOWED_WHILE_PENDING[msgType] then
		local scopeStatus = Cerebro:VerifyGuildScope()
		if scopeStatus == false then
			Cerebro:Debug("Message ignored (scope mismatch): " .. msgType)
			return
		elseif scopeStatus == nil then
			Cerebro:Debug("Message ignored (scope pending): " .. msgType)
			return
		end
	end

	-- RETIRED (v1.0.3): These V1 handlers are superseded by CommunicationV2.
	-- No current client sends these on the V1 prefix. Kept as comments for reference.
	-- if msgType == MSG.SYNC_REQUEST then
	-- 	self:HandleSyncRequest(sender, data)
	-- elseif msgType == MSG.SYNC_DATA then
	-- 	self:HandleSyncData(data, sender)
	-- elseif msgType == MSG.EPGP_UPDATE then
	-- 	self:HandleEPGPUpdate(data, sender)
	-- elseif msgType == MSG.SESSION_START then
	-- 	self:HandleSessionStart(data, sender)
	-- elseif msgType == MSG.SESSION_END then
	-- 	self:HandleSessionEnd(data, sender)
	-- elseif msgType == MSG.DECAY then
	-- 	self:HandleDecay(data, sender)
	-- elseif msgType == MSG.RESET then
	-- 	self:HandleReset(data, sender)
	-- elseif msgType == MSG.GOSSIP_DIGEST then
	-- 	self:HandleGossipDigest(data, sender)

	-- Active V1 handlers (bidding, loot, diagnostics, M+, receipts, etc.)
	if msgType == MSG.BID_SUBMIT then
		self:HandleBidSubmit(data, sender)

	elseif msgType == MSG.BID_LOCK then
		self:HandleBidLock(sender)

	elseif msgType == MSG.BID_UNLOCK then
		self:HandleBidUnlock(sender)

	elseif msgType == MSG.ITEM_DROP then
		self:HandleItemDrop(data, sender)

	elseif msgType == MSG.ITEM_AWARD then
		self:HandleItemAward(data, sender)

	elseif msgType == MSG.BOSS_KILL then
		self:HandleBossKill(data, sender)

	elseif msgType == MSG.VERSION_CHECK then
		self:HandleVersionCheck(data, sender)

	elseif msgType == MSG.SYNC_CHECK then
		self:HandleSyncCheck(data, sender)

    elseif msgType == MSG.ANNOUNCE_SELF then
        self:HandleAnnounceSelf(data, sender)

    elseif msgType == MSG.SYNC_RECEIPTS then
        self:HandleSyncReceipts(data, sender)

    elseif msgType == MSG.BROADCAST_TRANSACTION then
        self:HandleBroadcastTransaction(data, sender)
        
    elseif msgType == MSG.BROADCAST_AUDIT then
        self:HandleBroadcastAuditLog(data, sender)

    elseif msgType == MSG.DISCOVERY_REQUEST then
        self:HandleDiscoveryRequest(sender)

    elseif msgType == MSG.PLAYER_UPDATE then
        self:HandlePlayerUpdate(data, sender)

    elseif msgType == MSG.MPLUS_RUN_INJECT then
        self:HandleMPlusRunInject(data, sender)

    elseif msgType == MSG.RECEIPT_REQUEST then
        self:HandleReceiptRequest(data, sender)

    elseif msgType == MSG.MPLUS_RUN_PUSH then
        self:HandleMPlusRunPush(data, sender)

    -- RETIRED (Build A): PLAYER_PURGE handler moved to CommunicationV2 RPC_DISPATCH["PURGE"]
    -- elseif msgType == MSG.PLAYER_PURGE then
    --     self:HandlePlayerPurge(data, sender)

    -- Delta Sync Protocol v2 handlers (still active for receipt cursors)
    elseif msgType == MSG.SYNC_ACK then
        self:HandleSyncAck(data, sender)

    elseif msgType == MSG.SYNC_RCPT_ACK then
        self:HandleSyncRcptAck(data, sender)

    -- Sync Audit (diagnostic)
    elseif msgType == MSG.SYNC_AUDIT_REQ then
        self:HandleSyncAuditRequest(data, sender)

    elseif msgType == MSG.SYNC_AUDIT_RESP then
        self:HandleSyncAuditResponse(data, sender)

    -- Fix 2c: Non-leader officer award forwarding
    elseif msgType == MSG.EPGP_FORWARD then
        self:HandleEPGPForward(data, sender)

	else
		Cerebro:Debug("Unknown message type: " .. msgType)
	end
end

--[[=============================================
	Sync Handlers
===============================================]]

-- RETIRED (Build D): All sync handled by V2 hash-based pipeline
function Communication:RequestSync()
	return -- V2 handles via STATE_FETCH
end

-- Guarded periodic sync request (SPEC.md §4.2)
-- Guards: combat, guild/group context, recent sync, recent request
function Communication:MaybeRequestSync()
    -- Guard: quarantine
    if self:IsQuarantined() then
        Cerebro:Debug("MaybeRequestSync: skipped (quarantine active)")
        return
    end
    
    -- Guard: combat lockout
    if InCombatLockdown() then 
        Cerebro:Debug("MaybeRequestSync: skipped (in combat)")
        return 
    end
    
    -- Guard: must be in guild or group
    if not IsInGuild() and not IsInGroup() then 
        Cerebro:Debug("MaybeRequestSync: skipped (no guild/group)")
        return 
    end
    
    -- Backoff: skip if recent sync received (< 4 min / 240s)
    local syncStatus = Cerebro.db.global.syncStatus or {}
    local lastSync = syncStatus.lastOfficerSync or 0
    if (time() - lastSync) < 240 then 
        Cerebro:Debug("MaybeRequestSync: skipped (recent sync)")
        return 
    end
    
    -- Backoff: skip if we just requested (< 60s)
    if self.lastSyncRequest and (time() - self.lastSyncRequest) < 60 then 
        Cerebro:Debug("MaybeRequestSync: skipped (recent request)")
        return 
    end
    
    self.lastSyncRequest = time()
    self:RequestSync()
end

function Communication:HandleSyncRequest(sender, data)
	-- Bug Fix: Self-exclusion guard - don't respond to our own SYNC_REQ
	-- (AceComm echoes messages to self in some configurations)
	-- Compare both full and short names to handle format variations
	local myFullName = Cerebro:GetPlayerFullName()
	local myShortName = Cerebro:GetShortPlayerName(myFullName)
	local senderShort = Cerebro:GetShortPlayerName(sender)
	
	if sender == myFullName or senderShort == myShortName then
		Cerebro:Debug("HandleSyncRequest: ignoring self request (sender=" .. sender .. ")")
		return
	end
	
	Cerebro:Debug("Sync requested by " .. sender)
    
    -- Bug Fix: Only sync receipts if requester is an officer
    -- Raiders should NOT trigger receipt sync from other raiders
    if Cerebro:IsOfficer(sender) then
        self:SyncReceipts()
    end

	-- Only officers respond with full State Data
	if not Cerebro:IsOfficer() then return end

	-- Ensure PR is freshly calculated before sending
	-- This prevents sending stale cached PR that doesn't match EP/GP
	Cerebro:RecalculateAllPR()

	-- Delta Sync v2: Check if requester supports delta mode
	local requestCaps = data and data.caps or {}
	local supportsDelta = false
	if type(requestCaps) == "table" then
		for _, cap in ipairs(requestCaps) do
			if cap == "delta_sync_v2" then
				supportsDelta = true
				break
			end
		end
	end
	
	-- Build sync payload (delta if supported, full otherwise)
	local s = Cerebro.db.global.syncState or {}
	local now = GetServerTime and GetServerTime() or time()
	-- If requester flagged needsFullSync (checksum mismatch), always force full
	local requestNeedsFull = data and data.needsFullSync
	local forceFullSync = requestNeedsFull or not supportsDelta or (now - (s.lastFullSyncAt or 0)) >= 600
	
	if requestNeedsFull then
		Cerebro:Debug("HandleSyncRequest: forcing full sync (requester checksum mismatch)")
	end
	
	local syncData = self:BuildSyncPayload(forceFullSync)
	
	-- Delta Sync v2: Track pending WHISPER syncs for ACK
	if syncData.mode == "delta" then
		s.pendingSyncs = s.pendingSyncs or {}
		
		-- Cap pending syncs to avoid unbounded memory growth
		local maxPending = 5
		local pendingCount = Cerebro:TableCount(s.pendingSyncs)
		if pendingCount >= maxPending then
			-- Too many pending, force full sync instead
			syncData = self:BuildSyncPayload(true)  -- Force full
			Cerebro:Debug("Too many pending syncs, forcing full for " .. sender)
		else
			s.pendingSyncs[syncData.seq] = {
				sentAt = now,
				target = senderShort,
				retryCount = 0,
			}
			Cerebro:Debug(string.format("HandleSyncRequest: delta to %s, seq=%d pending=%d",
				senderShort, syncData.seq, pendingCount + 1))
		end
	end

	-- Send to requester only (WHISPER)
	-- FIX: Check if requester is still online before whispering
	if not self:IsGuildMemberOnline(senderShort) then
		Cerebro:Debug("Skipping sync response - requester " .. senderShort .. " is offline")
		-- Clear pending entry since we won't send
		if syncData.mode == "delta" and syncData.seq and s.pendingSyncs then
			s.pendingSyncs[syncData.seq] = nil
		end
		return
	end
	self:SendMessage(MSG.SYNC_DATA, syncData, "WHISPER", senderShort)
end

function Communication:HandleSyncData(data, sender)
	-- Only accept sync from officers
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("Ignoring sync from non-officer: " .. sender)
		return
	end
	
	-- Version enforcement: reject sync from outdated clients
	local senderVersion = data.version
	local versionStr = senderVersion or "(missing - legacy client)"
	
	if not senderVersion or Cerebro:CompareVersions(senderVersion, MIN_SYNC_VERSION) < 0 then
		Cerebro:Debug("Sync rejected: version " .. versionStr .. " < " .. MIN_SYNC_VERSION)
		
		-- Log to audit (will be deduplicated if multiple officers see it)
		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("SYNC_REJECTED_VERSION", 
				string.format("Sender: %s running version %s (minimum: %s)", sender, versionStr, MIN_SYNC_VERSION))
		end
		
		-- Print warning to officer's chat so they know who needs to update
		Cerebro:Print(string.format("|cffff8800[Version Warning]|r %s is running an outdated version (%s). Sync blocked.",
			sender, versionStr))
		
		return
	end
	
	-- Quarantine check FIRST (local intent to stay wiped)
	if self:IsQuarantined() then
		Cerebro:Debug("Sync from " .. sender .. " blocked (quarantine active)")
		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("SYNC_BLOCKED_QUARANTINE", "From: " .. sender)
		end
		return
	end
	
	-- Epoch check (reject stale data OR accept higher epoch from officer)
	local payloadEpoch = data.epoch or 0
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	local senderIsOfficer = Cerebro:IsOfficerSafe(sender)
	
	if payloadEpoch < localEpoch then
		-- Sender has older epoch - reject
		Cerebro:Debug("Sync rejected: epoch " .. payloadEpoch .. " < " .. localEpoch)
		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("SYNC_REJECTED_STALE", 
				string.format("From %s, epoch %d < %d", sender, payloadEpoch, localEpoch))
		end
		return
	elseif payloadEpoch > localEpoch then
		-- Sender has higher epoch (factory reset happened while we were offline)
		-- v1.0+: Only accept epoch upgrade from officers
		if senderIsOfficer then
			-- Create auto-backup before epoch upgrade overwrites data
			-- This protects offline officers who missed the reset broadcast
			Cerebro.db.global.backup = {
				timestamp = time(),
				version = Cerebro.version,
				players = Cerebro:CopyTable(Cerebro.db.global.players),
				transactions = Cerebro:CopyTable(Cerebro.db.global.transactions),
				audit = Cerebro:CopyTable(Cerebro.db.global.audit),
				epgpConfig = Cerebro:CopyTable(Cerebro.db.global.config and Cerebro.db.global.config.epgp),
				resetEpoch = localEpoch,
			}
			
			if Cerebro.Ledger then
				Cerebro.Ledger:AddAuditLog("BACKUP_CREATED", 
					"Auto-backup before epoch upgrade from " .. sender .. 
					" (epoch " .. localEpoch .. " -> " .. payloadEpoch .. ")")
			end
			
			Cerebro:Print("|cff00ff00Auto-backup created|r (factory reset occurred while offline)")
			
			Cerebro:Debug("Epoch upgraded by officer: " .. localEpoch .. " -> " .. payloadEpoch)
			Cerebro.db.global.resetEpoch = payloadEpoch
			-- Clear any epoch quarantine since we found an authoritative officer
			Cerebro.db.global.epochQuarantine = nil
			
			-- CRITICAL FIX (v0.8.18): Wipe local player data during epoch upgrade
			-- Without this, old epoch records survive and win LWW merge due to newer timestamps
			-- The incoming sync payload will repopulate with correct current-epoch data
			Cerebro.db.global.players = {}
			Cerebro.db.global.transactions = {}
			Cerebro.db.global.history = {}
			Cerebro.db.global.seasonBossKills = 0
			Cerebro.db.global.totalBossKills = 0
			Cerebro.db.global.currentSession = nil
			Cerebro:Debug("Wiped local data for epoch upgrade: " .. localEpoch .. " -> " .. payloadEpoch)
			
			-- Receipt Cursor Tracking: Reset cursor state on epoch upgrade
			Cerebro:ResetReceiptCursorState("epoch upgrade")
		else
			-- Non-officer has higher epoch - enter epoch quarantine
			-- This prevents raiders from forcing epoch changes on offline officers
			Cerebro:Debug("Epoch mismatch from non-officer " .. sender .. ": " .. payloadEpoch .. " > " .. localEpoch)
			Cerebro.db.global.epochQuarantine = {
				at = time(),
				expectedEpoch = payloadEpoch,
				triggeredBy = sender
			}
			-- Friendly message to user
			Cerebro:Print("|cffff8800[Sync Halted]|r Another officer initiated a data reset.")
			Cerebro:Print("Waiting for an officer to sync. Contact guild leadership if this persists.")
			return
		end
	end

	-- Mark that we received officer sync (for receipt fallback logic)
	self.receivedOfficerSync = true
	
	Cerebro:Debug("Syncing data from " .. sender)

	-- Process purge ledger FIRST (before player merge)
	-- This ensures purged players are deleted before incoming data can resurrect them
	if data.purgedPlayers then
		Cerebro.db.global.purgedPlayers = Cerebro.db.global.purgedPlayers or {}
		for name, purgeTime in pairs(data.purgedPlayers) do
			local localTime = Cerebro.db.global.purgedPlayers[name] or 0
			if purgeTime > localTime then
				Cerebro.db.global.purgedPlayers[name] = purgeTime
			end
			-- Enforce: delete if still in local players
			if Cerebro.db.global.players[name] then
				Cerebro.db.global.players[name] = nil
				Cerebro:Debug("Sync-purged: " .. name)
			end
		end
	end

	-- v1.0+ Sync Architecture: LWW (Last-Write-Wins) merge for players
	-- Delta Sync v2: Per-field merge when mode="delta" uses fieldRev
	-- Legacy/Full mode: Uses lastUpdate timestamp (unchanged behavior)
	local syncMode = data.mode or "full"  -- Legacy clients send no mode
	
	if data.players then
		for rawName, pData in pairs(data.players) do
			-- Normalize realm name (strip spaces to match API format)
			local name = Cerebro:GetFullPlayerName(rawName) or rawName
			-- Skip player if in purge or block ledger (prevents resurrection)
			if Cerebro.db.global.purgedPlayers and Cerebro.db.global.purgedPlayers[name] then
				Cerebro:Debug("Skipping purged player during V1 sync: " .. name)
			elseif Cerebro.db.global.blockedPlayers and Cerebro.db.global.blockedPlayers[name] then
				Cerebro:Debug("Skipping blocked player during V1 sync: " .. name)
			-- Skip synthetic test players (prevents test data pollution)
			elseif Cerebro:IsSyntheticTestPlayer(name) then
				Cerebro:Debug("Skipping synthetic test player during sync: " .. name)
			else
				local localPlayer = Cerebro.db.global.players[name]
			
				if not localPlayer then
					-- New player from officer: accept entirely
					Cerebro.db.global.players[name] = pData
					Cerebro:Debug(string.format("Sync: added new player %s (status=%s)", name, pData.status or "nil"))
				elseif syncMode == "delta" and pData.fieldRev then
					-- Delta Sync v2: Per-field merge based on fieldRev
					local localFieldRev = localPlayer.fieldRev or {}
					local incomingFieldRev = pData.fieldRev or {}
					local fieldsUpdated = 0
					
					-- Update rev first
					if (pData.rev or 0) > (localPlayer.rev or 0) then
						localPlayer.rev = pData.rev
					end
					
					-- Merge fieldRev entries
					localPlayer.fieldRev = localPlayer.fieldRev or {}
					
					-- List of syncable fields
					local syncFields = {"ep", "gp", "pr", "status", "class", "lastSeen", "attendance",
						"bossKillsPresent", "coreAdmittedAt", "createdEpoch", "role", "lastUpdate",
						"lifetimeEarned", "lifetimeSpent", "eligibleBossKills"}
					
					for _, field in ipairs(syncFields) do
						local incomingRev = incomingFieldRev[field] or 0
						local localRev = localFieldRev[field] or 0
						
						if incomingRev > localRev and pData[field] ~= nil then
							-- PROTECT: never downgrade Core→Pending via sync
							if field == "status" and localPlayer[field] == "Core" and pData[field] == "Pending" then
								Cerebro:Debug("V1 delta: blocked Core→Pending downgrade for " .. name)
							else
								localPlayer[field] = pData[field]
								localPlayer.fieldRev[field] = incomingRev
								fieldsUpdated = fieldsUpdated + 1
							end
						end
					end
					
					if fieldsUpdated > 0 then
						Cerebro:Debug(string.format("Sync delta: %s updated %d fields", name, fieldsUpdated))
					end
				else
					-- Legacy/Full mode: LWW based on lastUpdate
					local incomingUpdate = pData.lastUpdate or 0
					local localUpdate = localPlayer.lastUpdate or 0
					
					if incomingUpdate >= localUpdate then
						-- Incoming data is newer (or equal, officer wins ties)
						local oldStatus = localPlayer.status
						for k, v in pairs(pData) do
							-- PROTECT: never downgrade Core→Pending via sync
							if k == "status" and localPlayer.status == "Core" and v == "Pending" then
								Cerebro:Debug("V1 full: blocked Core→Pending downgrade for " .. name)
							else
								Cerebro.db.global.players[name][k] = v
							end
						end
						local newStatus = Cerebro.db.global.players[name].status
						if oldStatus ~= newStatus then
							Cerebro:Debug(string.format("Sync: status changed for %s: %s -> %s", name, oldStatus or "nil", newStatus or "nil"))
						end
					else
						-- Local data is newer, keep it
						Cerebro:Debug(string.format("Sync: keeping local %s (local=%d > incoming=%d)", name, localUpdate, incomingUpdate))
					end
				end
			end
		end
	end

	-- Post-merge: Recalculate cached PR for all players
	-- This ensures the cached data.pr matches the actual EP/GP after sync
	-- Without this, Standings (which reads cached pr) can show stale values
	-- while My Progress (which computes live) shows different values
	Cerebro:RecalculateAllPR()
	Cerebro:Debug("Sync: Recalculated PR for all players")

	-- DISABLED (v0.10.24): Session state is now exclusively managed by CommunicationV2.
	-- V1 session merge was running in parallel with V2 and lacked officer/non-officer guards,
	-- causing random session injection and nil crashes.
	-- if data.currentSession then
	-- 	...
	-- end

	-- Sync totalBossKills (attendance denominator for all clients)
	if data.totalBossKills then
		Cerebro.db.global.totalBossKills = data.totalBossKills
	end

	if data.config then
		-- Preserve LOCAL settings that should NOT be synced from officers:
		-- - config.ui: window positions, minimap visibility (per-user preference)
		-- - config.debug: debug mode toggle (per-user preference)
		local localUI = Cerebro.db.global.config.ui
		local localDebug = Cerebro.db.global.config.debug
		
		Cerebro.db.global.config = data.config
		
		-- Restore local-only settings
		Cerebro.db.global.config.ui = localUI
		Cerebro.db.global.config.debug = localDebug
	end
	
	-- Guild M+ History sync (officer-to-officer)
	-- Merge incoming runs with local, dedupe by runKey
	if data.guildMplusRuns and Cerebro:IsOfficer() then
		if not Cerebro.db.global.guildMplusRuns then
			Cerebro.db.global.guildMplusRuns = {}
		end
		
		local localRuns = Cerebro.db.global.guildMplusRuns
		local localKeys = {}
		for _, r in ipairs(localRuns) do
			if r.runKey then localKeys[r.runKey] = true end
		end
		
		-- Add new runs from officer sync
		for _, r in ipairs(data.guildMplusRuns) do
			if r.runKey and not localKeys[r.runKey] then
				table.insert(localRuns, r)
				localKeys[r.runKey] = true
			end
		end
		
		-- FIFO: Cap at 500
		while #localRuns > 500 do
			table.remove(localRuns, 1)
		end
		
		Cerebro:Debug("Merged guildMplusRuns: now " .. #localRuns .. " runs")
	end
    
	-- v1.0+: Transactions and audit logs are NO longer synced
	-- (They remain local-only for personal history)
	-- Officers receive raider transactions via hybrid receipts

	Cerebro:Debug("Data synchronized with raid leader.")
	
	-- Record sync timestamp for /cb status (FIX #9: only after officer validation + successful merge)
	-- NOTE: This MUST NOT generate audit logs (passive timestamp, not officer action)
	if not Cerebro.db.global.syncStatus then
		Cerebro.db.global.syncStatus = {}
	end
	Cerebro.db.global.syncStatus.lastOfficerSync = time()
	
	-- Guild Scope: Accept scope from officer sync (for non-officer bootstrapping)
	-- SECURITY: Only accept if sender is officer AND scope matches our guild
	if data.scope and not Cerebro.db.global.scope then
		-- Validate sender is officer
		if not Cerebro:IsOfficerSafe(sender) then
			Cerebro:Debug("Ignoring scope from non-officer: " .. sender)
		else
			-- Validate scope matches our current guild (if we're in one)
			local myGuildName = GetGuildInfo("player")
			local senderRealm = Cerebro:ExtractRealmFromName(sender)
			
			-- If we're in a guild, scope MUST match our guild name
			local accepted = false
			if myGuildName then
				if data.scope.guildName ~= myGuildName then
					Cerebro:Debug(string.format(
						"Ignoring scope (guild mismatch): sender claims '%s', we're in '%s'",
						data.scope.guildName or "nil", myGuildName
					))
				else
					accepted = true
				end
			else
				-- Not in a guild, less strict (might be joining soon)
				accepted = true
			end
			
			-- Realm check (best effort, if both available)
			if accepted and senderRealm and data.scope.realm then
				local normalizedScopeRealm = Cerebro:NormalizeRealmString(data.scope.realm)
				if senderRealm ~= normalizedScopeRealm then
					Cerebro:Debug("Ignoring scope (realm mismatch)")
					accepted = false
				end
			end
			
			if accepted then
				Cerebro.db.global.scope = data.scope
				Cerebro:Debug("Received guild scope from officer sync: " .. (data.scope.guildName or "?"))
				
				-- Receipt Cursor Tracking: Reset cursor state on scope change
				Cerebro:ResetReceiptCursorState("scope change")
			end
		end
	end
	
	-- Post-sync self-correction: ensure local officer is not marked Pending
	local me = Cerebro:GetPlayerFullName()
	local myData = Cerebro.db.global.players[me]
	if myData and myData.status == "Pending" and Cerebro:IsOfficer() then
		myData.status = "Core"
		Cerebro:Debug("Post-sync self-correction: status set to Core")
	end
	
	-- Refresh UI
	Cerebro:RefreshAllUI()
	
	-- Delta Sync v2: Send SYNC_ACK back to sender if we received a delta with seq
	-- This ACKs receipt so the sender can clear pending state
	if data.seq and data.caps then
		local ackData = { seq = data.seq }
		self:SendMessage(MSG.SYNC_ACK, ackData, "WHISPER", Cerebro:GetShortPlayerName(sender))
		Cerebro:Debug("Sent SYNC_ACK for seq " .. data.seq .. " to " .. sender)
	end
	
	-- Post-sync ANN_SELF DISABLED (v0.11.0) — caused thundering herd
	-- Every raider simultaneously re-announcing after receiving sync data
	-- saturated the message queue. V2 heartbeat-based detection handles
	-- the "invisible raider" bug via periodic ANNOUNCE RPC instead.
	
	-- Content Checksum: DISABLED (v0.10.2)
	-- The auto-retry loop caused a thundering herd: every raider re-requests
	-- a full sync after checksum mismatch, saturating ChatThrottleLib and killing
	-- ALL addon communication. LWW merge in HandleSyncData handles correctness
	-- without this. Will be replaced by V2 hash-based convergence.
	if false and data.checksum and not Cerebro:IsOfficer() and data.players then
		-- Build a subset of local players matching only the payload keys
		local localSubset = {}
		for name, _ in pairs(data.players) do
			localSubset[name] = Cerebro.db.global.players[name]
		end
		local localChecksum = self:ComputeSyncChecksum(localSubset)
		if localChecksum ~= data.checksum then
			-- Retry guard: prevent infinite re-request loops
			local MAX_CHECKSUM_RETRIES = 2
			local syncStatus = Cerebro.db.global.syncStatus or {}
			Cerebro.db.global.syncStatus = syncStatus
			local retries = syncStatus.checksumRetries or 0

			if retries >= MAX_CHECKSUM_RETRIES then
				Cerebro:Debug(string.format(
					"Checksum mismatch persists after %d retries (local=%s officer=%s) — accepting",
					retries, localChecksum, data.checksum))
				syncStatus.checksumRetries = 0
			else
				syncStatus.checksumRetries = retries + 1
				Cerebro:Debug(string.format(
					"Checksum mismatch (retry %d/%d): local=%s officer=%s — requesting full sync",
					retries + 1, MAX_CHECKSUM_RETRIES, localChecksum, data.checksum))
				-- Auto-request full sync with needsFullSync flag
				Cerebro:ScheduleTimer(function()
					if Cerebro.modules.Communication then
						local reqData = {
							player = Cerebro:GetPlayerFullName(),
							version = Cerebro.version,
							caps = { "delta_sync_v2" },
							needsFullSync = true,
						}
						if IsInRaid() then
							Cerebro.modules.Communication:SendMessage(MSG.SYNC_REQUEST, reqData, "RAID")
						elseif IsInGuild() then
							Cerebro.modules.Communication:SendMessage(MSG.SYNC_REQUEST, reqData, "GUILD")
						end
						Cerebro:Debug("Auto-requested full sync (checksum mismatch)")
					end
				end, 3)  -- 3s delay to avoid request storm
			end
		else
			-- Checksum matches — reset retry counter
			local syncStatus = Cerebro.db.global.syncStatus or {}
			Cerebro.db.global.syncStatus = syncStatus
			syncStatus.checksumRetries = 0
			Cerebro:Debug("Checksum match: " .. localChecksum .. " — data in sync")
		end
	end
end


--[[=============================================
	Proactive Sync to New Raid Joiners
	Sends a full snapshot WHISPER to a specific target.
	Called from Core.lua GROUP_ROSTER_UPDATE when a new member joins.
===============================================]]

-- RETIRED (Build D): All sync handled by V2 hash-based pipeline
function Communication:ProactiveWhisperSync(_target)
	return false -- V2 handles via heartbeat + snapshot
end


-- RETIRED (Build D): All sync handled by V2 hash-based pipeline
function Communication:SyncToRaid()
	return false -- V2 handles via SyncToRaid + heartbeat
end

--- Sync data to guild channel (Fix 4: used for post-session push)
-- RETIRED (Build D): All sync handled by V2 hash-based pipeline
function Communication:SyncToGuild()
	return false -- V2 handles via SyncToRaid + heartbeat
end

--[[=============================================
	Fix 4b: Pre-Raid Convergence Verification
	Runs an automated sync audit at session start.
	If divergence is detected, retries up to 3 times (10s apart).
	Warns but does not block session start.
===============================================]]

function Communication:VerifyRaidConvergence(retryCount)
	retryCount = retryCount or 0
	local maxRetries = 3
	local retryDelay = 10

	if not Cerebro:IsOfficer() then return end
	if not IsInRaid() then return end

	-- Trigger a sync audit
	Cerebro:Debug(string.format("VerifyRaidConvergence: attempt %d/%d", retryCount + 1, maxRetries))

	-- Compute our checksum and trigger audit
	local myChecksum = self:ComputeSyncChecksum()
	local myFingerprints = self:BuildPlayerFingerprints()
	local auditData = {
		requester = Cerebro:GetPlayerFullName(),
		checksum = myChecksum,
		fingerprints = myFingerprints,
	}

	-- Broadcast audit request to guild
	self:BroadcastToGuild(MSG.SYNC_AUDIT_REQ, auditData)

	-- Schedule check after collection window (8 seconds)
	Cerebro:ScheduleTimer(function()
		local s = Cerebro.db.global.syncAuditState
		if not s then return end

		local diverged = false
		for _, resp in pairs(s.responses or {}) do
			if resp.checksum ~= myChecksum then
				diverged = true
				break
			end
		end

		if diverged and retryCount < maxRetries then
			Cerebro:Print(string.format("|cffff8800[Convergence]|r Divergence detected (attempt %d/%d). Syncing and retrying...",
				retryCount + 1, maxRetries))
			-- Force a full sync then retry
			self:SyncToRaid()
			Cerebro:ScheduleTimer(function()
				self:VerifyRaidConvergence(retryCount + 1)
			end, retryDelay)
		elseif diverged then
			Cerebro:Print("|cffff0000[Convergence]|r WARNING: Data divergence persists after " .. maxRetries .. " attempts. Use /cb syncaudit for details.")
		else
			Cerebro:Print("|cff00ff00[Convergence]|r All raid members converged. ✓")
		end
	end, 8)
end

--[[=============================================
	Delta Sync v2: Payload Builder
	See docs/DELTA_SYNC_IMPLEMENTATION.md
===============================================]]

--[[=============================================
	Sync Checksum
	Computes a lightweight content fingerprint for mismatch detection.
	Formula: "playerCount:sumLastUpdate"
	 - playerCount catches added/removed players
	 - sumLastUpdate catches ANY field mutation (EP, GP, status, etc.)
	   because all write paths bump lastUpdate.
===============================================]]

function Communication:ComputeSyncChecksum(playerTable)
	local players = playerTable or Cerebro.db.global.players or {}
	-- Canonical ordering: sort by name for deterministic hash
	local names = {}
	for name, _ in pairs(players) do
		names[#names + 1] = name
	end
	table.sort(names)

	local count = #names
	local hash = 0
	for _, name in ipairs(names) do
		local p = players[name]
		-- Hash authoritative fields only: EP, GP, status, lastUpdate
		-- PR is derived and excluded (Fix 1b)
		local ep = p.ep or 0
		local gp = p.gp or 0
		local status = p.status or "Pending"
		local lastUpdate = p.lastUpdate or 0
		-- Name-based salt for position sensitivity
		local nameHash = 0
		for i = 1, #name do
			nameHash = nameHash + string.byte(name, i)
		end
		hash = hash + nameHash + ep * 7 + gp * 13 + lastUpdate
		-- Status contribution (map to numeric)
		if status == "Core" then hash = hash + 1
		elseif status == "Trial" then hash = hash + 2
		elseif status == "Pending" then hash = hash + 3
		elseif status == "Inactive" then hash = hash + 4
		end
	end
	return count .. ":" .. string.format("%.0f", hash)
end

--[[=============================================
	Sync Payload Construction
===============================================]]

function Communication:BuildSyncPayload(forceFullSync)
	-- Filter out synthetic test players before syncing
	local filteredPlayers = Cerebro:GetFilteredPlayersForSync()
	local s = Cerebro.db.global.syncState or {}
	local _now = GetServerTime and GetServerTime() or time()
	
	-- Increment sequence for this sync
	s.nextSeq = (s.nextSeq or 0) + 1
	local seq = s.nextSeq
	
	-- Determine mode: delta vs full
	local mode = "full"
	local deltaPlayers = nil
	
	if not forceFullSync and Cerebro:HasDirtyPlayers() then
		-- Build delta payload with only dirty players and their dirty fields
		mode = "delta"
		deltaPlayers = {}
		
		for name, dirtyFields in pairs(s.dirtyPlayers or {}) do
			local pData = filteredPlayers[name]
			if pData then
				-- Include full player record but mark which fields are dirty
				-- Receiver merges using fieldRev for conflict resolution
				deltaPlayers[name] = {
					rev = pData.rev or 0,
					fieldRev = pData.fieldRev or {},
					ep = pData.ep,
					gp = pData.gp,
					-- pr omitted: derived field, computed locally via RecalculateAllPR()
					status = pData.status,
					lastUpdate = pData.lastUpdate,
					class = pData.class,
					lastSeen = pData.lastSeen,
					attendance = pData.attendance,
					bossKillsPresent = pData.bossKillsPresent,
					coreAdmittedAt = pData.coreAdmittedAt,
					createdEpoch = pData.createdEpoch,
					role = pData.role,
					lifetimeEarned = pData.lifetimeEarned,
					lifetimeSpent = pData.lifetimeSpent,
					eligibleBossKills = pData.eligibleBossKills,
				}
			end
		end
	end
	
	local syncData = {
		version = Cerebro.version,
		epoch = Cerebro.db.global.resetEpoch or 0,
		scope = Cerebro.db.global.scope,
		-- purgedPlayers: REMOVED (v1.0.x) — V2 handles purge sync with guild-member
		-- protection (Phase 0.5). V1 was sending the full purge list in every payload,
		-- which older clients merged without guild-member checks, causing stale purge
		-- entries to regenerate endlessly between officers.
		totalBossKills = Cerebro.db.global.totalBossKills or 0,
		-- Delta Sync v2 fields
		mode = mode,
		seq = seq,
		caps = { "delta_sync_v2" },  -- Capability negotiation
	}
	
	if mode == "delta" then
		syncData.players = deltaPlayers
		syncData.currentSession = nil  -- Don't send session in delta
		Cerebro:Debug(string.format("BuildSyncPayload: delta mode, %d players, seq=%d", 
			Cerebro:TableCount(deltaPlayers), seq))
	else
		syncData.players = filteredPlayers
		syncData.currentSession = Cerebro.db.global.currentSession
		syncData.config = Cerebro.db.global.config
		syncData.guildMplusRuns = Cerebro.db.global.guildMplusRuns
		Cerebro:Debug(string.format("BuildSyncPayload: full mode, %d players, seq=%d",
			Cerebro:TableCount(filteredPlayers), seq))
	end
	
	-- Add content checksum for mismatch detection
	-- Checksum over the actual sent players to match what the receiver will verify
	syncData.checksum = self:ComputeSyncChecksum(syncData.players)
	
	return syncData
end

--[[=============================================
	Player Purge Handler
===============================================]]

-- RETIRED (Build A): HandlePlayerPurge moved to CommunicationV2 RPC_DISPATCH["PURGE"]
-- V2 handler includes officer guard, purge ledger write, player deletion, and UI refresh.

--[[=============================================
	Delta Sync v2: ACK Handlers
	See docs/DELTA_SYNC_IMPLEMENTATION.md
===============================================]]

-- Handle SYNC_ACK from receiver (used for WHISPER syncs + broadcast peer tracking)
function Communication:HandleSyncAck(data, sender)
	-- Only officers care about ACKs (they're the ones sending syncs)
	if not Cerebro:IsOfficer() then return end
	
	local seq = data.seq
	if not seq then return end
	
	local s = Cerebro.db.global.syncState
	if not s or not s.pendingSyncs then return end
	
	-- Raid Sync v2: Always update peer tracking regardless of ACK type
	self:UpdatePeerStatus(sender, seq, s)
	
	local pending = s.pendingSyncs[seq]
	if not pending then
		-- Broadcast ACK (not a targeted whisper) — peer tracking already updated above
		Cerebro:Debug("Broadcast SYNC_ACK from " .. sender .. " for seq " .. seq)
		return
	end
	
	-- Verify ACK came from the target we sent to
	local senderShort = Cerebro:GetShortPlayerName(sender)
	if pending.target ~= senderShort then
		Cerebro:Debug("SYNC_ACK target mismatch: expected " .. pending.target .. ", got " .. senderShort)
		return
	end
	
	-- Remove from pending syncs
	s.pendingSyncs[seq] = nil
	Cerebro:Debug("SYNC_ACK received from " .. sender .. " for seq " .. seq)
	
	-- Clear dirty entries that were included in that sync
	-- Note: For targeted syncs, we clear all current dirties since they were all sent
	-- This is safe because the receiver has ACKed receipt
	if Cerebro:TableCount(s.pendingSyncs) == 0 then
		-- No more pending syncs, safe to clear dirties
		Cerebro:ClearAllDirty()
		Cerebro:Debug("All pending syncs ACKed, cleared dirties")
	end
end

--[[ Raid Sync v2: Per-Player Peer Status Tracking
	 Updates peerStatus[fullName] with seq, timestamp, and EWMA latency.
	 Called from HandleSyncAck for every ACK (broadcast or targeted).
]]
function Communication:UpdatePeerStatus(sender, seq, syncState)
	local fullName = Cerebro:GetFullPlayerName(sender)
	if not fullName then return end
	
	syncState.peerStatus = syncState.peerStatus or {}
	local peer = syncState.peerStatus[fullName]
	if not peer then
		peer = {
			lastSeenAt = 0,
			lastSentSeq = 0,
			lastSentAt = 0,
			lastAckSeq = 0,
			lastAckAt = 0,
			lastAckLatencyMs = 0,
			avgAckLatencyMs = 0,
			freshness = "UNKNOWN",
		}
		syncState.peerStatus[fullName] = peer
	end
	
	local now = GetServerTime and GetServerTime() or time()
	
	-- Update ACK fields
	peer.lastAckSeq = seq
	peer.lastAckAt = now
	peer.lastSeenAt = now
	
	-- Calculate latency if this ACK matches the last broadcast
	if seq == syncState.lastBroadcastSeq and (syncState.lastBroadcastAt or 0) > 0 then
		local latencyMs = (now - syncState.lastBroadcastAt) * 1000
		peer.lastAckLatencyMs = latencyMs
		
		-- EWMA (α = 0.3) for stable average
		if peer.avgAckLatencyMs == 0 then
			peer.avgAckLatencyMs = latencyMs
		else
			peer.avgAckLatencyMs = 0.3 * latencyMs + 0.7 * peer.avgAckLatencyMs
		end
	end
	
	-- Derive freshness classification
	local lastBroadcast = syncState.lastBroadcastSeq or 0
	if lastBroadcast == 0 then
		peer.freshness = "UNKNOWN"
	elseif peer.lastAckSeq >= lastBroadcast then
		peer.freshness = "FRESH"
	else
		peer.freshness = "STALE"
	end
	
	Cerebro:Debug(string.format("PeerStatus updated: %s seq=%d freshness=%s latency=%dms",
		fullName, seq, peer.freshness, peer.lastAckLatencyMs or 0))
end

-- Raid Sync v2: Get list of stale/unknown raid members
-- Returns table of full player names who are stale or have no ACK data
-- DEPRECATED (v0.11.0): V2 heartbeat handles divergence detection, no stale peer tracking needed
function Communication:GetStalePeers()
	local s = Cerebro.db.global.syncState or {}
	local peerStatus = s.peerStatus or {}
	local stale = {}
	if not IsInRaid() then return stale end

	local selfName = Cerebro:GetFullPlayerName(UnitName("player"))
	for i = 1, GetNumGroupMembers() do
		local name = GetRaidRosterInfo(i)
		if name then
			local fullName = Cerebro:GetFullPlayerName(name)
			-- Skip self
			if fullName ~= selfName then
				local peer = peerStatus[fullName]
				if not peer or peer.freshness ~= "FRESH" then
					table.insert(stale, fullName)
				end
			end
		end
	end
	return stale
end

-- Convenience wrapper: returns true if any raid member is stale
function Communication:HasStalePeers()
	return #self:GetStalePeers() > 0
end

-- Raid Sync v2: Send targeted WHISPER sync to specific stale players
-- Used instead of full broadcast when only a few players are behind
local TARGETED_SYNC_THRESHOLD = 5  -- If <= this many stale, whisper instead of broadcast

-- DEPRECATED (v0.11.0): V2 heartbeat handles divergence detection, no targeted sync needed
function Communication:SendTargetedSync(targets)
	if not Cerebro:IsOfficer() then return false end

	-- Combat gate
	if not Cerebro.modules.SyncScheduler:ShouldSync() then
		Cerebro:Debug("SendTargetedSync: deferred (combat)")
		return false
	end

	-- Quarantine guard
	if self:IsQuarantined() then
		Cerebro:Debug("SendTargetedSync: skipped (quarantine)")
		return false
	end

	-- Ensure PR is fresh
	Cerebro:RecalculateAllPR()

	-- Build full snapshot payload (targets need everything)
	local syncData = self:BuildSyncPayload(true)

	local sent = 0
	for _, fullName in ipairs(targets) do
		local shortName = Cerebro:GetShortPlayerName(fullName)
		if shortName then
			self:SendMessage(MSG.SYNC_DATA, syncData, "WHISPER", shortName)
			sent = sent + 1
		end
	end

	Cerebro:Debug(string.format("Targeted sync whispered to %d stale peers", sent))
	return sent > 0
end

-- DEPRECATED (v0.11.0): V2 heartbeat handles divergence detection
function Communication:GetTargetedSyncThreshold()
	local s = Cerebro.db.global.syncState or {}
	local cfg = s.raidSyncConfig or {}
	return cfg.targetedSyncThreshold or TARGETED_SYNC_THRESHOLD
end

--[[=============================================
	Officer-to-Officer Gossip Digest (v0.9.3+)
	Lightweight anti-entropy protocol.
	Officers exchange ~100-byte checksums on GUILD channel.
	When checksums diverge, the staler officer requests a full sync.
	Solves "Offline Segmentation" where Officer A misses data
	that only Officer B received.
===============================================]]

-- Rate limit: minimum seconds between auto-sync requests triggered by gossip
local GOSSIP_REQUEST_COOLDOWN = 60
local _lastGossipRequestAt = 0

function Communication:BroadcastGossipDigest()
	-- Officer-only: non-officers have nothing to gossip about
	if not Cerebro:IsOfficer() then return end

	-- Must be in a guild to broadcast
	if not IsInGuild() then return end

	-- Fix 2b: During raids, only the Sync Leader sends gossip digests
	if IsInRaid() and not Cerebro.modules.SyncScheduler:IsSyncLeader() then return end

	-- Combat gate: don't send during encounters
	if not Cerebro.modules.SyncScheduler:ShouldSync() then return end

	-- Quarantine guard
	if self:IsQuarantined() then return end

	local now = GetServerTime and GetServerTime() or time()
	local digest = {
		e = Cerebro.db.global.resetEpoch or 0,       -- Epoch
		c = self:ComputeSyncChecksum(),               -- Content checksum
		v = Cerebro.version,                          -- Addon version
		t = now,                                      -- Sender timestamp
	}

	self:BroadcastToOfficers(MSG.GOSSIP_DIGEST, digest)
	Cerebro:Debug(string.format("BroadcastGossipDigest: e=%d c=%s", digest.e, digest.c))
end

function Communication:HandleGossipDigest(digest, sender)
	-- Ignore our own gossip broadcasts (we receive them back from GUILD channel)
	local myName = Cerebro:GetFullPlayerName(UnitName("player"))
	if sender == myName then return end

	-- Both sides must be officers
	if not Cerebro:IsOfficer() then return end
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("Ignoring gossip digest from non-officer: " .. sender)
		return
	end

	-- Fix 2b: During raids, only the Sync Leader processes gossip digests
	if IsInRaid() and not Cerebro.modules.SyncScheduler:IsSyncLeader() then
		Cerebro:Debug("Ignoring gossip digest (not sync leader in raid)")
		return
	end

	-- Validate digest structure
	if not digest or not digest.e or not digest.c then return end

	-- Epoch mismatch: different reset generation, ignore
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	if digest.e ~= localEpoch then
		Cerebro:Debug(string.format("Gossip digest epoch mismatch: local=%d sender=%d", localEpoch, digest.e))
		return
	end

	-- Compare checksums
	local localChecksum = self:ComputeSyncChecksum()
	if digest.c == localChecksum then
		Cerebro:Debug("Gossip digest match: " .. localChecksum)
		return
	end

	-- Checksums diverge — determine who is staler
	Cerebro:Debug(string.format("Gossip digest MISMATCH: local=%s officer=%s from %s",
		localChecksum, digest.c, sender))

	-- Use our last officer sync timestamp as staleness indicator
	local syncStatus = Cerebro.db.global.syncStatus or {}
	local myLastSync = syncStatus.lastOfficerSync or 0
	local theirTimestamp = digest.t or 0

	-- If our last sync is older than theirs, we are staler — request from them
	if myLastSync < theirTimestamp then
		-- Rate limit: prevent request storms
		local now = GetServerTime and GetServerTime() or time()
		if (now - _lastGossipRequestAt) < GOSSIP_REQUEST_COOLDOWN then
			Cerebro:Debug("Gossip sync request throttled (cooldown)")
			return
		end
		_lastGossipRequestAt = now

		-- Request full sync from the fresher officer
		local senderShort = Cerebro:GetShortPlayerName(sender)
		if senderShort and self:IsGuildMemberOnline(senderShort) then
			local reqData = {
				player = Cerebro:GetPlayerFullName(),
				version = Cerebro.version,
				caps = { "delta_sync_v2" },
				needsFullSync = true,
			}
			self:SendMessage(MSG.SYNC_REQUEST, reqData, "WHISPER", senderShort)
			Cerebro:Debug("Gossip: requested full sync from fresher officer " .. senderShort)
		end
	else
		-- They are staler (or equal): they will detect the mismatch and request from us
		Cerebro:Debug("Gossip: we appear fresher, waiting for their request")
	end
end

-- Handle SYNC_RCPT_ACK from officer (receipt cursor confirmation)
function Communication:HandleSyncRcptAck(data, sender)
	-- Only accept from officers
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("Ignoring SYNC_RCPT_ACK from non-officer: " .. sender)
		return
	end
	
	local batchId = data.batchId
	local cursor = data.cursor
	if not batchId or not cursor then return end
	
	-- Clear pending batch (validate sender matches target)
	local state = Cerebro.db.global.receiptSyncState or {}
	if state.pendingBatches and state.pendingBatches[batchId] then
		local pending = state.pendingBatches[batchId]
		-- Security: verify sender matches the officer we sent to
		if pending.target and pending.target ~= sender then
			Cerebro:Debug(string.format("SYNC_RCPT_ACK: ignoring mismatched sender %s (expected %s)",
				sender, pending.target))
			return
		end
		state.pendingBatches[batchId] = nil
		Cerebro:Debug("SYNC_RCPT_ACK: cleared pending batch " .. batchId)
	end
	
	-- Update our acknowledged cursor for this officer
	Cerebro.db.global.receiptAckedBy = Cerebro.db.global.receiptAckedBy or {}
	local oldCursor = Cerebro.db.global.receiptAckedBy[sender]
	-- Only advance cursor, never go backwards
	if not oldCursor or cursor.ts > (oldCursor.ts or 0) or
	   (cursor.ts == (oldCursor.ts or 0) and (cursor.seq or 0) > (oldCursor.seq or 0)) then
		Cerebro.db.global.receiptAckedBy[sender] = cursor
	end
	
	-- Clear stale cursor flag if present
	if Cerebro.db.global.staleCursors and Cerebro.db.global.staleCursors[sender] then
		Cerebro.db.global.staleCursors[sender] = nil
		Cerebro:Debug("Cleared stale cursor for " .. sender)
	end
	
	Cerebro:Debug("SYNC_RCPT_ACK from " .. sender .. ", cursor ts=" .. (cursor.ts or 0) .. " seq=" .. (cursor.seq or 0))
	
	-- Continuation: trigger next batch if more receipts remain
	-- Use short delay to avoid tight loops; pass full name for cursor lookup
	Cerebro:ScheduleTimer(function()
		self:SyncReceipts(sender)
	end, 1)
end

--[[=============================================
	EPGP Update Handlers
===============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastEPGPUpdate()
function Communication:BroadcastEPGPUpdate(player, newBalance, amount, reason, currency)
	local data = {
		player = player,
		newBalance = newBalance,
		amount = amount,
		reason = reason,
		currency = currency,
		timestamp = time(),
	}
	
	-- Broadcast to raid/party if in one, otherwise broadcast to guild
	-- This ensures manual EP/GP adjustments sync immediately even when not raiding
	if IsInRaid() or IsInGroup() then
		self:BroadcastToRaid(MSG.EPGP_UPDATE, data)
	elseif IsInGuild() then
		self:BroadcastToGuild(MSG.EPGP_UPDATE, data)
	end
end

function Communication:HandleEPGPUpdate(data, sender)
	-- Accept Readiness EP from any guild member (M+ self-awards via AwardReadinessEP).
	-- All other EP/GP changes still require officer sender.
	if not Cerebro:IsOfficer(sender) then
		if not (data.currency == "EP" and data.reason and data.reason:find("Readiness")) then
			return
		end
		Cerebro:Debug("Accepted Readiness EP from raider: " .. sender)
	end

	-- Epoch check: reject stale EPGP updates from pre-reset epoch
	if data.epoch and data.epoch < (Cerebro.db.global.resetEpoch or 0) then
		Cerebro:Debug("EPGP_UPDATE rejected: stale epoch from " .. sender)
		return
	end

	-- Update local data
	local playerData = Cerebro:GetPlayerData(data.player)
	if data.currency == "EP" then
		playerData.ep = data.newBalance
	else
		playerData.gp = data.newBalance
	end
	-- Propagate the sender's lastUpdate to prevent roster hash divergence.
	-- Without this, the receiver keeps its old lastUpdate while the sender
	-- has a newer one from Player:AddEP/AddGP, causing permanent hash
	-- mismatch and post-boss-kill fetch storms (integrity=false).
	if data.timestamp then
		playerData.lastUpdate = data.timestamp
	end
	
	-- History sync (v0.10.30+): write a local transaction so this award appears in the
	-- History tab. Use IngestReceipts, which preserves the sender's UUID, stamps
	-- verified=false/source=Auto-Import, and deduplicates by UUID automatically.
	if data.txId and Cerebro.Ledger then
		local receipt = {
			id           = data.txId,
			timestamp    = data.timestamp or time(),
			timestampUTC = date("!%Y-%m-%d %H:%M:%S", data.timestamp or time()),
			player       = data.player,
			amount       = data.amount,
			currency     = data.currency,
			reason       = data.reason,
			details      = data.details or {},
			admin        = Cerebro:GetShortPlayerName(sender) or sender,
			logicVersion = Cerebro.version,
		}
		local count = Cerebro.Ledger:IngestReceipts({ receipt })
		if count > 0 then
			Cerebro:Debug("HandleEPGPUpdate: ingested History entry " .. data.txId)
		end
	end

	-- Refresh PR
	local player = Cerebro.Player:New(data.player)
	player:UpdatePR()

	-- Show notification if it's us
	if data.player == Cerebro:GetPlayerFullName() then
		local config = Cerebro.db.global.config.notifications or {}
		if config.showEPGPChanges then
			local color = data.amount > 0 and "|cff00ff00" or "|cffff0000"
			local sign = data.amount > 0 and "+" or ""

			Cerebro:Print(string.format("%s%s %s%d|r (Reason: %s) | New balance: %d",
				color, data.currency or "EPGP", sign, data.amount, data.reason, data.newBalance))
		end
	end
	
	-- Refresh UI
	Cerebro:RefreshAllUI()
end

--[[=============================================
	BROADCAST_TX / BROADCAST_TX_BATCH Handlers (v0.10.30+)
	Receive full transaction objects from other officers.
	Used by real-time per-award broadcast and retroactive /cb synchistory.
===============================================]]

-- Receive a single transaction from another officer's Ledger:AddTransaction() call.
-- BROADCAST_TX is sent automatically by BroadcastTransaction() on every new award.
function Communication:HandleBroadcastTransaction(data, sender)
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("BROADCAST_TX rejected (non-officer): " .. tostring(sender))
		return
	end

	-- Quarantine guard: do not ingest transactions during factory reset window
	if self:IsQuarantined() then
		Cerebro:Debug("BROADCAST_TX rejected (quarantine) from " .. sender)
		return
	end

	-- Epoch guard: reject entries from a prior reset generation
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	if data.epoch and data.epoch < localEpoch then
		Cerebro:Debug("BROADCAST_TX rejected (stale epoch) from " .. sender)
		return
	end

	if not data.entry or not data.entry.id then
		Cerebro:Debug("BROADCAST_TX: malformed payload from " .. sender)
		return
	end

	-- IngestReceipts handles UUID dedup, schema validation, ReplayHistory, and RefreshAllUI
	local count = Cerebro.Ledger:IngestReceipts({ data.entry })
	if count > 0 then
		Cerebro:Debug("BROADCAST_TX: merged transaction " .. data.entry.id .. " from " .. sender)
	end
end

-- Receive a batch of transactions from /cb synchistory force-push.
-- BROADCAST_TX_BATCH is sent by BroadcastAllTransactions() in chunks of 25.
function Communication:HandleBroadcastTransactionBatch(data, sender)
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("BROADCAST_TX_BATCH rejected (non-officer): " .. tostring(sender))
		return
	end

	-- Quarantine guard
	if self:IsQuarantined() then
		Cerebro:Debug("BROADCAST_TX_BATCH rejected (quarantine) from " .. sender)
		return
	end

	-- Epoch guard
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	if data.epoch and data.epoch < localEpoch then
		Cerebro:Debug("BROADCAST_TX_BATCH rejected (stale epoch) from " .. sender)
		return
	end

	if not data.entries or type(data.entries) ~= "table" then
		Cerebro:Debug("BROADCAST_TX_BATCH: malformed payload from " .. sender)
		return
	end

	-- IngestReceipts deduplicates by UUID — safe to call even if some entries already exist
	local count = Cerebro.Ledger:IngestReceipts(data.entries)
	if count > 0 then
		local total = data.total or #data.entries
		Cerebro:Print(string.format("|cff00ff00[Cerebro]|r Received %d history entries from %s.",
			count, Cerebro:GetShortPlayerName(sender) or sender))
		Cerebro:Debug(string.format("BROADCAST_TX_BATCH: %d/%d new from %s", count, total, sender))
	end
end

--[[=============================================
	Fix 2c: EPGP Forward Handler
	Sync Leader receives forwarded awards from non-leader officers
	and merges them into local state for next heartbeat propagation.
===============================================]]

function Communication:HandleEPGPForward(data, sender)
	-- Only the Sync Leader should process forwards
	if not Cerebro.modules.SyncScheduler:IsSyncLeader() then
		Cerebro:Debug("Ignoring EPGP_FORWARD (not sync leader) from: " .. sender)
		return
	end

	-- Only accept from officers
	if not Cerebro:IsOfficer(sender) then
		Cerebro:Debug("Ignoring EPGP_FORWARD from non-officer: " .. sender)
		return
	end

	-- Epoch check: reject stale EPGP forwards from pre-reset epoch
	if data.epoch and data.epoch < (Cerebro.db.global.resetEpoch or 0) then
		Cerebro:Debug("EPGP_FORWARD rejected: stale epoch from " .. sender)
		return
	end

	-- Validate data structure
	if not data or not data.player or not data.amount or not data.currency then
		Cerebro:Debug("Invalid EPGP_FORWARD data from: " .. sender)
		return
	end

	local playerName = data.player
	local amount = data.amount
	local currency = data.currency
	local reason = data.reason or "Forwarded"

	-- Apply the change using Player model (same as HandleEPGPUpdate but with mutation)
	local player = Cerebro.Player:New(playerName)
	if currency == "EP" then
		player:AddEP(amount)
	elseif currency == "GP" then
		player:AddGP(amount)
	else
		Cerebro:Debug("Unknown currency in EPGP_FORWARD: " .. tostring(currency))
		return
	end

	-- Log to ledger for audit trail (deduplication via UUID)
	if data.txId then
		-- Check for duplicate transaction
		local existing = Cerebro.Ledger:FindTransaction(data.txId)
		if existing then
			Cerebro:Debug("EPGP_FORWARD duplicate transaction: " .. data.txId)
			return
		end
	end
	Cerebro.Ledger:AddTransaction(playerName, amount, currency, reason, data.details)

	-- Mark this player dirty so next heartbeat includes the update
	Cerebro:MarkPlayerDirty(playerName, { currency == "EP" and "ep" or "gp" })

	-- Recalculate PR
	Cerebro:RecalculateAllPR()
	Cerebro:RefreshAllUI()

	Cerebro:Debug(string.format("EPGP_FORWARD from %s: %s %+d %s (%s)",
		sender, playerName, amount, currency, reason))
end

--[[=============================================
	Bidding Handlers
===============================================]]

function Communication:BroadcastBid(itemID, itemLink, amount, priority)
	self:BroadcastToOfficers(MSG.BID_SUBMIT, {
		player = Cerebro:GetPlayerFullName(),
		itemID = itemID,
		itemLink = itemLink,
		amount = amount,
		priority = priority,
		timestamp = time(),
	})
end

function Communication:HandleBidSubmit(data, sender)
	-- Store bid in session (officers only need to track this)
	if not Cerebro:IsOfficer() then return end

	local session = Cerebro.modules.Session:GetCurrentSession()

	if not session then return end

	-- Initialize bids for this item
	if not session.bids[data.itemID] then
		session.bids[data.itemID] = {}
	end

	-- Find existing bid from this player
	local found = false

	for i, bid in ipairs(session.bids[data.itemID]) do
		if bid.player == sender then
			-- Update bid
			session.bids[data.itemID][i] = {
				player = sender,
				amount = data.amount,
				priority = data.priority,
				timestamp = data.timestamp,
				status = "pending",
			}
			found = true
			break
		end
	end

	if not found then
		-- New bid
		table.insert(session.bids[data.itemID], {
			player = sender,
			amount = data.amount,
			priority = data.priority,
			timestamp = data.timestamp,
			status = "pending",
		})
	end

	Cerebro:Debug(string.format("%s bid on item %d (priority: %d)",
		sender, data.itemID, data.priority))
end

function Communication:BroadcastBidLock()
	self:BroadcastToRaid(MSG.BID_LOCK, {
		timestamp = time(),
	})
end

function Communication:HandleBidLock(sender)
	if not Cerebro:IsOfficer(sender) then return end

	local session = Cerebro.modules.Session:GetCurrentSession()

	if session then
		session.locked = true
		Cerebro:Print("|cffff8800Bidding is now locked!|r")
	end
end

function Communication:BroadcastBidUnlock()
	self:BroadcastToRaid(MSG.BID_UNLOCK, {
		timestamp = time(),
	})
end

function Communication:HandleBidUnlock(sender)
	if not Cerebro:IsOfficer(sender) then return end

	local session = Cerebro.modules.Session:GetCurrentSession()

	if session then
		session.locked = false
		Cerebro:Print("|cff00ff00Bidding has been unlocked!|r")
	end
end

--[[=============================================
	Item Handlers
===============================================]]

function Communication:BroadcastItemDrop(itemID, itemLink)
	self:BroadcastToRaid(MSG.ITEM_DROP, {
		itemID = itemID,
		itemLink = itemLink,
		timestamp = time(),
	})
end

function Communication:HandleItemDrop(data, sender)
	-- Item dropped with bids
	Cerebro:Print("|cff00ff00Loot dropped:|r " .. data.itemLink)

	-- Check if we have a wishlist item
	if Cerebro.modules.Wishlist:IsOnWishlist(data.itemLink) then
		local shouldBid, amount, priority = Cerebro.modules.Wishlist:ShouldAutoBid(data.itemID)

		if shouldBid then
			-- Auto-bid
			Cerebro:ScheduleTimer(function()
				Cerebro.modules.Bidding:PlaceBid(data.itemLink, amount, priority)
			end, 0.5)
		end
	end
end

function Communication:BroadcastItemAward(itemID, itemLink, winner, amount)
	self:BroadcastToRaid(MSG.ITEM_AWARD, {
		itemID = itemID,
		itemLink = itemLink,
		winner = winner,
		amount = amount,
		timestamp = time(),
	})
end

function Communication:HandleItemAward(data, sender)
	if not Cerebro:IsOfficer(sender) then return end

	Cerebro:Print(string.format("%s won %s for %d GP",
		Cerebro:ColorPlayerName(data.winner),
		data.itemLink,
		data.amount
	))

	if data.winner == Cerebro:GetPlayerFullName() then
		Cerebro:PlaySound(SOUNDKIT.AUCTION_WINDOW_OPEN)
	end
end

--[[=============================================
	Session Handlers
===============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastSessionStart()
function Communication:BroadcastSessionStart(sessionID, raidName)
	local data = {
		sessionID = sessionID,
		raidName = raidName,
		timestamp = time(),
	}
	-- Fix 4: Dual-channel broadcast for reliability
	self:BroadcastToRaid(MSG.SESSION_START, data)
	if IsInGuild() then
		self:BroadcastToGuild(MSG.SESSION_START, data)
	end
end

function Communication:HandleSessionStart(data, sender)
	if not Cerebro:IsOfficer(sender) then return end

	Cerebro:Print(string.format("|cff00ff00Raid session started:|r %s (ID: %s)",
		data.raidName, data.sessionID))
end

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastSessionEnd()
function Communication:BroadcastSessionEnd(sessionID)
	local data = {
		sessionID = sessionID,
		timestamp = time(),
	}
	-- Fix 4: Dual-channel broadcast for reliability
	self:BroadcastToRaid(MSG.SESSION_END, data)
	if IsInGuild() then
		self:BroadcastToGuild(MSG.SESSION_END, data)
	end

	-- Fix 4: Post-session full GUILD sync push (ensures all members have final state)
	if Cerebro:IsOfficer() and IsInGuild() then
		Cerebro:ScheduleTimer(function()
			if Cerebro.modules.Communication then
				Cerebro.modules.Communication:SyncToGuild()
				Cerebro:Debug("Post-session GUILD sync push complete")
			end
		end, 3) -- 3-second delay to let session end processing complete
	end
end

function Communication:HandleSessionEnd(data, sender)
	if not Cerebro:IsOfficer(sender) then return end

	-- Clear local session if it matches the ended one (or if no specific ID provided)
	local localSession = Cerebro.db.global.currentSession
	if localSession then
		local endedID = data and data.sessionID
		if not endedID or localSession.sessionID == endedID then
			Cerebro.db.global.currentSession = nil
			Cerebro:Debug("SESSION_END from " .. sender .. ": cleared local session " .. tostring(localSession.sessionID))
		else
			Cerebro:Debug("SESSION_END from " .. sender .. ": ID mismatch (local=" .. tostring(localSession.sessionID) .. ", ended=" .. tostring(endedID) .. ")")
		end
	end

	Cerebro:Print("|cffff8800Raid session ended.|r Thanks for raiding!")
	
	-- Refresh UI so button reflects the updated state
	Cerebro:RefreshAllUI()
end

--[[=============================================
	Boss Kill Handlers
===============================================]]

function Communication:BroadcastBossKill(encounterID, encounterName)
	self:BroadcastToRaid(MSG.BOSS_KILL, {
		encounterID = encounterID,
		encounterName = encounterName,
		timestamp = time(),
	})
end

function Communication:HandleBossKill(data, sender)
	if not Cerebro:IsOfficer(sender) then return end

	Cerebro:Print(string.format("|cff00ff00Boss killed:|r %s", data.encounterName))
end

--[[=============================================
	Other Handlers
===============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastDecay()
function Communication:BroadcastDecay(decayPercent)
	self:BroadcastToRaid(MSG.DECAY, {
		decayPercent = decayPercent,
		timestamp = time(),
	})
end

function Communication:HandleDecay(data, sender)
	if not Cerebro:IsOfficer(sender) then return end

	Cerebro:Print(string.format("|cffff8800EPGP Decay applied:|r %d%% decay to all positive balances",
		data.decayPercent))
end

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastReset()
function Communication:BroadcastReset()
	-- Broadcast to entire guild (not just raid/party) so all online members receive reset
	self:BroadcastToGuild(MSG.RESET, {
		timestamp = time(),
		epoch = Cerebro.db.global.resetEpoch or 0,
	})
end

function Communication:HandleReset(data, sender)
	-- RBAC: Validate SENDER is authorized to initiate reset
	-- Everyone processes the reset, but only if sender is authorized
	local senderIsGM = Cerebro:IsGuildMaster(sender)
	local senderIsOfficer = Cerebro:IsOfficer(sender)
	local allowOfficerReset = Cerebro.db.global.config.admin and 
		Cerebro.db.global.config.admin.allowOfficerFactoryReset
	
	if not senderIsGM and not (senderIsOfficer and allowOfficerReset) then
		Cerebro:Debug("Reset from " .. sender .. " rejected (sender unauthorized)")
		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("RESET_DENIED", "Unauthorized sender: " .. sender)
		end
		return
	end
	
	-- Epoch check
	local incomingEpoch = data.epoch or 0
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	if incomingEpoch < localEpoch then
		Cerebro:Debug("Reset from " .. sender .. " ignored (stale epoch)")
		return
	end
	
	-- Create backup before wipe (for ALL users, not just officers)
	local backup = {
		timestamp = time(),
		version = Cerebro.version,
		players = Cerebro:CopyTable(Cerebro.db.global.players),
		transactions = Cerebro:CopyTable(Cerebro.db.global.transactions),
		audit = Cerebro:CopyTable(Cerebro.db.global.audit),
		epgpConfig = Cerebro:CopyTable(Cerebro.db.global.config.epgp),
		resetEpoch = localEpoch,
	}
	
	-- Abort if backup failed
	if not backup.players or not backup.transactions then
		Cerebro:Debug("HandleReset aborted: backup creation failed")
		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("RESET_ABORTED", "Backup failed, no wipe performed")
		end
		return
	end
	
	Cerebro.db.global.backup = backup
	if Cerebro.Ledger then
		Cerebro.Ledger:AddAuditLog("BACKUP_CREATED", "Before remote reset from " .. sender)
	end
	
	-- Wipe data tables (audit is PRESERVED with epoch markers for accountability)
	Cerebro.db.global.players = {}
	Cerebro.db.global.transactions = {}
	Cerebro.db.global.awards = {}
	-- NOTE: Audit is intentionally NOT wiped - preserved for accountability
	-- Old entries will show [Epoch N] markers in UI
	Cerebro.db.global.currentSession = nil
	Cerebro.db.global.resetEpoch = incomingEpoch
	Cerebro.db.global.resetQuarantine = { at = time() }
	
	-- Receipt Cursor Tracking: Reset cursor state on factory reset
	Cerebro:ResetReceiptCursorState("factory reset")
	
	-- Log the reset to audit (this entry will have the new epoch)
	if Cerebro.Ledger then
		Cerebro.Ledger:AddAuditLog("FACTORY_RESET", "Remote reset from " .. sender .. " (epoch " .. incomingEpoch .. ")")
	end
	
	Cerebro:Print("|cffff0000EPGP data reset by " .. sender .. "|r")
	
	-- Refresh UI
	Cerebro:RefreshAllUI()
end

--[[=============================================
	Version Check (Officer Tool)
	
	Broadcasts version request to all guild members running Cerebro.
	Responses are collected and displayed locally (not to guild chat).
===============================================]]

function Communication:VersionCheck()
	-- Officers only
	if not Cerebro:IsOfficer() then
		Cerebro:Print("Version check requires officer permissions.")
		return
	end
	
	-- Initialize response collection
	self.versionCache = {}
	self.versionCheckStart = time()
	
	-- Broadcast request to guild (uses addon messaging, invisible to guild chat)
	self:BroadcastToGuild(MSG.VERSION_CHECK, {
		request = true,
		version = Cerebro.version,
	})
	
	Cerebro:Print("|cff00ff00[Version Check]|r Scanning guild members...")

	-- Wait for responses and report
	Cerebro:ScheduleTimer(function()
		self:ReportVersions()
	end, 5)
end

function Communication:HandleVersionCheck(data, sender)
	-- If this is a REQUEST, respond with our version
	if data.request then
		self:SendMessage(MSG.VERSION_CHECK, {
			response = true,
			version = Cerebro.version,
		}, "WHISPER", Cerebro:GetShortPlayerName(sender))
		return
	end
	
	-- If this is a RESPONSE, store it
	if data.response then
		if not self.versionCache then
			self.versionCache = {}
		end
		self.versionCache[sender] = data.version
	end
end

--[[=============================================
	Sync Check (V1 Transport)
	
	Uses the proven V1 ("Cerebro" prefix) guild broadcast for reliability.
	Officer broadcasts request, all peers respond via WHISPER with their
	V2 roster/config hashes. Identical pattern to VersionCheck.
===============================================]]

function Communication:SyncCheckRequest()
	if not Cerebro:IsOfficer() then
		Cerebro:Print("Sync check requires officer permissions.")
		return
	end
	
	-- Initialize response collection
	self.syncCheckCache = {}
	self.syncCheckStart = time()
	
	-- Broadcast request via V1 guild channel (proven reliable)
	self:BroadcastToGuild(MSG.SYNC_CHECK, {
		request = true,
		version = Cerebro.version,
	})
	
	Cerebro:Print("|cff00ff00[Sync Check]|r Broadcasting via V1 channel...")
	Cerebro:Print("Collecting peer responses for 5 seconds...")
	
	-- After 5 seconds, report results
	Cerebro:ScheduleTimer(function()
		self:ReportSyncCheck()
	end, 5)
end

function Communication:HandleSyncCheck(data, sender)
	-- Self-exclusion: GUILD channel echoes messages back to sender.
	-- Without this guard, the officer responds to their own request,
	-- whispering themselves a snapshot and poisoning the response cache.
	local myName = Cerebro:GetPlayerFullName()
	local myShort = Cerebro:GetShortPlayerName(myName)
	local senderShort = Cerebro:GetShortPlayerName(sender)
	if sender == myName or senderShort == myShort then
		Cerebro:Debug("HandleSyncCheck: ignoring self-echo from " .. sender)
		return
	end

	-- If this is a REQUEST, respond with our V2 hashes
	if data.request then
		local v2 = Cerebro.modules.CommunicationV2
		local rosterHash, playerCount = "?", 0
		local configHash = "?"
		if v2 then
			rosterHash, playerCount = v2:ComputeRosterHash()
			configHash = v2:ComputeConfigHash()
		end
		self:SendMessage(MSG.SYNC_CHECK, {
			response = true,
			version = Cerebro.version,
			rosterHash = rosterHash,
			configHash = configHash,
			playerCount = playerCount,
		}, "WHISPER", Cerebro:GetShortPlayerName(sender))
		return
	end
	
	-- If this is a RESPONSE, store it
	if data.response then
		if not self.syncCheckCache then
			self.syncCheckCache = {}
		end
		self.syncCheckCache[sender] = {
			version = data.version,
			rosterHash = data.rosterHash,
			configHash = data.configHash,
			playerCount = data.playerCount,
		}
	end
end

function Communication:ReportSyncCheck()
	if not self.syncCheckCache then return end
	
	local v2 = Cerebro.modules.CommunicationV2
	local myRosterHash = v2 and v2:ComputeRosterHash() or "?"
	local myConfigHash = v2 and v2:ComputeConfigHash() or "?"
	
	local matched, mismatched = 0, 0
	local mismatchNames = {}
	local totalResponses = 0
	
	for player, info in pairs(self.syncCheckCache) do
		totalResponses = totalResponses + 1
		local rOk = info.rosterHash == myRosterHash
		local cOk = info.configHash == myConfigHash
		if rOk and cOk then
			matched = matched + 1
		else
			mismatched = mismatched + 1
			local shortName = Cerebro:GetShortPlayerName(player) or player
			local detail = ""
			if not rOk then detail = detail .. "roster" end
			if not cOk then
				if detail ~= "" then detail = detail .. "+" end
				detail = detail .. "config"
			end
			table.insert(mismatchNames, string.format("%s(%s)", shortName, detail))
		end
	end
	
	Cerebro:Print("|cff00ff00[Sync Check]|r Results:")
	Cerebro:Print(string.format("  My hashes: roster=%s config=%s", myRosterHash, myConfigHash))
	Cerebro:Print(string.format("  Responses: %d | |cff00ff00%d matched|r | |cffff4444%d mismatched|r", totalResponses, matched, mismatched))
	
	if mismatched > 0 then
		Cerebro:Print("  Mismatched: " .. table.concat(mismatchNames, ", "))
		-- Auto-trigger targeted sync (whisper snapshots to divergent peers)
		if v2 then
			local syncCount = 0
			for player, info in pairs(self.syncCheckCache) do
				if info.rosterHash ~= myRosterHash or info.configHash ~= myConfigHash then
					local snapshot = v2:BuildSnapshot(true, true)
					v2:SendV2Message("SNAP", snapshot, "WHISPER", Cerebro:GetShortPlayerName(player))
					syncCount = syncCount + 1
				end
			end
			if syncCount > 0 then
				Cerebro:Print(string.format("  |cffffcc00Auto-syncing %d divergent peers...|r", syncCount))
			end
		end
	elseif matched == 0 then
		Cerebro:Print("  |cff888888No peer responses received.|r")
	else
		Cerebro:Print("  |cff00ff00All peers converged!|r")
	end
	
	-- Clear cache
	self.syncCheckCache = {}
end

function Communication:ReportVersions()
	if not self.versionCache then return end

	local versions = {}
	local totalResponses = 0
	local outdatedCount = 0

	for player, version in pairs(self.versionCache) do
		totalResponses = totalResponses + 1
		
		-- Check if outdated
		if Cerebro:CompareVersions(version, MIN_SYNC_VERSION) < 0 then
			outdatedCount = outdatedCount + 1
		end
		
		if not versions[version] then
			versions[version] = {}
		end
		table.insert(versions[version], Cerebro:GetShortPlayerName(player))
	end

	Cerebro:Print("|cff00ff00=== Addon Version Report ===|r")
	Cerebro:Print(string.format("Responses: %d", totalResponses))

	-- Sort versions for consistent output
	local sortedVersions = {}
	for v, _ in pairs(versions) do
		table.insert(sortedVersions, v)
	end
	table.sort(sortedVersions, function(a, b)
		return Cerebro:CompareVersions(a, b) > 0  -- Descending
	end)
	
	for _, version in ipairs(sortedVersions) do
		local players = versions[version]
		local color = Cerebro:CompareVersions(version, MIN_SYNC_VERSION) >= 0 and "00ff00" or "ff8800"
		local status = Cerebro:CompareVersions(version, MIN_SYNC_VERSION) >= 0 and "" or " (OUTDATED)"
		-- Show version and player names
		local playerList = table.concat(players, ", ")
		Cerebro:Print(string.format("|cff%s%s|r%s: %s", color, version, status, playerList))
	end
	
	if outdatedCount > 0 then
		Cerebro:Print(string.format("|cffff8800Warning:|r %d player(s) below minimum version %s", outdatedCount, MIN_SYNC_VERSION))
	end

	-- Clear cache
	self.versionCache = {}
end



--[[=============================================
	Guild Discovery
==============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:AnnounceSelf()
function Communication:AnnounceSelf()
    -- Broadcast presence to guild so officers can see pending players
    if not IsInGuild() then return end
    
    -- Debounce: prevent ANN_SELF spam from rapid triggers
    -- (PLAYER_GUILD_UPDATE fires multiple times, post-sync re-announce from multiple officers, etc.)
    local now = time()
    local cooldown = 30  -- seconds
    if self._lastAnnounceSelfAt and (now - self._lastAnnounceSelfAt) < cooldown then
        Cerebro:Debug("AnnounceSelf: skipped (cooldown, last=" .. (now - self._lastAnnounceSelfAt) .. "s ago)")
        return
    end
    
    -- Ensure guild data is loaded before shouting (sometimes IsInGuild is true but channel isn't ready)
    if not GetGuildInfo("player") then
        Cerebro:Debug("Guild info not ready, retrying AnnounceSelf in 5s...")
        self:ScheduleTimer("AnnounceSelf", 5)
        return
    end
    
    -- Fix 5 Layer 3: Sender-side gate — skip ANN_SELF if own rank is not visible
    local myRankIndex = Cerebro:GetPlayerGuildRankIndex(Cerebro:GetPlayerFullName())
    local visibleRanks = (Cerebro.db.global.config.sync or {}).visibleRanks
    if visibleRanks and myRankIndex ~= nil and not visibleRanks[myRankIndex] then
        Cerebro:Debug("AnnounceSelf: skipped (rank " .. myRankIndex .. " not visible)")
        return
    end
    
    local me = Cerebro:GetPlayerFullName()
    local myData = Cerebro:GetPlayerData(me)
    
    -- Self-correction: Officers should never be "Pending"
    -- If we are an officer but marked Pending, auto-promote and skip announcement
    if myData.status == "Pending" then
        if Cerebro:IsOfficer() then
            myData.status = "Core"
            Cerebro:Debug("Self-corrected status to Core (officer detected)")
            return -- No need to announce, we're already Core
        end
        
        self._lastAnnounceSelfAt = now
        self:BroadcastToGuild(MSG.ANNOUNCE_SELF, {
            version = Cerebro.version,
            guildRankIndex = myRankIndex, -- Fix 5: Include rank for Layer 1 filtering
        })
        Cerebro:Debug("Announced presence to guild (Status: Pending)")
    end
end

function Communication:HandleAnnounceSelf(data, sender)
    -- Only officers care about this
    if not Cerebro:IsOfficer() then return end
    
    -- Version enforcement: ignore announcements from outdated clients
    local senderVersion = data.version or "0.0.0"
    if Cerebro:CompareVersions(senderVersion, MIN_SYNC_VERSION) < 0 then
        Cerebro:Debug("Ignoring announcement from outdated client: " .. sender .. " (v" .. senderVersion .. ")")
        return
    end
    
    -- Fix 5 Layer 1: Officer-side hard gate — reject ANN_SELF from non-visible ranks
    local visibleRanks = (Cerebro.db.global.config.sync or {}).visibleRanks
    if visibleRanks then
        -- Try payload-provided rank first, then live lookup
        local senderRankIndex = data.guildRankIndex
        if senderRankIndex == nil then
            senderRankIndex = Cerebro:GetPlayerGuildRankIndex(Cerebro:GetFullPlayerName(sender))
        end
        if senderRankIndex ~= nil and not visibleRanks[senderRankIndex] then
            Cerebro:Debug("ANN_SELF rejected: " .. sender .. " rank " .. senderRankIndex .. " not visible")
            return  -- Do NOT create/update player record
        end
    end
    
    -- Add to database if not exists (implicitly created by GetPlayerData)
    -- This ensures they show up in the "Pending" list
    local player = Cerebro:GetFullPlayerName(sender)
    
    -- Check if they are already in DB
    if not Cerebro.db.global.players[player] then
        Cerebro:GetPlayerData(player) -- Creates entry as Pending
        
        -- All announced players start as Pending - officer self-corrects on THEIR client
        -- Do NOT try to detect officer status here - IsOfficer(sender) has format issues
        Cerebro:Print(string.format("New candidate detected: %s (v%s)", Cerebro:ColorPlayerName(player), data.version or "?"))
        
        -- Refresh UI if open
        Cerebro:RefreshAllUI()
    else
        -- Auto-Recovery: Player exists but re-announced as Pending
        -- This happens when a raider deletes their SavedVariables — their local DB is blank
        -- so they re-announce, but we already have them as Core. Push a full sync so they
        -- regain their standings automatically without officer manual intervention.
        local existingData = Cerebro.db.global.players[player]
        if existingData.status == "Core" then
            Cerebro:Debug("Auto-recovery: " .. player .. " re-announced (Core in DB), pushing sync")
            local senderShort = Cerebro:GetShortPlayerName(sender)
            if self:IsGuildMemberOnline(senderShort) then
                Cerebro:RecalculateAllPR()
                local syncData = self:BuildSyncPayload(true)  -- Full sync (raider DB is empty)
                self:SendMessage(MSG.SYNC_DATA, syncData, "WHISPER", senderShort)
                Cerebro:Print(string.format("|cff00ff00[Auto-Recovery]|r Pushed standings to %s (detected data loss)",
                    Cerebro:ColorPlayerName(player)))
            end
        end
    end
end

--[[=============================================
	Receipt Sync (Optimistic)
==============================================]]

function Communication:SyncReceipts(targetOfficer)
    -- Receipt Cursor Tracking: Upload local transactions with cursor-based delta sync
    -- See docs/proposals/Receipt_cursor_tracking.md
    
    local receipts = Cerebro.Ledger:GetTransactions()
    
    -- Filter: only send locally created receipts (not Auto-Import)
    local myReceipts = {}
    for _, t in ipairs(receipts) do
        if t.source ~= "Auto-Import" then
            table.insert(myReceipts, t)
        end
    end
    
    if #myReceipts == 0 then return end
    
    -- Sort by (ts, seq) for consistent ordering
    table.sort(myReceipts, function(a, b)
        if (a.timestamp or 0) ~= (b.timestamp or 0) then
            return (a.timestamp or 0) < (b.timestamp or 0)
        end
        return (a.receiptSeq or 0) < (b.receiptSeq or 0)
    end)
    
    -- Get cursor state
    local ackedBy = Cerebro.db.global.receiptAckedBy or {}
    local staleCursors = Cerebro.db.global.staleCursors or {}
    local state = Cerebro.db.global.receiptSyncState or {}
    state.pendingBatches = state.pendingBatches or {}
    local maxBatchSize = state.maxBatchSize or 40
    
    -- Skip if we already have a pending batch (serial batching)
    if Cerebro:TableCount(state.pendingBatches) > 0 then
        Cerebro:Debug("SyncReceipts: skipped, pending batch in-flight")
        return
    end
    
    -- Get target officers
    local targets = {}
    if targetOfficer then
        -- Normalize to full name for cursor key consistency
        local officerFull = Cerebro:GetFullPlayerName(targetOfficer) or targetOfficer
        table.insert(targets, officerFull)
    else
        -- Broadcast mode: find known officers
        local knownOfficers = type(self.GetKnownOfficers) == "function" and self:GetKnownOfficers() or {}
        if #knownOfficers == 0 then
            -- Fallback to broadcast
            self:BroadcastToOfficers(MSG.SYNC_RECEIPTS, {
                receipts = myReceipts,
                epoch = Cerebro.db.global.resetEpoch or 0,
            })
            Cerebro:Debug("SyncReceipts: broadcast mode, " .. #myReceipts .. " receipts")
            return
        end
        targets = knownOfficers
    end
    
    -- For each officer, filter by cursor and send batch
    for _, officer in ipairs(targets) do
        local cursor = ackedBy[officer]
        local isStale = staleCursors[officer]
        
        -- Filter by cursor (unless stale or no cursor)
        local toSend = {}
        for _, t in ipairs(myReceipts) do
            if isStale or not cursor then
                -- Stale or no cursor: send all
                table.insert(toSend, t)
            else
                -- Cursor comparison: (ts, seq) > cursor
                local receiptTs = t.timestamp or 0
                local receiptSeq = t.receiptSeq or 0
                local cursorTs = cursor.ts or 0
                local cursorSeq = cursor.seq or 0
                
                if receiptTs > cursorTs or (receiptTs == cursorTs and receiptSeq > cursorSeq) then
                    table.insert(toSend, t)
                end
            end
        end
        
        if #toSend == 0 then
            Cerebro:Debug("SyncReceipts: no new receipts for " .. officer)
        else
            -- Take first batch only (serial)
            local chunk = {}
            local maxTs, maxSeq = 0, 0
            for i = 1, math.min(maxBatchSize, #toSend) do
                table.insert(chunk, toSend[i])
                local t = toSend[i]
                if (t.timestamp or 0) > maxTs or
                   ((t.timestamp or 0) == maxTs and (t.receiptSeq or 0) > maxSeq) then
                    maxTs = t.timestamp or 0
                    maxSeq = t.receiptSeq or 0
                end
            end
            
            local batchId = Cerebro:GenerateUUID()
            local payload = {
                batchId = batchId,
                cursor = { ts = maxTs, seq = maxSeq },
                receipts = chunk,
                epoch = Cerebro.db.global.resetEpoch or 0,
            }
            
            -- Track pending batch (store payload for retry resend)
            state.pendingBatches[batchId] = {
                sentAt = time(),
                target = officer,
                cursor = { ts = maxTs, seq = maxSeq },
                retryCount = 0,
                payload = payload,  -- Store for retry resend
            }
            
            -- WHISPER uses short name, but cursor keys use full name
            local whisperTarget = Cerebro:GetShortPlayerName(officer) or officer
            self:SendMessage(MSG.SYNC_RECEIPTS, payload, "WHISPER", whisperTarget)
            Cerebro:Debug(string.format("SyncReceipts: sent %d receipts to %s (cursor ts=%d seq=%d)",
                #chunk, officer, maxTs, maxSeq))
        end
    end
end

function Communication:HandleSyncReceipts(data, sender)
    if not Cerebro:IsOfficer() then return end
    
    -- Epoch check (defense-in-depth): reject stale receipts from pre-reset epoch
    local payloadEpoch = data.epoch or 0
    local localEpoch = Cerebro.db.global.resetEpoch or 0
    if payloadEpoch < localEpoch then
        Cerebro:Debug("Receipts from " .. sender .. " rejected: stale epoch " .. payloadEpoch .. " < " .. localEpoch)
        if Cerebro.Ledger then
            Cerebro.Ledger:AddAuditLog("RECEIPTS_REJECTED_STALE", 
                string.format("From %s, epoch %d < %d", sender, payloadEpoch, localEpoch))
        end
        return
    end
    
    Cerebro:Debug("Received receipts from " .. sender)
    
    if data.receipts then
        local added = Cerebro.Ledger:IngestReceipts(data.receipts)
        if added > 0 then
            Cerebro:Print(string.format("Imported %d new transactions from %s.", added, Cerebro:ColorPlayerName(sender)))
            
            -- Raid Sync v2: Defer broadcast — mark peers as needing updated data
            -- MaybeRaidSync heartbeat will pick this up on next 30s tick
            -- This avoids an extra full broadcast mid-raid for receipt ingestion
            local s = Cerebro.db.global.syncState or {}
            s.peerStatus = s.peerStatus or {}
            -- Mark all current raid members as STALE since data changed
            if IsInRaid() then
                for i = 1, GetNumGroupMembers() do
                    local rName = GetRaidRosterInfo(i)
                    if rName then
                        local rFullName = Cerebro:GetFullPlayerName(rName)
                        if rFullName and s.peerStatus[rFullName] then
                            s.peerStatus[rFullName].freshness = "STALE"
                        end
                    end
                end
                Cerebro:Debug("Receipt ingestion: marked raid peers as STALE (deferred sync)")
            else
                -- Not in raid: sync immediately (guild fallback)
                Cerebro:ScheduleTimer(function()
                    self:SyncToRaid()
                end, 2)
            end
        end
    end
    
    -- Receipt Cursor Tracking: Send SYNC_RCPT_ACK back to sender via WHISPER
    -- FIX: Check if sender is online before whispering (prevents spam to offline players)
    if data.batchId and data.cursor then
        local senderShort = Cerebro:GetShortPlayerName(sender)
        local senderOnline = self:IsGuildMemberOnline(sender)
        
        if not senderOnline then
            Cerebro:Debug("Skipping SYNC_RCPT_ACK - sender " .. sender .. " is offline")
        else
            local ackPayload = {
                batchId = data.batchId,
                cursor = data.cursor,
            }
            self:SendMessage(MSG.SYNC_RCPT_ACK, ackPayload, "WHISPER", senderShort)
            Cerebro:Debug("Sent SYNC_RCPT_ACK for batch " .. data.batchId .. " to " .. sender)
        end
    end
end

--[[=============================================
	Receipt Fallback (Officer-only)
	
	When an officer comes online and no other officers respond to sync,
	this fallback requests receipts from all raiders.
==============================================]]

-- DEPRECATED (v0.11.0): V2 handles sync requests via STATE_FETCH
function Communication:RequestSyncWithFallback()
    -- Officers only
    if not Cerebro:IsOfficer() then return end
    
    -- Reset flag
    self.receivedOfficerSync = false
    
    -- Phase 1: Request sync from officers
    self:RequestSync()
    Cerebro:Debug("RequestSyncWithFallback: Phase 1 - requesting officer sync")
    
    -- Phase 2: After timeout, if no response, request receipts from raiders
    Cerebro:ScheduleTimer(function()
        if not self.receivedOfficerSync then
            Cerebro:Debug("No officer sync received, requesting receipts from raiders...")
            Cerebro:Print("|cffff8800No officers responded.|r Requesting receipts from guild members...")
            self:RequestRaiderReceipts()
        end
    end, 10)  -- 10 second timeout
end

function Communication:RequestRaiderReceipts()
    -- Only officers can request receipts
    if not Cerebro:IsOfficer() then return end
    
    self:BroadcastToGuild(MSG.RECEIPT_REQUEST, {
        officer = Cerebro:GetPlayerFullName(),
        version = Cerebro.version,
    })
    Cerebro:Debug("Requested receipts from guild members.")
end

function Communication:HandleReceiptRequest(data, sender)
    -- Only respond if we are NOT an officer (officers don't need to send receipts back)
    if Cerebro:IsOfficer() then return end
    
    -- Only respond if sender is an officer
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("Ignoring receipt request from non-officer: " .. sender)
        return
    end
    
    -- Version check on the request
    local senderVersion = data.version or "0.0.0"
    if Cerebro:CompareVersions(senderVersion, MIN_SYNC_VERSION) < 0 then
        Cerebro:Debug("Ignoring receipt request from outdated officer: " .. sender)
        return
    end
    
    -- Upload our receipts to the requesting officer (use full name for cursor lookup)
    self:SyncReceipts(sender)
    Cerebro:Debug("Sent receipts to officer " .. sender)
end

--[[=============================================
	Broadcast Handlers (Real-time propagation)
==============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastTransaction()
function Communication:BroadcastTransaction(entry)
    self:BroadcastToOfficers(MSG.BROADCAST_TRANSACTION, {
        entry = entry
    })
end

-- REMOVED (v0.10.30): Old HandleBroadcastTransaction deleted.
-- It lacked quarantine and epoch guards and was silently overwriting the
-- new secure handler at L1776 (Lua last-definition-wins regression).
-- The active handler is now the one in the "BROADCAST_TX / BROADCAST_TX_BATCH"
-- section above, called via V2 RPC dispatch.

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastAuditLog()
function Communication:BroadcastAuditLog(entry)
    self:BroadcastToOfficers(MSG.BROADCAST_AUDIT, {
        entry = entry
    })
end

function Communication:HandleBroadcastAuditLog(data, sender)
    if not Cerebro:IsOfficer() then return end
    if sender == Cerebro:GetPlayerFullName() then return end
    
    if data.entry then
        Cerebro.Ledger:MergeAuditLog(data.entry)
    end
end


--[[=============================================
	Discovery Handlers (v0.1.2)
==============================================]]

-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastDiscovery()
function Communication:BroadcastDiscovery()
    -- Ask all guild members to check their status
    -- "GUILD" distribution ensures we hit people not in raid
    if IsInGuild() then
        self:BroadcastToGuild(MSG.DISCOVERY_REQUEST, {})
        Cerebro:Debug("Sent Discovery Request to Guild.")
    end
end

function Communication:HandleDiscoveryRequest(sender)
    -- If we are an officer, ignore this (we are the ones asking, or already known)
    if Cerebro:IsOfficer() then return end
    
    -- If we are a normal raider, we might need to announce?
    -- Only if we are Pending? Or maybe Core too just in case?
    -- Plan says "If local player is Pending"
    
    local me = Cerebro:GetPlayerFullName()
    local myData = Cerebro:GetPlayerData(me)
    
    if myData.status == "Pending" then
        -- We are pending! Announce ourselves so the officer sees us.
        self:AnnounceSelf()
        Cerebro:Print("Officer " .. sender .. " requested a roll call. Announced presence.")
    end
end


-- DEPRECATED (v0.11.0): Superseded by CommunicationV2:BroadcastPlayerUpdate()
function Communication:BroadcastPlayerUpdate(playerName, updateData)
    -- Broadcast specific field updates for a player to the GUILD
    -- Useful for Status changes (Pending -> Core)
    if IsInGuild() then
        self:BroadcastToGuild(MSG.PLAYER_UPDATE, {
            player = playerName,
            update = updateData
        })
    end
end

function Communication:HandlePlayerUpdate(data, sender)
    -- Allow updates from officers
    if not Cerebro:IsOfficer(sender) then return end
    
    local target = data.player
    local update = data.update
    
    if target and update then
        local playerData = Cerebro:GetPlayerData(target)
        
        -- Merge updates
        for k, v in pairs(update) do
            playerData[k] = v
        end
        
        -- FIX: Ensure lastUpdate is set for LWW sync
        -- If incoming update includes lastUpdate, it was already applied above
        -- If not, set it now so this update wins future merges
        if not update.lastUpdate then
            playerData.lastUpdate = time()
        end
        
        Cerebro:Debug("Received player update for " .. target .. " from " .. sender)
        
        -- Refresh UI if open (e.g. My Progress needs to show new status)
        Cerebro:RefreshAllUI()
        
        -- If it's me, print a message
        if target == Cerebro:GetPlayerFullName() then
             if update.status then
                 Cerebro:Print("Your status has been updated to: |cff00ff00" .. update.status .. "|r")
             end
        end
    end
end

--[[=============================================
    M+ Run Injection (Dev-Only Testing)
===============================================]]

-- Broadcast a simulated M+ run to target's history (dev-only)
function Communication:BroadcastMPlusRunInject(targetPlayer, runData)
    -- Dev gate: Only available when test harness is loaded
    if not (SlashCmdList["TESTMPLUS"] or Cerebro._TEST_HARNESS_LOADED) then
        Cerebro:Debug("BroadcastMPlusRunInject: blocked (not in dev mode)")
        return
    end
    
    if not targetPlayer or not runData then
        Cerebro:Debug("BroadcastMPlusRunInject: missing target or runData")
        return
    end
    
    -- Send via whisper to target
    local shortName = Cerebro:GetShortPlayerName(targetPlayer)
    self:SendMessage(MSG.MPLUS_RUN_INJECT, { run = runData }, "WHISPER", shortName)
    Cerebro:Debug("Sent MPLUS_RUN_INJECT to " .. shortName)
end

function Communication:HandleMPlusRunInject(data, sender)
    -- Dev gate: Only process if test harness is loaded
    if not (SlashCmdList["TESTMPLUS"] or Cerebro._TEST_HARNESS_LOADED) then
        Cerebro:Debug("HandleMPlusRunInject: ignored (not in dev mode)")
        return
    end
    
    -- RBAC: Only accept from officers
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("HandleMPlusRunInject: ignored (non-officer sender: " .. sender .. ")")
        return
    end
    
    -- Payload validation: required top-level fields
    local run = data and data.run
    if not run then
        Cerebro:Debug("HandleMPlusRunInject: malformed payload (no run)")
        return
    end
    
    if not run.mapID or not run.level or not run.timestamp then
        Cerebro:Debug("HandleMPlusRunInject: malformed payload (missing required fields)")
        return
    end
    
    if not run.totalEP then
        Cerebro:Debug("HandleMPlusRunInject: warning - missing totalEP, UI may display incorrectly")
    end
    
    -- Ensure group exists (warn if not)
    if not run.group or type(run.group) ~= "table" then
        Cerebro:Debug("HandleMPlusRunInject: warning - missing or malformed group data")
    end
    
    -- Initialize mplusRuns if needed
    if not Cerebro.db.global.mplusRuns then
        Cerebro.db.global.mplusRuns = {}
    end
    
    -- Insert the run
    table.insert(Cerebro.db.global.mplusRuns, run)
    Cerebro:Debug("HandleMPlusRunInject: inserted run (level " .. run.level .. ") from " .. sender)
    
    -- Refresh UI if My Progress is visible
    local MyProgress = Cerebro:GetModule("MyProgress", true)
    if MyProgress then
        -- Trigger a full redraw by firing the message
        MyProgress:SendMessage("Cerebro_EPGP_Update")
    end
    
    -- Also refresh standings in case it's open
    Cerebro:RefreshAllUI()
    
    Cerebro:Print("|cff00ff00[DEV]|r Received simulated M+ run from " .. sender)
end

--[[=============================================
	Guild M+ History - Push/Receive
	Raiders push completed runs to officers for guild-wide history
===============================================]]

-- Push a completed M+ run to officers for Guild M+ History tab
-- Called after CHALLENGE_MODE_COMPLETED records the run locally
function Communication:PushMPlusRunToOfficers(runData)
    if not IsInGuild() then return end
    
    -- Broadcast to guild (officers will receive and store)
    self:BroadcastToGuild(MSG.MPLUS_RUN_PUSH, runData)
    Cerebro:Debug("Pushed M+ run to officers: " .. (runData.runKey or "no-key"))
    
    -- Auto-sync EP receipts 3 seconds after M+ completion
    self:ScheduleTimer("SyncReceipts", 3)
end

-- Handle incoming M+ run push from raiders (officer-only)
-- Uses runKey for deduplication (same pattern as HasExistingRunKeyAward)
function Communication:HandleMPlusRunPush(data, sender)
    -- Officers only - raiders don't store guild history
    if not Cerebro:IsOfficer() then return end
    
    -- Validate required fields
    if not data or not data.runKey then
        Cerebro:Debug("HandleMPlusRunPush: missing runKey from " .. sender)
        return
    end
    
    -- Initialize storage if needed
    if not Cerebro.db.global.guildMplusRuns then
        Cerebro.db.global.guildMplusRuns = {}
    end
    
    local runs = Cerebro.db.global.guildMplusRuns
    
    -- Dedupe by runKey (same pattern as HasExistingRunKeyAward in MythicPlus.lua)
    for _, r in ipairs(runs) do
        if r.runKey == data.runKey then
            Cerebro:Debug("HandleMPlusRunPush: duplicate runKey, skipping")
            return
        end
    end
    
    -- Add metadata
    data.uploadedBy = sender
    data.receivedAt = time()
    
    -- Store the run
    table.insert(runs, data)
    
    -- FIFO: Cap at 500 runs
    while #runs > 500 do
        table.remove(runs, 1)
    end
    
    Cerebro:Debug("HandleMPlusRunPush: stored run from " .. sender .. " (total: " .. #runs .. ")")
end

--[[=============================================
	Sync Audit (Diagnostic)
	Officer-initiated per-player fingerprint comparison
	across all online guild members.
	Strictly read-only — no mutations, no ledger writes.
===============================================]]

-- Transient audit state (not persisted to DB)
local _auditResponses = nil    -- { [sender] = { checksum, fingerprints } }
local _auditLocalFP = nil      -- Officer's own fingerprints for comparison
local _auditInProgress = false -- Guard against overlapping audits

--- Build per-player fingerprints for audit comparison.
--- Returns: { ["Name-Realm"] = { ep=N, gp=N, st="Status", lu=N, att=N }, ... }
--- Only includes players with status == "Core" (matches Standings filter).
--- Read-only: no mutations.
function Communication:BuildPlayerFingerprints()
    local players = Cerebro.db.global.players or {}
    local fingerprints = {}

    for name, pData in pairs(players) do
        -- Only include Core players (same filter as Standings display)
        if pData.status == "Core" then
            fingerprints[name] = {
                ep  = pData.ep or 0,
                gp  = pData.gp or 0,
                st  = pData.status or "?",
                lu  = pData.lastUpdate or 0,
                att = pData.attendance or 0,
            }
        end
    end

    return fingerprints
end

--- Officer initiates a sync audit: broadcasts request to all guild members.
function Communication:RequestSyncAudit()
    -- Officer-only guard
    if not Cerebro:IsOfficer() then
        Cerebro:Print("|cffff4444Sync Audit is officer-only.|r")
        return
    end

    -- Must be in a guild
    if not IsInGuild() then
        Cerebro:Print("|cffff4444Not in a guild. Cannot run sync audit.|r")
        return
    end

    -- Guard against overlapping audits
    if _auditInProgress then
        Cerebro:Print("|cffff8800Sync audit already in progress. Please wait.|r")
        return
    end

    _auditInProgress = true
    _auditResponses = {}
    _auditLocalFP = self:BuildPlayerFingerprints()

    local reqData = {
        requester = Cerebro:GetPlayerFullName(),
        checksum  = self:ComputeSyncChecksum(),
    }

    -- Broadcast on GUILD (reaches all online guild members)
    self:SendMessage(MSG.SYNC_AUDIT_REQ, reqData, "GUILD")

    -- Also broadcast on RAID if in a raid (catches non-guild members)
    if IsInRaid() then
        self:SendMessage(MSG.SYNC_AUDIT_REQ, reqData, "RAID")
    end

    local fpCount = 0
    for _ in pairs(_auditLocalFP) do fpCount = fpCount + 1 end

    Cerebro:Print(string.format("|cff00ccffSync Audit:|r Scanning... (%d local Core players)", fpCount))
    Cerebro:Print("Collecting responses for 30 seconds...")

    -- After 30s, compile and print the report
    self:ScheduleTimer(function()
        self:PrintSyncAuditReport()
        _auditInProgress = false
    end, 30)
end

--- Handle incoming sync audit REQUEST (anyone can respond).
function Communication:HandleSyncAuditRequest(data, sender)
    -- Validate: sender must be an officer (only officers may initiate audits)
    if not Cerebro:IsOfficer(sender) then
        Cerebro:Debug("Ignoring sync audit request from non-officer: " .. sender)
        return
    end

    -- Don't respond to our own request
    local me = Cerebro:GetPlayerFullName()
    if sender == me then return end

    -- Build our local fingerprints and reply via WHISPER
    local fingerprints = self:BuildPlayerFingerprints()
    local checksum = self:ComputeSyncChecksum()

    local respData = {
        requester    = data.requester,
        checksum     = checksum,
        fingerprints = fingerprints,
        isOfficer    = Cerebro:IsOfficerSafe(),
        version      = Cerebro.version,
    }

    -- Whisper back to requester (sender is guaranteed online — they just messaged us)
    local senderShort = Cerebro:GetShortPlayerName(sender)
    if senderShort then
        self:SendMessage(MSG.SYNC_AUDIT_RESP, respData, "WHISPER", senderShort)
        Cerebro:Debug("Sent sync audit response to " .. senderShort)
    end
end

--- Handle incoming sync audit RESPONSE (officer collects).
function Communication:HandleSyncAuditResponse(data, sender)
    -- Only collect if we have an audit in progress
    if not _auditInProgress or not _auditResponses then
        return
    end

    -- Store response
    _auditResponses[sender] = {
        checksum     = data.checksum,
        fingerprints = data.fingerprints or {},
        isOfficer    = data.isOfficer,
        version      = data.version,
    }

    Cerebro:Debug("Received sync audit response from " .. sender)
end

--- Compile and print the sync audit report.
--- Compares each responder's fingerprints against the officer's authoritative data.
function Communication:PrintSyncAuditReport()
    local responses = _auditResponses or {}
    local localFP = _auditLocalFP or {}
    local localChecksum = self:ComputeSyncChecksum()

    -- Count responses
    local responseCount = 0
    for _ in pairs(responses) do responseCount = responseCount + 1 end

    if responseCount == 0 then
        Cerebro:Print("|cff00ccff=== Sync Audit Report ===|r")
        Cerebro:Print("  |cff888888No responses received. Other players may be offline or not running Cerebro.|r")
        Cerebro:Print("|cff00ccff========================|r")
        _auditResponses = nil
        _auditLocalFP = nil
        return
    end

    Cerebro:Print(string.format("|cff00ccff=== Sync Audit Report (%d responses) ===|r", responseCount))

    local inSyncCount = 0
    local mismatchCount = 0
    local sortedResponders = {}
    for responder in pairs(responses) do
        table.insert(sortedResponders, responder)
    end
    table.sort(sortedResponders)

    for _, responder in ipairs(sortedResponders) do
        local resp = responses[responder]
        local shortName = Cerebro:GetShortPlayerName(responder) or responder
        local roleTag = resp.isOfficer and "|cffff8800[O]|r " or "|cff888888[R]|r "

        -- Fast path: checksum match means fully in sync
        if resp.checksum == localChecksum then
            inSyncCount = inSyncCount + 1
            Cerebro:Print(string.format("  |cff00ff00+|r %s%s |cff00ff00IN SYNC|r",
                roleTag, shortName))
        else
            -- Per-player comparison
            local mismatches = {}
            local missingLocal = {}   -- They have it, we don't
            local missingRemote = {}  -- We have it, they don't

            -- Check players we have vs what they have
            for playerName, localData in pairs(localFP) do
                local remoteData = resp.fingerprints[playerName]
                if not remoteData then
                    table.insert(missingRemote, playerName)
                else
                    -- Compare fields
                    local diffs = {}
                    if localData.ep ~= remoteData.ep then
                        table.insert(diffs, string.format("EP %d->%d", localData.ep, remoteData.ep))
                    end
                    if localData.gp ~= remoteData.gp then
                        table.insert(diffs, string.format("GP %d->%d", localData.gp, remoteData.gp))
                    end
                    if localData.att ~= remoteData.att then
                        table.insert(diffs, string.format("att %d->%d", localData.att, remoteData.att))
                    end
                    if localData.st ~= remoteData.st then
                        table.insert(diffs, string.format("status %s->%s", localData.st, remoteData.st))
                    end
                    if #diffs > 0 then
                        table.insert(mismatches, {
                            player = playerName,
                            details = table.concat(diffs, ", ")
                        })
                    end
                end
            end

            -- Check players they have that we don't
            for playerName, _ in pairs(resp.fingerprints) do
                if not localFP[playerName] then
                    table.insert(missingLocal, playerName)
                end
            end

            local totalIssues = #mismatches + #missingRemote + #missingLocal

            if totalIssues == 0 then
                -- Checksum mismatch but no field-level diffs (non-Core players differ)
                inSyncCount = inSyncCount + 1
                Cerebro:Print(string.format("  |cff00ff00+|r %s%s |cff00ff00IN SYNC|r |cff888888(Core data matches)|r",
                    roleTag, shortName))
            else
                mismatchCount = mismatchCount + 1
                Cerebro:Print(string.format("  |cffff4444x|r %s%s |cffff4444%d ISSUE(S)|r",
                    roleTag, shortName, totalIssues))

                -- Print mismatched players (cap at 5 to avoid spam)
                local shown = 0
                for _, m in ipairs(mismatches) do
                    if shown >= 5 then
                        Cerebro:Print(string.format("      |cff888888... and %d more|r",
                            #mismatches - shown))
                        break
                    end
                    local pShort = Cerebro:GetShortPlayerName(m.player) or m.player
                    Cerebro:Print(string.format("      |cffffcc00%s|r: %s", pShort, m.details))
                    shown = shown + 1
                end

                -- Missing players
                if #missingRemote > 0 then
                    local names = {}
                    for i, n in ipairs(missingRemote) do
                        if i > 3 then
                            table.insert(names, "...+" .. (#missingRemote - 3))
                            break
                        end
                        table.insert(names, Cerebro:GetShortPlayerName(n) or n)
                    end
                    Cerebro:Print(string.format("      |cffff8800Missing %d player(s)|r: %s",
                        #missingRemote, table.concat(names, ", ")))
                end
                if #missingLocal > 0 then
                    local names = {}
                    for i, n in ipairs(missingLocal) do
                        if i > 3 then
                            table.insert(names, "...+" .. (#missingLocal - 3))
                            break
                        end
                        table.insert(names, Cerebro:GetShortPlayerName(n) or n)
                    end
                    Cerebro:Print(string.format("      |cff888888Extra %d player(s)|r: %s",
                        #missingLocal, table.concat(names, ", ")))
                end
            end
        end
    end

    -- Summary
    Cerebro:Print(string.format("  |cff00ccffSummary:|r |cff00ff00%d IN SYNC|r, |cffff4444%d MISMATCHED|r",
        inSyncCount, mismatchCount))
    Cerebro:Print("|cff00ccff" .. string.rep("=", 40) .. "|r")

    -- Cleanup
    _auditResponses = nil
    _auditLocalFP = nil
end

return Communication
