--[[
	Cerebro - Communication Module
	Handles addon messaging, synchronization, and broadcasts
]]--

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()
    -- Announce self after a short delay to ensure everything is loaded
    self:ScheduleTimer("AnnounceSelf", 10)
    
    -- Listen for GuildJoin
    self:RegisterEvent("PLAYER_GUILD_UPDATE", "AnnounceSelf")
    
    -- Auto-sync on login (5s delay) and periodic (every 1 min)
    -- See SPEC.md §4.2 for guards and backoff rules
    self:ScheduleTimer("RequestSync", 5)
    self:ScheduleRepeatingTimer("MaybeRequestSync", 60)
    
    -- Delta Sync v2: Check for stale pending syncs every 30s
    self:ScheduleRepeatingTimer("CheckPendingSyncs", 30)
    
    -- Receipt Cursor Tracking: Check for stale pending receipt batches every 30s
    self:ScheduleRepeatingTimer("CheckPendingReceiptBatches", 30)
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
}

-- 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
local lastQuarantineLog = 0        -- Dedup log spam for SyncToRaid

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

-- Delta Sync v2: Retry stale pending syncs
function Communication:CheckPendingSyncs()
    if not Cerebro:IsOfficer() then 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
                
                self:SendMessage(MSG.SYNC_DATA, syncData, "WHISPER", pending.target)
                Cerebro:Debug(string.format("Retry %d for seq %d to %s", 
                    pending.retryCount, seq, pending.target))
            else
                -- Max retries exceeded: force full sync
                Cerebro:Debug("Max retries for seq " .. seq .. ", forcing full to " .. pending.target)
                
                local fullData = self:BuildSyncPayload(true)  -- Full
                self:SendMessage(MSG.SYNC_DATA, fullData, "WHISPER", pending.target)
                
                -- Remove this pending entry
                s.pendingSyncs[seq] = nil
            end
        end
    end
end

-- Receipt Cursor Tracking: Retry stale pending receipt batches
function Communication:CheckPendingReceiptBatches()
    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"

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

-- Helper: Check if a guild member is currently online
-- Used by all WHISPER send locations to prevent spam to offline players
function Communication:IsGuildMemberOnline(playerName)
    if not IsInGuild() then return false end
    
    local fullName = Cerebro:GetFullPlayerName(playerName)
    local shortName = Cerebro:GetShortPlayerName(playerName)
    
    for i = 1, GetNumGuildMembers() do
        local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
        if name and online then
            local memberFull = Cerebro:GetFullPlayerName(name)
            local memberShort = Cerebro:GetShortPlayerName(name)
            if memberFull == fullName or memberShort == shortName then
                return true
            end
        end
    end
    
    return false
end

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_DATA] = true,
		[MSG.SYNC_REQUEST] = true,
		[MSG.ANNOUNCE_SELF] = 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

	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.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.SESSION_START then
		self:HandleSessionStart(data, sender)

	elseif msgType == MSG.SESSION_END then
		self:HandleSessionEnd(data, sender)

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

	elseif msgType == MSG.DECAY then
		self:HandleDecay(data, sender)

	elseif msgType == MSG.RESET then
		self:HandleReset(data, sender)

	elseif msgType == MSG.VERSION_CHECK then
		self:HandleVersionCheck(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.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)

    elseif msgType == MSG.PLAYER_PURGE then
        self:HandlePlayerPurge(data, sender)

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

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

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

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

function Communication:RequestSync()
	-- Quarantine guard
	if self:IsQuarantined() then
		Cerebro:Debug("RequestSync: skipped (quarantine active)")
		return
	end
	
	Cerebro:Debug("Requesting data sync from officers...")
	local data = {
		player = Cerebro:GetPlayerFullName(),
		version = Cerebro.version,
		caps = { "delta_sync_v2" },  -- Delta Sync v2 capability
	}
	-- RAID takes priority (targeted), GUILD as fallback (reaches all online officers)
	if IsInRaid() then
		self:SendMessage(MSG.SYNC_REQUEST, data, "RAID")
	elseif IsInGuild() then
		self:SendMessage(MSG.SYNC_REQUEST, data, "GUILD")
	end
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()
	local forceFullSync = not supportsDelta or (now - (s.lastFullSyncAt or 0)) >= 600
	
	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: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 name, pData in pairs(data.players) do
			-- Skip player if in purge ledger (prevents resurrection)
			if Cerebro.db.global.purgedPlayers and Cerebro.db.global.purgedPlayers[name] then
				Cerebro:Debug("Skipping purged player during 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"}
					
					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
							localPlayer[field] = pData[field]
							localPlayer.fieldRev[field] = incomingRev
							fieldsUpdated = fieldsUpdated + 1
						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
							Cerebro.db.global.players[name][k] = v
						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")

	if data.currentSession then
		local localSession = Cerebro.db.global.currentSession
		local incomingSessionID = data.currentSession.sessionID
		
		-- Check if this session was already ended (exists in history)
		local wasEnded = Cerebro.db.global.history and Cerebro.db.global.history[incomingSessionID]
		if wasEnded then
			Cerebro:Debug("Sync: Rejecting stale session " .. incomingSessionID .. " (already in history)")
		elseif not localSession then
			-- No local session, accept incoming
			Cerebro.db.global.currentSession = data.currentSession
		elseif localSession.sessionID ~= incomingSessionID then
			-- Different session entirely - replace
			Cerebro.db.global.currentSession = data.currentSession
		else
			-- Same session - don't overwrite, preserve local state
			Cerebro:Debug("Sync: Preserving local session state (same sessionID)")
		end
	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: Non-officers re-announce to ensure officers know they exist
	-- This fixes the "invisible raider" bug where sync clobbers local player entry
	-- and the raider never appears in officer's Standings
	if not Cerebro:IsOfficer() then
		Cerebro:ScheduleTimer(function()
			if Cerebro.modules.Communication then
				Cerebro.modules.Communication:AnnounceSelf()
			end
		end, 2)
	end
end


function Communication:SyncToRaid()
	-- Officers periodically sync data to raid
	if not Cerebro:IsOfficer() then return end
	
	-- Quarantine guard with log dedup (avoid spam from 30s heartbeat)
	if self:IsQuarantined() then
		if (time() - lastQuarantineLog) > QUARANTINE_WINDOW then
			Cerebro:Debug("SyncToRaid: skipped (quarantine active)")
			lastQuarantineLog = time()
		end
		return
	end
	
	-- Delta Sync v2: Skip if no dirty players and not time for full snapshot
	local s = Cerebro.db.global.syncState or {}
	local now = GetServerTime and GetServerTime() or time()
	local fullSyncInterval = 600  -- 10 minutes
	local timeSinceFullSync = now - (s.lastFullSyncAt or 0)
	local forceFullSync = timeSinceFullSync >= fullSyncInterval
	
	-- Skip sync if no dirties and not time for full (conditional sync)
	if not forceFullSync and not Cerebro:HasDirtyPlayers() then
		Cerebro:Debug("SyncToRaid: skipped (no dirty players, not full sync window)")
		return
	end

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

	-- Build sync payload (delta or full)
	local syncData = self:BuildSyncPayload(forceFullSync)

	-- Bug Fix: Add guild fallback for Force Sync when not in raid/party
	if IsInRaid() then
		self:BroadcastToRaid(MSG.SYNC_DATA, syncData)
	elseif IsInGroup() then
		self:SendMessage(MSG.SYNC_DATA, syncData, "PARTY")
	elseif IsInGuild() then
		self:BroadcastToGuild(MSG.SYNC_DATA, syncData)
		Cerebro:Debug("Synced to guild (not in raid/party)")
	end
	
	-- Delta Sync v2: Update state after sync
	if syncData.mode == "delta" then
		-- For broadcast syncs, retain dirties for 2 minutes in case of missed receivers
		-- They'll be cleared on full sync or by timer
		s.dirtyRetainUntil = now + 120
		Cerebro:Debug("Delta sync sent, dirties retained until " .. (s.dirtyRetainUntil or 0))
	else
		-- Full sync: clear all dirties
		Cerebro:ClearAllDirty()
		s.lastFullSyncAt = now
		Cerebro:Debug("Full sync sent, cleared all dirties")
	end
end

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

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 = pData.pr,
					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,
				}
			end
		end
	end
	
	local syncData = {
		version = Cerebro.version,
		epoch = Cerebro.db.global.resetEpoch or 0,
		scope = Cerebro.db.global.scope,
		purgedPlayers = Cerebro.db.global.purgedPlayers,
		-- 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
	
	return syncData
end

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

function Communication:HandlePlayerPurge(data, sender)
	-- Only accept from officers
	if not Cerebro:IsOfficer(sender) then 
		Cerebro:Debug("Ignoring purge from non-officer: " .. sender)
		return 
	end
	
	local playerName = data.player
	local purgeTime = data.timestamp or time()
	
	-- Update purge ledger
	Cerebro.db.global.purgedPlayers = Cerebro.db.global.purgedPlayers or {}
	Cerebro.db.global.purgedPlayers[playerName] = purgeTime
	
	-- Delete from players if exists
	if Cerebro.db.global.players[playerName] then
		Cerebro.db.global.players[playerName] = nil
		Cerebro:Debug("Received purge for: " .. playerName)
	end
	
	-- Refresh UI
	Cerebro:RefreshAllUI()
end

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

-- Handle SYNC_ACK from receiver (used for WHISPER syncs)
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
	
	local pending = s.pendingSyncs[seq]
	if not pending then
		Cerebro:Debug("SYNC_ACK for unknown seq " .. seq .. " from " .. sender)
		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

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

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)
	-- Only accept from officers
	if not Cerebro:IsOfficer(sender) then 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
	
	-- 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

--[[=============================================
	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
===============================================]]

function Communication:BroadcastSessionStart(sessionID, raidName)
	self:BroadcastToRaid(MSG.SESSION_START, {
		sessionID = sessionID,
		raidName = raidName,
		timestamp = time(),
	})
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

function Communication:BroadcastSessionEnd(sessionID)
	self:BroadcastToRaid(MSG.SESSION_END, {
		sessionID = sessionID,
		timestamp = time(),
	})
end

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

	Cerebro.db.global.currentSession = nil
	Cerebro:Print("|cffff8800Raid session ended.|r Thanks for raiding!")
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
===============================================]]

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

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

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

function Communication:AnnounceSelf()
    -- Broadcast presence to guild so officers can see pending players
    if not IsInGuild() then 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
    
    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:BroadcastToGuild(MSG.ANNOUNCE_SELF, {
            version = Cerebro.version
        })
        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
    
    -- 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()
    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)))
            
            -- FIX: Broadcast updated standings to guild after ingesting receipts
            -- Without this, other raiders never see EP changes from M+ runs
            Cerebro:ScheduleTimer(function()
                self:SyncToRaid()
            end, 2)  -- 2s delay to batch multiple concurrent receipts
        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.
==============================================]]

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)
==============================================]]

function Communication:BroadcastTransaction(entry)
    self:BroadcastToOfficers(MSG.BROADCAST_TRANSACTION, {
        entry = entry
    })
end

function Communication:HandleBroadcastTransaction(data, sender)
    if not Cerebro:IsOfficer() then return end
    -- Ignore self (though AceComm usually filters self unless looped)
    if sender == Cerebro:GetPlayerFullName() then return end
    
    if data.entry then
        Cerebro.Ledger:MergeTransaction(data.entry)
    end
end

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)
==============================================]]

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


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

return Communication


